diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..ac1b94c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,7 @@ +# Commits that only reformat code; `git blame` skips them when configured +# with `git config blame.ignoreRevsFile .git-blame-ignore-revs` (GitHub's +# blame view reads this file automatically). + +# ci-and-build-tooling: one-time `cargo fmt --all` with the committed +# rustfmt.toml (max_width = 150, use_small_heuristics = "Max"). +58a7792b2404d3ca04d6e66478fe25183ee8d2e1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..957ebf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +# Continuous integration for FileCommand (build-tooling capability; design D1). +# +# `windows` and `msrv` are the required checks (branch protection on `main` +# lists them). `linux` is best-effort and never blocks a merge: non-Windows +# builds are best-effort per the project charter. +# +# The stable toolchain is pinned to one version so a Rust release cannot turn +# `-D warnings` red on an unchanged branch. Bump RUST_TOOLCHAIN deliberately, +# in a PR that also fixes whatever new lints the release brings. + +name: CI + +on: + pull_request: + push: + branches: [main] + +env: + RUST_TOOLCHAIN: "1.98.1" + CARGO_TERM_COLOR: always + +jobs: + windows: + name: windows + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pinned stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Format check + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings + + - name: Test + run: cargo test --workspace --locked + + - name: Doc + run: cargo doc --workspace --no-deps --locked + env: + RUSTDOCFLAGS: -D warnings + + msrv: + name: msrv + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + # The floor is whatever `[workspace.package] rust-version` says, so the + # manifest and this job cannot drift apart. + - name: Read rust-version from Cargo.toml + id: msrv + shell: bash + run: | + version=$(sed -n 's/^rust-version = "\([^"]*\)"/\1/p' Cargo.toml) + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Install rust-version toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ steps.msrv.outputs.version }} + + - uses: Swatinem/rust-cache@v2 + + - name: Check on the declared minimum toolchain + run: cargo check --workspace --all-targets --locked + + linux: + name: linux (best-effort) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Install pinned stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --workspace --locked + + - name: Test + run: cargo test --workspace --locked diff --git a/Cargo.lock b/Cargo.lock index 534bace..38c649c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,9 +295,9 @@ name = "filecommand-core" version = "0.1.4" dependencies = [ "git2", - "memmap2", "proptest", "unicode-width 0.1.14", + "windows-sys 0.59.0", ] [[package]] @@ -309,9 +309,11 @@ dependencies = [ "crossterm", "filecommand-core", "insta", + "libc", "proptest", "ratatui", "unicode-width 0.1.14", + "windows-sys 0.59.0", ] [[package]] @@ -394,8 +396,6 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", - "openssl-sys", "url", ] @@ -620,27 +620,11 @@ name = "libgit2-sys" version = "0.17.0+1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" -dependencies = [ - "cc", - "libc", - "libssh2-sys", - "libz-sys", - "openssl-sys", - "pkg-config", -] - -[[package]] -name = "libssh2-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c04141a07bb0c0bc461cb657808764de571702a59bc5c726c400ac9a7625e3ab" dependencies = [ "cc", "libc", "libz-sys", - "openssl-sys", "pkg-config", - "vcpkg", ] [[package]] @@ -697,15 +681,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -826,24 +801,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" diff --git a/Cargo.toml b/Cargo.toml index 694059c..fd8ea6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,11 @@ members = ["crates/filecommand-core", "crates/filecommand-tui"] [workspace.package] version = "0.1.4" edition = "2021" +# Minimum supported Rust version. Verified empirically: the lowest toolchain +# on which `cargo check --workspace --all-targets --locked` passes, and +# re-checked by the `msrv` CI job on every pull request (ci-and-build-tooling +# design D3). +rust-version = "1.88" license = "MIT OR Apache-2.0" authors = ["The FileCommand Authors"] repository = "https://github.com/kkaminsk/FileCommand" @@ -15,5 +20,60 @@ ratatui = "0.29" crossterm = "0.28" insta = "1" proptest = "1" -memmap2 = "0.9" -git2 = "0.19" +# Local repository state only (`discover`, `head`, `statuses`): no SSH or +# HTTPS transport, so libssh2 and, on Unix, OpenSSL drop out of the build +# (ci-and-build-tooling design D5). +git2 = { version = "0.19", default-features = false } +# Windows-only binding crate shared by both crates. The feature list is the +# union of what the workspace calls (ci-and-build-tooling design D6): +# - Win32_Foundation: HANDLE, BOOL, SYSTEMTIME, CloseHandle, GetLastError, +# INVALID_HANDLE_VALUE and the ERROR_* codes +# - Win32_Storage_FileSystem: CreateFileW, GetFileInformationByHandle, +# FindFirstStreamW/FindNextStreamW/FindClose, GetLogicalDrives, +# GetVolumeInformationW, GetDiskFreeSpaceExW, CopyFileExW, ReplaceFileW +# and their constants +# - Win32_Security: required by `windows-sys` to compile CreateFileW +# (its SECURITY_ATTRIBUTES parameter) +# - Win32_System_Diagnostics_Debug: SetThreadErrorMode, SEM_FAILCRITICALERRORS +# - Win32_System_SystemInformation: GlobalMemoryStatusEx, MEMORYSTATUSEX, +# GetLocalTime +# - Win32_System_SystemServices: FILE_NAMED_STREAMS +# - Win32_System_WindowsProgramming: COPY_FILE_* and PROGRESS_* for +# CopyFileExW +# - Win32_System_Console: the console control handler +# (process-lifecycle-hardening) +# - Win32_System_Com: CoInitializeEx/CoUninitialize around ShellExecuteW +# (offload-blocking-io) +# - Win32_UI_Shell, Win32_UI_WindowsAndMessaging: ShellExecuteW +# (open-with-associated-app) +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Security", + "Win32_System_Diagnostics_Debug", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_WindowsProgramming", + "Win32_System_Console", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } + +[workspace.lints.clippy] +# Every `unsafe` block carries a `// SAFETY:` comment stating the invariant +# that makes it sound; with `-D warnings` in CI an undocumented one fails the +# pull request (ci-and-build-tooling design D6). +undocumented_unsafe_blocks = "warn" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +# `panic` deliberately stays at its default, "unwind" (ci-and-build-tooling +# design D4). Three things depend on unwinding: the terminal-restoring RAII +# guard and chained panic hook in `filecommand-tui::terminal` must run on a +# main-thread panic; worker threads isolate their panics with `catch_unwind` +# so one bad listing or job ends that thread, not the process; and the +# `panic_restoration` integration test checks that order. `panic = "abort"` +# would kill the process with the terminal left in raw mode. diff --git a/README.md b/README.md index d69963d..248d9db 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ and supported. - [File operations](#file-operations) - [Viewer (F3) and editor (F4)](#viewer-f3-and-editor-f4) - [Themes](#themes) +- [Diagnostics](#diagnostics) - [Project layout](#project-layout) - [Testing](#testing) - [Contributing](#contributing) @@ -71,7 +72,13 @@ signing) and the winget package template. ## Building from source -Requires the [Rust toolchain](https://rustup.rs) (stable). +Requires the [Rust toolchain](https://rustup.rs), stable **1.88 or newer**. +The workspace declares `rust-version = "1.88"`, so an older toolchain stops +with cargo's standard message naming the required version. Windows is the +primary target; Linux and macOS builds are best-effort. They need a C +compiler (for the vendored libgit2 and zlib) but **no OpenSSL development +headers**, because `git2` is built without its network transports — git info +only reads local repository state. ```powershell git clone https://github.com/kkaminsk/FileCommand.git @@ -79,6 +86,9 @@ cd FileCommand cargo build --release ``` +Release builds use thin LTO, a single codegen unit and stripped symbols; +panics still unwind, which the terminal-restoring guard relies on. + The binary is produced at `target\release\filecommand.exe`. Run it directly, or via Cargo: @@ -101,16 +111,41 @@ The workspace has two crates: | `--theme ` or `--theme=` | Launch with a specific theme for this session, overriding the saved `theme =` in `config.toml`. Built-in names: `nc-classic`, `nc-mono`, `terminal-green`, `purple-lights`, `yellow-storm`, `inverted`. | | `--nosplash` | Skip the startup splash screen, overriding `splash = true` in `config.toml`. | | `--nomouse` | Disable mouse capture for this session, overriding `[mouse] enabled` in `config.toml`. | +| `--noanimations` | Disable the decorative UI animations (splash entrance, menu and dialog openings, panel reveal) for this session, overriding `animations = true` in `config.toml`. | +| `--config-dir ` or `--config-dir=` | Use `` as the profile directory holding `config.toml`, `history.json`, and `usermenu.toml` for this session, overriding portable mode and the platform default (see [Configuration](#configuration)). | +| `--log ` or `--log=` | Append diagnostic messages to `` for this session (see [Diagnostics](#diagnostics)). Overrides the `FILECOMMAND_LOG` environment variable. | Flags can be combined in any order, e.g. `filecommand --nosplash --theme=purple-lights`. ## Configuration -FileCommand reads and writes its files in the current working directory -(the directory it's launched from), not a fixed profile directory. Missing -files are created with defaults on first run; a malformed file produces a -startup warning and falls back to defaults rather than being silently -overwritten. +FileCommand reads and writes all of its files in one **profile directory**, +resolved once at startup in this order: + +1. `--config-dir ` — an explicit override for testing and scripting + (a relative path resolves against the launch directory). +2. **Portable mode** — when a file named `portable` exists next to the + executable, the executable's own directory is the profile directory. +3. The platform default — `%APPDATA%\FileCommand` on Windows, + `$XDG_CONFIG_HOME/filecommand` (falling back to + `~/.config/filecommand`) elsewhere. `--help` prints the resolved + default. + +The directory is created on first run. If it can't be created, the session +runs on in-memory defaults with saving turned off, and a startup warning +names the path and the error. Because `history.json` lives in one place, +command history and the Ctrl+J fuzzy-jump frecency list are shared no +matter which directory FileCommand is launched from. + +**Coming from a per-directory layout?** Earlier versions read and wrote +these files in the launch directory. FileCommand never moves or deletes +the old files: when the profile directory has no `config.toml` yet and the +launch directory still holds any of the three, a startup notice says where +FileCommand now reads from so you can move them yourself. + +Missing files are created with defaults on first run; a malformed file +produces a startup warning and falls back to defaults rather than being +silently overwritten. ### `config.toml` @@ -119,14 +154,12 @@ table below), one setting per line: ```toml splash = true +animations = true theme = "nc-classic" shell = "cmd.exe /C" editor = "code -w" panel_split = 50 -[mouse] -enabled = true - key.paste_name = "ctrl+enter" key.paste_path = "ctrl+]" key.quick_filter = "ctrl+p" @@ -136,12 +169,24 @@ key.split_right = "ctrl+right" key.split_reset = "ctrl+=" key.clipboard_files = "ctrl+c" key.clipboard_paths = "ctrl+shift+insert" +key.clear_line = "ctrl+y" + +[mouse] +enabled = true ``` +Top-level settings must come before any `[table]`: a `key.*` line placed +after `[mouse]` belongs to that table and is ignored (with a warning). A +`#` outside quotes starts a comment, on its own line or after a value +(`splash = false # straight to the panels`); quote any value that itself +contains ` #`. A UTF-8 byte-order mark (Notepad's "UTF-8 with BOM") is +accepted. + | Key | Meaning | Default | |---|---|---| | `splash` | Show the startup splash screen. | `true` | -| `theme` | Active theme name (built-in or a file in `themes/`). | `nc-classic` | +| `animations` | Run the decorative ASCII animations: the splash entrance, the F9 bar sweep, pop-up menus unfolding, dialogs opening, and the panel reveal after the splash. `false` shows every surface in its final form immediately. Loading feedback (directory listings, progress counters) is never animated. | `true` | +| `theme` | Active theme name (one of the six built-ins — see [Themes](#themes)). | `nc-classic` | | `shell` | Shell command line used by the file-action menu's Run entry and the F2 user menu. | `cmd.exe /C` on Windows, `/bin/sh -c` elsewhere | | `editor` | External editor command for F4; unset means "use the built-in editor". | unset | | `panel_split` | Left-panel width as a percentage. | `50` | @@ -149,17 +194,14 @@ key.clipboard_paths = "ctrl+shift+insert" | `key.*` | Overridable key bindings (see table below). | see below | Only the bindings listed above are configurable; the rest of the key map -(F-keys, Tab, arrows, Ctrl+T/W, etc.) is fixed. Unknown keys are ignored -rather than rejected, and the schema tolerates missing/malformed lines. - -### `themes/*.toml` - -User-defined themes. Each theme maps the role names documented in the design -spec (`panel.frame`, `panel.cursor`, `dialog.primary`, ...) to ANSI-16 color -names (`black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, -and `bright-` variants), with an optional per-role `#RRGGBB` truecolor -override. Switch themes at runtime via **Options → Themes**, or pin one at -launch with `--theme`. +(F-keys, Tab, arrows, Ctrl+T/W, etc.) is fixed. A missing `config.toml` +is silent, but anything FileCommand cannot use — an unknown key or table, +a line that is not `key = value`, a setting under the wrong table, a +boolean or key binding it cannot read, or a file that is not UTF-8 text — +produces a one-line startup warning naming the file and line (with a +`(+N more)` count when there are several); the rest of the file still +applies. The warning dialog is dismissed with any key, and FileCommand +starts regardless. With `--log`, every warning is written to the log. ### `usermenu.toml` @@ -175,14 +217,31 @@ label = "Directory listing" command = "dir" ``` +Comments (`# …`, including after a value or after `[[entry]]`) and a UTF-8 +byte-order mark are accepted. An unknown key in an entry, a key outside an +`[[entry]]` block, or an entry missing `label` or `command` produces a +startup warning naming the line, and the rest of the menu still loads. + Unlike `config.toml`, a malformed `usermenu.toml` is treated as an error: it falls back to a default menu and shows a warning rather than being silently -overwritten. +overwritten. A file that is not UTF-8 text (saved as ANSI or UTF-16) is +reported the same way — re-save it as UTF-8, or delete it to recreate the +defaults — and is never overwritten. ### `history.json` -Command-line history and fuzzy-jump directory frecency data, written -atomically so a crash mid-write never corrupts it. Not meant to be hand-edited. +Command-line history and fuzzy-jump directory frecency data. Not meant to +be hand-edited. Navigation never waits on the disk: changes are written in +the background a few seconds after a burst of navigation settles (and +within ten seconds at most), and anything still pending is written when +FileCommand quits. Each write goes to a flushed temporary file that then +atomically replaces `history.json`, so an interruption leaves either the +previous or the new complete file. Before writing, FileCommand merges its +history with the file on disk, so two instances sharing a profile keep +each other's commands and directories. The directory history is capped at +1,000 entries (the least frecent are dropped first); visit times are +wall-clock, so Ctrl+J ranking stays meaningful across restarts, and a file +from an earlier version is migrated on first load. ## Using FileCommand @@ -211,7 +270,9 @@ atomically so a crash mid-write never corrupts it. Not meant to be hand-edited. with a "terminal too small" placeholder and the F-key bar degrades through progressively shorter forms as width shrinks. - The startup splash (product name/version banner) is the very first frame - unless `--nosplash`/`splash = false`; any key dismisses it early. + unless `--nosplash`/`splash = false`; any key dismisses it early. Its box + traces in and its lines reveal within the 800 ms hold; turn that and the + menu/dialog motion off with `--noanimations`/`animations = false`. ### Command line @@ -229,26 +290,42 @@ programs, see [Running programs](#running-programs). You don't need to focus the command line. While the panels have the keyboard (no dialog, menu, type-ahead jump, or Ctrl+P quick filter open), -every plain printable key is added to the command line and the panel cursor -stays put. Text is only ever added at the end; there is no cursor to move -within the line. +every plain printable key is inserted into the command line at the caret +and the panel cursor stays put. The caret moves with ←/→ while the line +has text, so fixing one character in the middle of a command never means +retyping its tail. Some keys mean different things depending on whether the line is empty: | Key | Line empty | Line has text | |---|---|---| -| Printable character | Starts the line | Added to the end | +| Printable character | Starts the line | Inserted at the caret | | `+` / `-` / `*` | Select / deselect by wildcard, invert selection | Typed as text | -| Backspace | Go to the parent directory | Delete the last character | +| Backspace | Go to the parent directory | Delete the character before the caret | +| ← / → | Nothing | Move the caret | +| Ctrl+Backspace | Go to the parent directory | Delete the word before the caret | | ↑ / ↓ | Move the panel cursor | Recall older / newer history | | Enter | Act on the cursor entry (open a directory, file-action menu for a file) | Run the line | -| Ctrl+Enter / Ctrl+] | Paste the cursor entry's name / full path | Same, after a separating space | +| Ctrl+Enter / Ctrl+] | Paste the cursor entry's name / full path | Same, after a separating space; caret lands at the end | +| Ctrl+V | Paste the clipboard's text at the caret | Same (line breaks become spaces) | +| Ctrl+Y | Nothing | Clear the line (ends any history recall) | | Esc | Ask to quit | Ask to quit (cancelling keeps your text) | All other keys keep their panel meaning while you type. Home/End and -PgUp/PgDn move the panel cursor, and Tab switches panels without clearing the -line. **Delete opens the delete confirmation for the cursor entry; it does -not delete a character.** To clear the line, backspace it until it's empty. +PgUp/PgDn move the panel cursor even mid-composition, Ctrl+←/→ adjust the +panel split, and Tab switches panels without clearing the line. **Delete +opens the delete confirmation for the cursor entry; it does not delete a +character.** To clear the line, press Ctrl+Y (rebindable via +`key.clear_line` in [`config.toml`](#configtoml)), or backspace it until +it's empty. + +The same editing keys work in every text field — the F5/F6/F7 destination +and name fields, the file-action menu's Rename, an overwrite-conflict's +Rename, the Alt+F7 find pattern, and the Ctrl+J fuzzy-jump pattern. There +Home/End and Delete *are* editing keys (jump the caret to the start/end, +delete the character after it), Ctrl+←/→ move the caret by words, +Ctrl+Backspace/Ctrl+Delete delete words, and both Shift+Ins and Ctrl+V +paste the clipboard's text at the caret. #### Built-in commands @@ -341,11 +418,22 @@ both with `key.paste_name` and `key.paste_path` in #### Running programs The command line can't run programs, so `dir`, `notepad notes.txt`, and -`git status` are all rejected. There are two ways to launch something: - -- **The file-action menu.** Press Enter (or right-click) on an executable - (any `PATHEXT` extension, or a `.lnk`) and choose **Run**. Enter never - starts an executable directly. +`git status` are all rejected. There are three ways to launch something: + +- **Open.** Press Enter on a file and choose **Open** — the first, + highlighted entry for a non-executable (so Enter, Enter opens a + document), second under **Run** for an executable; on a directory + (right-click menu) Open shows the directory in the system file browser. + Open launches the target through the platform's file association + (`ShellExecute` with the `open` verb on Windows, `xdg-open`/`open` + elsewhere) **without suspending FileCommand and without waiting** — a GUI + program simply appears beside the terminal, and a launch failure (no + association for the extension, a missing target) is reported on the + panel's status line. +- **The file-action menu's Run.** Press Enter (or right-click) on an + executable (any `PATHEXT` extension, or a `.lnk`) and choose **Run** — + the first entry for executables. Enter never starts an executable + directly. - **The F2 user menu.** Add an entry to [`usermenu.toml`](#usermenutoml): ```toml @@ -357,10 +445,15 @@ The command line can't run programs, so `dir`, `notepad notes.txt`, and The command string goes to the configured `shell` exactly as written, with no placeholder substitution. -Either way, FileCommand suspends its screen and runs the command in the -active panel's directory using the `shell` from `config.toml` (`cmd.exe /C` -by default; PowerShell adds about 200 ms or more of startup per command). -When the command finishes, press a key to return. Afterwards, **Ctrl+O** +Run and the user menu suspend FileCommand's screen and run the command in +the active panel's directory using the `shell` from `config.toml` +(`cmd.exe /C` by default; PowerShell adds about 200 ms or more of startup +per command) — the suspension is what makes a console program's output +visible, so Run remains the right choice for console tools. (Open on a file +type associated with a *console* program — say `.py` → `python.exe` — +attaches it to FileCommand's console instead, and its output interleaves +with the panels.) When the command finishes, press a key to return. +Afterwards, **Ctrl+O** hides the panels so you can read the output in your terminal's scrollback, and any key brings the panels back. FileCommand doesn't keep its own copy of command output; only your terminal's scrollback does. @@ -373,7 +466,7 @@ command output; only your terminal's scrollback does. |---|---| | ↑ / ↓ / PgUp / PgDn / Home / End | Move cursor (page size follows panel height) | | Tab | Switch active panel | -| Enter | Directory: enter it. `..`: go to parent. File: opens the file-action menu (Run/View/Edit/Send to clipboard/Copy/Rename/Move/Delete). Command line non-empty: dispatches the built-in verbs — `cd` navigates, `del`/`rmdir` open delete-confirmation, anything else is rejected. Tree mode: navigate/expand. | +| Enter | Directory: enter it. `..`: go to parent. File: opens the file-action menu (Open/View/Edit/Send to clipboard/Copy/Rename/Move/Delete; executables list Run first, then Open). Command line non-empty: dispatches the built-in verbs — `cd` navigates, `del`/`rmdir` open delete-confirmation, anything else is rejected. Tree mode: navigate/expand. | | Backspace (empty command line) | Parent directory | | Ctrl+PgUp | Parent directory | | Ins | Toggle selection at cursor, cursor advances | @@ -386,6 +479,7 @@ command output; only your terminal's scrollback does. | Ctrl+L | Toggle Info display mode on the active panel | | Ctrl+O | Show terminal scrollback (leaves the alternate screen; any key returns) | | Ctrl+T / Ctrl+W | New tab / close tab on the active panel | +| Ctrl+U | Swap the two panels' entire state (directories, tabs, listings, cursors, selections, modes); the active side and split stay put | | Alt+1..9 | Switch to tab *n* on the active panel | | Ctrl+←/→ | Shrink / grow the left panel (adjust the split) | | Ctrl+= | Reset the split to 50/50 | @@ -418,10 +512,26 @@ command output; only your terminal's scrollback does. | F10 or Esc | Request quit (Y/N confirmation) | | Esc (in a dialog/menu/overlay) | Cancel or close it | | Up / Down (command line non-empty) | Recall previous/next command-line history | +| Alt+F8 | Command-history dialog: newest first; Enter places the highlighted entry on the command line (it does not run it), Esc closes | +| Left / Right (command line non-empty) | Move the command-line caret | +| Ctrl+Backspace (command line non-empty) | Delete the word before the command-line caret | +| Ctrl+V | Paste the clipboard's text into the command line (or the focused dialog field) | + +**Text fields** (destination/name dialogs, Rename, overwrite-conflict Rename, +Alt+F7 find pattern, Ctrl+J fuzzy-jump pattern) + +| Key | Action | +|---|---| +| ← / → / Home / End | Move the caret one char / to the start / to the end | +| Backspace / Delete | Delete the character before / after the caret | +| Ctrl+← / Ctrl+→ | Move the caret one word back / forward | +| Ctrl+Backspace / Ctrl+Delete | Delete the word before / after the caret | +| Shift+Ins or Ctrl+V | Paste the clipboard's text at the caret (line breaks become spaces) | All of the bindings marked overridable in [Configuration](#configuration) (`paste_name`, `paste_path`, `quick_filter`, `fuzzy_jump`, `split_left`, -`split_right`, `split_reset`, `clipboard_files`, `clipboard_paths`) can be +`split_right`, `split_reset`, `clipboard_files`, `clipboard_paths`, +`clear_line`) can be remapped in `config.toml`; the rest of the table above is fixed. ### Mouse reference @@ -430,8 +540,8 @@ Enabled by default; disable with `[mouse] enabled = false` or `--nomouse`. Mouse is only honored in contexts a key press would also reach (panels, the key bar, menu titles/items, and dialog buttons) — it does nothing in the viewer/editor beyond wheel scrolling, and nothing at all while an -unsupported overlay (drive select, fuzzy jump, find file, user menu, theme -picker, help, startup warning) is open. +unsupported overlay (drive select, fuzzy jump, find file, user menu, +command history, theme picker, help, startup warning) is open. | Gesture | Action | |---|---| @@ -439,7 +549,7 @@ picker, help, startup warning) is open. | Ctrl+left click on an entry | Toggle that entry's selection in place | | Double left click on an entry | Same as Enter | | Left click on empty panel area / title | Focus that panel | -| Right click on an entry | Open the file-action menu for it | +| Right click on an entry | Open the file-action menu for it (same entries as Enter for a file; a directory lists Open/Send to clipboard/Copy/Rename/Move/Delete, with Open showing it in the system file browser) | | Left-drag from an entry onto a target | Propose a **Copy** | | Right-drag, or Shift+drag, from an entry | Propose a **Move** | | Esc during a drag | Cancel the drag | @@ -451,18 +561,17 @@ picker, help, startup warning) is open. ### Pull-down menus (F9) Five menus, navigated with ←/→ (between menus), ↑/↓ (within one), Enter, -Esc, or a hotkey letter: +Esc, or a hotkey letter. Every listed item works: - **Left / Right** (mirror each other, acting on their own panel): display - mode (Brief/Full/Tree/Quick view/Info), sort mode, re-read, drive select, - new/close tab. + mode (Brief/Full/Tree/Quick view/Info), sort mode, Filter (the panel's + quick filter), re-read, drive select, new/close tab. - **Files**: View, Edit, Copy, Rename/Move, Make directory, Delete, copy to clipboard (files/paths/names), Select/Deselect/Invert group, Quit. -- **Commands**: Find file, Fuzzy jump, Panels on/off. (History, Swap panels, - Compare directories, and Menu file edit are listed but not yet - implemented — greyed out.) -- **Options**: Themes (opens the live theme picker). (Configuration, Editor - selection, and Save setup are listed but not yet implemented.) +- **Commands**: Find file, History (the Alt+F8 command-history dialog), Swap + panels (Ctrl+U), Panels on/off, Fuzzy jump, Menu file edit (opens + `usermenu.toml` in the F4 editor and reloads the F2 menu on return). +- **Options**: Themes (opens the live theme picker). ### Panel display modes @@ -474,6 +583,15 @@ Esc, or a hotkey letter: | **Tree** | Lazily-expanded directory tree of the current drive; moving the cursor updates the *opposite* panel's listing | | **Quick view** | Live preview (text head) of the file under the *opposite* panel's cursor | +In Full mode, numeric sizes are right-aligned in the Size column (the Norton +Commander convention) and every value renders complete — `▶SUB-DIR◀`, +`MM-DD-YY` dates, and `HH:MM` times are never truncated. + +Hidden and system entries are still listed — there is no show/hide toggle — +but render dimmed in both Full and Brief modes: on Windows, entries carrying +the hidden or system attribute; on other platforms, names beginning with +`.`. Selection and the cursor bar take precedence over the dimming. + ### File operations Copy (F5), Move/Rename (F6), Mkdir (F7), and Delete (F8) run as cancellable @@ -487,14 +605,22 @@ bin — deletes are permanent, and the confirmation dialog says so. ### Viewer (F3) and editor (F4) -- **Viewer** — read-only, opens instantly at any file size (streams/memory-maps - rather than indexing). F2 toggles wrap, F4 toggles text/hex mode, F7 - searches, F10/Esc closes. +- **Viewer** — read-only, opens instantly at any file size (reads bounded + windows rather than indexing). F2 toggles wrap, F4 toggles text/hex mode, + F7 searches, F10/Esc closes. - **Editor** — F4 opens your configured external editor if `editor =` is set in `config.toml`; otherwise the built-in editor (files under 10 MB; larger - files open in the viewer instead). F2 saves, F3 marks a line-selection - anchor, F4 opens search-and-replace, F7 searches, Ctrl+X/C/V cut/copy/paste, - Ctrl+Z undoes, F10 quits (prompting to save if modified). + files open in the viewer instead). The built-in editor edits UTF-8 text + only: a file that is not valid UTF-8, is UTF-16, or contains NUL bytes + opens in the viewer with a notice instead (a UTF-8 byte-order mark is fine + and is kept on save). F2 saves, F3 marks a line-selection anchor, F4 opens + search-and-replace, F7 searches, Ctrl+X/C/V cut/copy/paste, Ctrl+Z undoes, + F10 quits (prompting to save if modified). Saves are atomic — the new + content is written to a temporary file and flushed before it replaces the + original, so a crash or a full disk never leaves a truncated file — and + keep the file's attributes, creation time and (on Windows) alternate data + streams. Read-only files are not saved; the editor says so and leaves them + untouched. ### Themes @@ -503,7 +629,35 @@ Six built-in themes: `nc-classic` (default, the authentic NC palette), and `inverted`. Switch live via **Options → Themes** (arrow keys preview each theme before you commit) or the F2 user menu; the choice persists to `config.toml`. Pin a theme for a single session with `--theme ` -without touching the saved default. +without touching the saved default. Every theme defines the full role set, +including a dedicated `panel.hidden` role — a visibly dimmer variant of the +theme's file color, used for hidden/system entries in the panels. + +## Diagnostics + +FileCommand keeps an **opt-in diagnostic log** for troubleshooting. It is +off by default — nothing is created or written unless you ask for it: + +- `filecommand --log C:\temp\fc.log` (also accepted as `--log=`), or +- set the `FILECOMMAND_LOG` environment variable to the log path. + +When both are given, the flag wins. The file is opened in append mode, so +earlier sessions' lines are kept; each line starts with a UTC timestamp and +a short category. The log records the failures FileCommand otherwise +handles quietly: + +- `persist` — a failed write of `history.json` or `config.toml`; +- `channel` / `job` — a result or dialog answer that could not be delivered + between a worker thread and the event loop; +- `copy` — metadata that could not be restored on a copied file (alternate + data streams, modification time, read-only attribute); +- `startup` — every startup warning, including ones a later warning + replaced in the startup-warning dialog. + +If the log file cannot be opened, the session starts without logging and a +startup warning names the path and the reason. When reporting an issue, +attach the log file alongside the error text (see +[Reporting issues](#reporting-issues)). ## Project layout @@ -525,11 +679,33 @@ FileCommand/ cargo test --workspace ``` -Covers `filecommand-core` unit and property-based tests (file ops against -temp directories, sorting/filtering, selection semantics, config/theme -parsing, git info against fixture repos) and `filecommand-tui` ratatui -`TestBackend` snapshot tests (panels, dialogs, viewer/editor, splash, help, -menus) via `insta`. +Covers `filecommand-core` unit and property-based tests — the file-operation +engine against the in-memory `FakeFs` double (conflicts, error recovery, +cancellation, fault injection), sorting/filtering, selection semantics, +config/theme parsing, and git info against fixture repositories created in +temporary directories — plus the `RealFs` integration suite +(`crates/filecommand-core/tests/realfs.rs`), which runs the job engine +against the real filesystem in per-test temporary directories (round trips, +cancellation, read-only targets, Windows-specific cases). `filecommand-tui` +has ratatui `TestBackend` snapshot tests (panels, dialogs, viewer/editor, +splash, help, menus) via `insta`, plus integration tests for the binary's +flags and terminal restoration. + +Every pull request runs the same gates in CI (`.github/workflows/ci.yml`: +Windows, pinned stable toolchain, against the committed `Cargo.lock`). To +reproduce the required job locally: + +```powershell +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo test --workspace --locked +$env:RUSTDOCFLAGS = '-D warnings'; cargo doc --workspace --no-deps --locked +``` + +A second required job checks the workspace on the declared minimum Rust +version (`cargo check --workspace --all-targets --locked` on 1.88). A +best-effort Linux job builds and tests on `ubuntu-latest`; its result is +reported but never blocks a merge. ## Contributing diff --git a/crates/filecommand-core/Cargo.toml b/crates/filecommand-core/Cargo.toml index c8085ad..719a01a 100644 --- a/crates/filecommand-core/Cargo.toml +++ b/crates/filecommand-core/Cargo.toml @@ -2,15 +2,25 @@ name = "filecommand-core" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true authors.workspace = true repository.workspace = true description = "Platform-agnostic application logic for FileCommand (no terminal dependencies)" +[lints] +workspace = true + [dependencies] unicode-width = { workspace = true } -memmap2 = { workspace = true } git2 = { workspace = true } +[target.'cfg(windows)'.dependencies] +# All Win32 calls (drive enumeration, volume information, free space, memory +# status, file identity, alternate-data-stream enumeration, `CopyFileExW`, +# `ReplaceFileW`) go through the shared workspace binding; no prototype is +# hand-declared (ci-and-build-tooling design D6). +windows-sys = { workspace = true } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/filecommand-core/build.rs b/crates/filecommand-core/build.rs index a91443c..4b701dd 100644 --- a/crates/filecommand-core/build.rs +++ b/crates/filecommand-core/build.rs @@ -9,6 +9,17 @@ //! `CryptCreateHash`, and friends. Declare it explicitly so a plain `cargo //! build`/`cargo test` succeeds without every downstream crate having to //! carry this workaround (crate README "Build requirements"). +//! +//! The link list of `libgit2-sys 0.17` does not depend on cargo features, +//! and the objects that need `advapi32` are compiled whatever the features +//! are, so building `git2` with `default-features = false` (no SSH/HTTPS; +//! ci-and-build-tooling design D5) does not make this unnecessary. +//! Checked during that change by removing the line once on Rust 1.88 and +//! 1.98: the test binaries still linked, but only because a transitive +//! `winapi` build script in the dev-dependency graph happens to emit +//! `rustc-link-lib=dylib=advapi32` (`target/debug/build/winapi-*/output`). +//! That is incidental and can disappear with any dependency bump, so the +//! explicit line stays. fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { diff --git a/crates/filecommand-core/src/anim.rs b/crates/filecommand-core/src/anim.rs new file mode 100644 index 0000000..912d274 --- /dev/null +++ b/crates/filecommand-core/src/anim.rs @@ -0,0 +1,489 @@ +//! Render-only motion model for the decorative UI animations (ui-animations +//! "Render-only motion model driven by the reducer clock"). +//! +//! Every animation is a pure function of `elapsed = State::clock_ms - stamp`, +//! where `stamp` is the reducer clock reading recorded when the animated +//! surface opened or changed. This module owns only the arithmetic — per-mille +//! progress, reveal counts, the title-sweep position, and the dialog iris +//! rectangle — plus the named duration constants (design D6), so renderers +//! and tests reference one definition. It reads no clock and draws nothing. + +use crate::dialogs::OverlayRect; +use crate::update::{Overlay, State, UiPhase}; + +/// Full progress, in per-mille. Progress is carried as an integer so the +/// helpers below stay exact and snapshot-stable (no float rounding). +pub const FULL: u16 = 1000; + +/// Splash frame trace: perimeter cells revealed clockwise from the top-left +/// corner over `0..SPLASH_TRACE_MS`. +pub const SPLASH_TRACE_MS: u64 = 200; +/// When the first identity line starts revealing. +pub const SPLASH_IDENTITY_START_MS: u64 = SPLASH_TRACE_MS; +/// Delay between one identity line starting and the next. +pub const SPLASH_IDENTITY_STAGGER_MS: u64 = 80; +/// How long one identity line takes to reveal outward from its center. +pub const SPLASH_IDENTITY_LINE_MS: u64 = 120; +/// How long a freshly revealed identity character stays in `splash.frame` +/// before settling to its line's final role. +pub const SPLASH_IDENTITY_FLASH_MS: u64 = 66; +/// The product-name highlight sweep window in time. +pub const SPLASH_SWEEP_START_MS: u64 = 560; +pub const SPLASH_SWEEP_END_MS: u64 = 760; +/// Width of the sweeping `splash.version` window, in cells. +pub const SPLASH_SWEEP_WINDOW: u16 = 3; +/// After this the splash is identical to the non-animated splash — still +/// inside the unchanged minimum hold. +pub const SPLASH_ENTRANCE_MS: u64 = SPLASH_SWEEP_END_MS; +// The entrance must finish inside the existing hold (startup-splash "Splash +// entrance animation"; design D6) — enforced at compile time so a retimed +// constant can't silently outrun the splash. +const _: () = assert!(SPLASH_ENTRANCE_MS < crate::update::SPLASH_MIN_HOLD_MS); +/// Panels wipe in top-to-bottom after the splash. +pub const PANEL_WIPE_MS: u64 = 150; +/// F9 menu bar sweeps in left-to-right. +pub const MENU_BAR_SWEEP_MS: u64 = 100; +/// Pop-up menus (pull-downs, F2 user menu, Enter action menu) unfold. +pub const POPUP_UNFOLD_MS: u64 = 100; +/// A newly highlighted pop-up row renders in `menu.hotkey` this long. +pub const HIGHLIGHT_SETTLE_MS: u64 = 50; +/// Modal dialogs open through a centered iris. +pub const DIALOG_IRIS_MS: u64 = 100; + +/// The frame cadence [`next_wake_ms`] requests while an animation is in +/// flight — the ~33 ms cadence the event loop used to tick at even when +/// idle (idle-aware-event-loop design D2). +pub const FRAME_MS: u64 = 33; + +/// The earliest reducer-clock time at which `update(Tick)` or a render could +/// produce something different from the current frame (idle-aware-event-loop +/// design D2) — the event loop's "when must I wake even without input" +/// answer, so an idle screen is never redrawn and nothing timed is ever +/// late. Three deadline kinds, earliest first: +/// +/// - `clock_ms + FRAME_MS` while any stamped surface is still inside its +/// animation duration (only when `State::animations` is on — with them +/// off, every surface renders its final frame immediately); +/// - the splash hold's end while in `UiPhase::Splash` (regardless of +/// `animations` — the hold is not decorative); +/// - the earliest clipboard-feedback `expires_at_ms` still showing. +/// +/// `None` means nothing timed is pending: the loop may sleep until input or +/// the (TUI-side) header clock's next minute flip. +pub fn next_wake_ms(state: &State) -> Option { + let mut wake: Option = None; + if state.animations && animation_running(state) { + wake = Some(state.clock_ms + FRAME_MS); + } + if let UiPhase::Splash { started_at_ms } = &state.phase { + let hold_end = started_at_ms.saturating_add(crate::update::SPLASH_MIN_HOLD_MS); + wake = Some(wake.map_or(hold_end, |w: u64| w.min(hold_end))); + } + for panel in [&state.left, &state.right] { + if let Some(feedback) = &panel.clipboard_feedback { + wake = Some(wake.map_or(feedback.expires_at_ms, |w: u64| w.min(feedback.expires_at_ms))); + } + } + wake +} + +/// Whether any stamped surface is still inside its animation duration at +/// `state.clock_ms`, so the frame one `FRAME_MS` from now would differ from +/// the current one. The stamp inventory mirrors the renderers in +/// `filecommand-tui::views` one-to-one — a surface added there must be added +/// here, or it would freeze mid-animation while idle (idle-aware-event-loop +/// design Risks). +fn animation_running(state: &State) -> bool { + let now = state.clock_ms; + let within = |stamp: u64, duration: u64| now.saturating_sub(stamp) < duration; + // Pop-up menus (F9 pull-downs, F2 user menu, Enter action menu, Alt+F8 + // history) unfold top-down, and a just-moved highlight flashes for + // `HIGHLIGHT_SETTLE_MS`. + let popup = |opened_at_ms: u64, selected_at_ms: Option| { + within(opened_at_ms, POPUP_UNFOLD_MS) || selected_at_ms.is_some_and(|at| within(at, HIGHLIGHT_SETTLE_MS)) + }; + + if let UiPhase::Splash { started_at_ms } = &state.phase { + if within(*started_at_ms, SPLASH_ENTRANCE_MS) { + return true; + } + } + if let Some(revealed) = state.panels_revealed_at_ms { + if within(revealed, PANEL_WIPE_MS) { + return true; + } + } + if let Some(menu) = state.menu() { + if within(menu.opened_at_ms, MENU_BAR_SWEEP_MS) { + return true; + } + // The pull-down renders only while open, so only then do its unfold + // and highlight-settle stamps animate. + if menu.pulldown_open && popup(menu.pulldown_opened_at_ms, menu.selected_at_ms) { + return true; + } + } + if let Some(menu) = state.user_menu() { + if popup(menu.opened_at_ms, menu.selected_at_ms) { + return true; + } + } + if let Some(menu) = state.file_action_menu() { + if popup(menu.opened_at_ms, menu.selected_at_ms) { + return true; + } + } + if let Some(dialog) = state.history_dialog() { + if popup(dialog.opened_at_ms, dialog.selected_at_ms) { + return true; + } + } + // The dialog iris keys on `dialog_opened_at_ms`, which outlives the + // dialog itself — count it only while one of the dialogs that render + // through the iris is actually up (the same inventory + // `stamps::DialogMark` tracks). + if dialog_visible(state) && within(state.dialog_opened_at_ms, DIALOG_IRIS_MS) { + return true; + } + // The quit confirmation layers over any dialog with its own stamp. + state.quit_confirm && within(state.quit_opened_at_ms, DIALOG_IRIS_MS) +} + +/// Whether a modal dialog that irises in on `State::dialog_opened_at_ms` is +/// currently visible: the file-op phase dialogs (setup, running, summary) +/// plus the overlays rendered through `views`'s iris — drive select, fuzzy +/// jump, find file, theme picker, help, and the startup warning. The pop-up +/// menus animate on their own stamps and are not listed here. +fn dialog_visible(state: &State) -> bool { + matches!(state.phase, UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_)) + || matches!( + state.overlay(), + Some( + Overlay::DriveSelect(_) + | Overlay::FuzzyJump(_) + | Overlay::FindFile(_) + | Overlay::ThemePicker(_) + | Overlay::Help(_) + | Overlay::StartupWarning(_) + ) + ) +} + +/// Progress through `duration_ms` after `elapsed_ms`, in per-mille +/// (`0..=FULL`). Clamps at both ends; a zero duration is already complete. +pub fn progress(elapsed_ms: u64, duration_ms: u64) -> u16 { + if duration_ms == 0 || elapsed_ms >= duration_ms { + return FULL; + } + // elapsed < duration, so the quotient is < FULL and fits `u16`. + (elapsed_ms.saturating_mul(FULL as u64) / duration_ms) as u16 +} + +/// How many of `total` items are revealed at `progress` (per-mille): 0 at +/// progress 0, exactly `total` at `FULL`, never more. +pub fn reveal_count(progress: u16, total: usize) -> usize { + let p = progress.min(FULL) as usize; + total.saturating_mul(p) / FULL as usize +} + +/// Left edge of a `window`-cell highlight crossing a span `width` cells wide +/// at `progress`: fully off the left edge (`-window`) at progress 0, fully +/// off the right edge (`width`) at `FULL`, so the window enters and exits +/// the span rather than popping in or out. +pub fn sweep_pos(progress: u16, width: u16, window: u16) -> i32 { + let travel = width as i64 + window as i64; + let p = progress.min(FULL) as i64; + (-(window as i64) + travel * p / FULL as i64) as i32 +} + +/// The centered iris window onto a dialog whose finished rectangle is +/// `finish`: 2×1 at progress 0, exactly `finish` at `FULL`, never larger +/// than `finish` and always centered in it. A dialog narrower than two cells +/// or empty of rows keeps its own size rather than inflating. +pub fn iris_rect(finish: OverlayRect, progress: u16) -> OverlayRect { + let grow = |start: u16, end: u16| -> u16 { + let start = start.min(end); + let span = (end - start) as usize; + start + reveal_count(progress, span) as u16 + }; + let width = grow(2, finish.width); + let height = grow(1, finish.height); + OverlayRect { x: finish.x + (finish.width - width) / 2, y: finish.y + (finish.height - height) / 2, width, height } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_clamps_at_both_ends() { + assert_eq!(progress(0, 100), 0); + assert_eq!(progress(50, 100), 500); + assert_eq!(progress(99, 100), 990); + assert_eq!(progress(100, 100), FULL); + assert_eq!(progress(10_000, 100), FULL, "past the end stays complete"); + assert_eq!(progress(0, 0), FULL, "a zero duration is already complete"); + } + + #[test] + fn progress_is_monotonic() { + let mut last = 0; + for t in 0..=DIALOG_IRIS_MS + 20 { + let p = progress(t, DIALOG_IRIS_MS); + assert!(p >= last, "progress went backwards at {t} ms"); + last = p; + } + } + + #[test] + fn reveal_count_hits_the_boundaries_exactly() { + assert_eq!(reveal_count(0, 110), 0); + assert_eq!(reveal_count(FULL, 110), 110); + assert_eq!(reveal_count(500, 110), 55); + assert_eq!(reveal_count(500, 80), 40); + assert_eq!(reveal_count(FULL + 500, 10), 10, "over-range progress never reveals more than total"); + assert_eq!(reveal_count(500, 0), 0); + } + + #[test] + fn reveal_count_is_monotonic_and_bounded() { + for total in [0usize, 1, 2, 7, 19, 110] { + let mut last = 0; + for p in 0..=FULL { + let n = reveal_count(p, total); + assert!(n >= last && n <= total, "total {total} progress {p} -> {n}"); + last = n; + } + } + } + + #[test] + fn sweep_enters_and_exits_the_span() { + assert_eq!(sweep_pos(0, 11, 3), -3, "starts fully off the left edge"); + assert_eq!(sweep_pos(FULL, 11, 3), 11, "ends fully off the right edge"); + let mid = sweep_pos(500, 11, 3); + assert!(mid > -3 && mid < 11, "mid-sweep is part-way across: {mid}"); + } + + #[test] + fn iris_starts_two_by_one_and_ends_at_the_finished_rect() { + let finish = OverlayRect { x: 10, y: 5, width: 40, height: 9 }; + let start = iris_rect(finish, 0); + assert_eq!((start.width, start.height), (2, 1)); + assert_eq!(iris_rect(finish, FULL), finish); + } + + #[test] + fn iris_never_exceeds_the_finished_rect_and_stays_centered() { + for finish in [ + OverlayRect { x: 10, y: 5, width: 40, height: 9 }, + OverlayRect { x: 0, y: 0, width: 62, height: 19 }, + OverlayRect { x: 3, y: 2, width: 2, height: 1 }, + OverlayRect { x: 7, y: 7, width: 1, height: 1 }, + ] { + for p in 0..=FULL { + let r = iris_rect(finish, p); + assert!(r.x >= finish.x && r.y >= finish.y, "{r:?} escaped {finish:?} at {p}"); + assert!(r.x + r.width <= finish.x + finish.width, "{r:?} overran {finish:?} at {p}"); + assert!(r.y + r.height <= finish.y + finish.height, "{r:?} overran {finish:?} at {p}"); + assert!(r.width <= finish.width && r.height <= finish.height); + } + } + } + + #[test] + fn iris_midway_is_roughly_half_the_dialog() { + let finish = OverlayRect { x: 20, y: 6, width: 40, height: 11 }; + let half = iris_rect(finish, 500); + assert!((half.width as i32 - 21).abs() <= 1, "width {}", half.width); + assert!((half.height as i32 - 6).abs() <= 1, "height {}", half.height); + } + + #[test] + fn splash_timeline_is_contiguous() { + // The last identity line ends at 200 + 3*80 + 120 = 560, exactly where + // the title sweep starts (design D6); the sweep ends the entrance at + // 760, inside the 800 ms hold (checked at compile time above). + let last_line_end = SPLASH_IDENTITY_START_MS + 3 * SPLASH_IDENTITY_STAGGER_MS + SPLASH_IDENTITY_LINE_MS; + assert_eq!(last_line_end, SPLASH_SWEEP_START_MS); + } + + // ----------------------------------------------------------------- + // idle-aware-event-loop: `next_wake_ms` wake deadlines (design D2; + // task 1.2) + // ----------------------------------------------------------------- + + use crate::menu::MenuState; + use crate::panel::ClipboardFeedback; + use crate::theme::Theme; + + /// An animations-on panels state parked at clock `now`, with nothing + /// open and nothing stamped. + fn animated_state_at(now: u64) -> State { + let mut state = State::empty(Theme::classic()); + state.animations = true; + state.clock_ms = now; + state + } + + /// Assert the wake contract for one surface stamped at `t`: a frame wake + /// right after opening, still a frame wake just before the duration + /// ends, and nothing due once it has. + fn assert_surface(mut state: State, t: u64, duration: u64) { + state.clock_ms = t; + assert_eq!(next_wake_ms(&state), Some(t + FRAME_MS), "just opened: one frame out"); + state.clock_ms = t + duration - 1; + assert_eq!(next_wake_ms(&state), Some(t + duration - 1 + FRAME_MS), "still animating one ms before the end"); + state.clock_ms = t + duration; + assert_eq!(next_wake_ms(&state), None, "finished: no wake"); + } + + #[test] + fn splash_entrance_wakes_until_its_duration_then_only_the_hold_end() { + let mut state = animated_state_at(1_000); + state.phase = UiPhase::Splash { started_at_ms: 1_000 }; + let hold_end = 1_000 + crate::update::SPLASH_MIN_HOLD_MS; + assert_eq!(next_wake_ms(&state), Some(1_000 + FRAME_MS), "the entrance animates a frame at a time"); + state.clock_ms = 1_000 + SPLASH_ENTRANCE_MS - 1; + assert_eq!(next_wake_ms(&state), Some(1_000 + SPLASH_ENTRANCE_MS - 1 + FRAME_MS)); + state.clock_ms = 1_000 + SPLASH_ENTRANCE_MS; + assert_eq!(next_wake_ms(&state), Some(hold_end), "the entrance is done; only the hold remains"); + } + + #[test] + fn splash_hold_end_is_a_deadline_even_with_animations_off() { + let mut state = State::empty(Theme::classic()); + state.clock_ms = 42_000; + state.phase = UiPhase::Splash { started_at_ms: 42_000 }; + assert_eq!(next_wake_ms(&state), Some(42_000 + crate::update::SPLASH_MIN_HOLD_MS)); + } + + #[test] + fn panel_wipe_wakes_until_its_duration() { + let mut state = animated_state_at(0); + state.panels_revealed_at_ms = Some(5_000); + assert_surface(state, 5_000, PANEL_WIPE_MS); + } + + #[test] + fn menu_bar_sweep_wakes_until_its_duration() { + let mut state = animated_state_at(0); + let mut menu = MenuState::opened(); + menu.pulldown_open = false; + menu.opened_at_ms = 2_000; + state.open_overlay(Overlay::Menu(menu)); + assert_surface(state, 2_000, MENU_BAR_SWEEP_MS); + } + + #[test] + fn pulldown_unfold_and_highlight_settle_wake_until_their_durations() { + let mut state = animated_state_at(0); + let mut menu = MenuState::opened(); + menu.opened_at_ms = 1_000; // the bar's own sweep long finished + menu.pulldown_opened_at_ms = 2_500; + state.open_overlay(Overlay::Menu(menu)); + assert_surface(state.clone(), 2_500, POPUP_UNFOLD_MS); + + state.menu_mut().unwrap().pulldown_opened_at_ms = 2_000; // unfold done + state.menu_mut().unwrap().selected_at_ms = Some(3_000); + assert_surface(state, 3_000, HIGHLIGHT_SETTLE_MS); + } + + #[test] + fn a_closed_pulldown_neither_unfolds_nor_settles() { + let mut state = animated_state_at(0); + let mut menu = MenuState::opened(); + menu.pulldown_open = false; + menu.opened_at_ms = 1_000; + menu.pulldown_opened_at_ms = 2_500; + menu.selected_at_ms = Some(2_500); + state.open_overlay(Overlay::Menu(menu)); + state.clock_ms = 2_500; + assert_eq!(next_wake_ms(&state), None, "a closed pull-down renders nothing animated"); + } + + #[test] + fn popup_menus_wake_for_unfold_and_highlight_settle() { + for slot in ["user_menu", "file_action_menu", "history_dialog"] { + let mut state = animated_state_at(0); + match slot { + "user_menu" => { + state.open_overlay(Overlay::UserMenu(crate::dialogs::UserMenuState { cursor: 0, opened_at_ms: 4_000, selected_at_ms: None })) + } + "file_action_menu" => { + state.open_overlay(Overlay::FileActionMenu(crate::dialogs::FileActionMenuState::new("f.txt".into(), false))); + state.file_action_menu_mut().unwrap().opened_at_ms = 4_000; + } + _ => { + state.open_overlay(Overlay::HistoryDialog(crate::dialogs::HistoryDialogState::default())); + state.history_dialog_mut().unwrap().opened_at_ms = 4_000; + } + } + assert_surface(state.clone(), 4_000, POPUP_UNFOLD_MS); + + // A highlight that moved after the unfold finished settles. + let mut state = state; + let settle_at = 4_000 + POPUP_UNFOLD_MS; + match slot { + "user_menu" => state.user_menu_mut().unwrap().selected_at_ms = Some(settle_at), + "file_action_menu" => state.file_action_menu_mut().unwrap().selected_at_ms = Some(settle_at), + _ => state.history_dialog_mut().unwrap().selected_at_ms = Some(settle_at), + } + assert_surface(state, settle_at, HIGHLIGHT_SETTLE_MS); + } + } + + #[test] + fn a_visible_dialog_irises_but_a_stale_stamp_does_not_wake() { + let mut state = animated_state_at(0); + state.dialog_opened_at_ms = 5_000; + state.clock_ms = 5_000; + assert_eq!(next_wake_ms(&state), None, "no dialog visible: the stamp alone animates nothing"); + state.set_startup_warning("warn".to_string()); + assert_surface(state, 5_000, DIALOG_IRIS_MS); + } + + #[test] + fn the_quit_dialog_irises_on_its_own_stamp() { + let mut state = animated_state_at(0); + state.quit_confirm = true; + state.quit_opened_at_ms = 6_000; + assert_surface(state, 6_000, DIALOG_IRIS_MS); + } + + #[test] + fn animations_off_yields_no_animation_wake() { + // Every surface stamped at once: with `animations` off, all render + // their final frame and nothing wakes (application-shell "Idle + // screen is not redrawn"). + let mut state = State::empty(Theme::classic()); + state.clock_ms = 7_000; + state.panels_revealed_at_ms = Some(7_000); + state.open_overlay(Overlay::Menu(MenuState::opened())); + state.quit_confirm = true; + state.quit_opened_at_ms = 7_000; + assert_eq!(next_wake_ms(&state), None); + } + + #[test] + fn clipboard_feedback_expiry_is_a_deadline() { + // application-shell "Clipboard feedback expires without input": the + // wake lands exactly on `expires_at_ms`, the earlier of the two + // panels winning. + let mut state = State::empty(Theme::classic()); + state.clock_ms = 1_000; + let feedback = |expires_at_ms| ClipboardFeedback { message: "copied".to_string(), is_error: false, expires_at_ms }; + state.left.clipboard_feedback = Some(feedback(9_999)); + assert_eq!(next_wake_ms(&state), Some(9_999)); + state.right.clipboard_feedback = Some(feedback(4_000)); + assert_eq!(next_wake_ms(&state), Some(4_000), "the earliest expiry wins"); + state.right.clipboard_feedback = None; + state.animations = true; + assert_eq!(next_wake_ms(&state), Some(9_999), "not mistaken for an animation frame wake"); + } + + #[test] + fn an_idle_panels_screen_has_no_wake() { + assert_eq!(next_wake_ms(&animated_state_at(12_345)), None); + } +} diff --git a/crates/filecommand-core/src/clock.rs b/crates/filecommand-core/src/clock.rs index f200c7b..180afea 100644 --- a/crates/filecommand-core/src/clock.rs +++ b/crates/filecommand-core/src/clock.rs @@ -46,19 +46,32 @@ impl Clock for TestClock { pub trait WallClock { /// Local wall-clock time as `(hour 0-23, minute 0-59)`. fn now_local(&self) -> (u8, u8); + /// Wall-clock time as Unix-epoch milliseconds. Unlike [`Clock::now_ms`] + /// this is comparable across process restarts, which is what the Ctrl+J + /// frecency index records visits in (fuzzy-jump "Frecency ranking"; + /// history-persistence-hardening design D1). Animations and every other + /// in-session timing keep using the monotonic [`Clock`]. + fn epoch_ms(&self) -> u64; } -/// A `WallClock` pinned to a fixed `(hour, minute)`. Never advances on its -/// own. +/// A `WallClock` pinned to a fixed `(hour, minute)` and a fixed epoch +/// reading. Never advances on its own. #[derive(Debug, Clone, Copy)] pub struct TestWallClock { hour: u8, minute: u8, + epoch_ms: u64, } impl TestWallClock { pub fn new(hour: u8, minute: u8) -> Self { - Self { hour: hour % 24, minute: minute % 60 } + Self { hour: hour % 24, minute: minute % 60, epoch_ms: 0 } + } + + /// The same pinned time-of-day with [`WallClock::epoch_ms`] pinned to + /// `epoch_ms`. + pub fn with_epoch(self, epoch_ms: u64) -> Self { + Self { epoch_ms, ..self } } } @@ -66,6 +79,10 @@ impl WallClock for TestWallClock { fn now_local(&self) -> (u8, u8) { (self.hour, self.minute) } + + fn epoch_ms(&self) -> u64 { + self.epoch_ms + } } /// Format `(hour, minute)` in the spec's `h:mm a` style: 12-hour, no @@ -102,6 +119,10 @@ mod tests { let clock = TestWallClock::new(15, 42); assert_eq!(clock.now_local(), (15, 42)); assert_eq!(clock.now_local(), (15, 42)); + assert_eq!(clock.epoch_ms(), 0, "the epoch defaults to 0 until pinned"); + let pinned = clock.with_epoch(1_787_441_063_250); + assert_eq!(pinned.epoch_ms(), 1_787_441_063_250); + assert_eq!(pinned.now_local(), (15, 42), "pinning the epoch leaves the time of day alone"); } #[test] diff --git a/crates/filecommand-core/src/config.rs b/crates/filecommand-core/src/config.rs index 7d4c6fb..53668ea 100644 --- a/crates/filecommand-core/src/config.rs +++ b/crates/filecommand-core/src/config.rs @@ -1,35 +1,79 @@ //! Minimal `config.toml` reader plus the on-disk persistence helpers. //! -//! Recognized keys: `splash` (bool), `theme` (string), `shell` (string), +//! Recognized keys: `splash` (bool), `animations` (bool, default true; +//! ui-animations), `theme` (string), `shell` (string), //! `editor` (string, the F4 external-editor command), `panel_split` //! (integer left-panel percentage; panel-split), and the overridable //! bindings `key.paste_name` / `key.paste_path` / `key.quick_filter` / //! `key.fuzzy_jump` / `key.split_left` / `key.split_right` / -//! `key.split_reset` / `key.clipboard_files` / `key.clipboard_paths`. +//! `key.split_reset` / `key.clipboard_files` / `key.clipboard_paths` / +//! `key.clear_line`. //! A `[mouse]` table holds `enabled` (bool, default true; mouse-input //! "Mouse capture configuration") — the one section header this otherwise //! flat, line-based parser recognizes, so `enabled` under `[mouse]` doesn't -//! collide with any bare top-level key of the same name. -//! Missing files and unrecognized or malformed lines are tolerated; -//! unrecognized keys are ignored. +//! collide with any bare top-level key of the same name. Top-level settings +//! must therefore come before the first `[table]`. //! -//! Command history persists to `history.json` next to the config, written -//! atomically (temp file + rename) so a crash mid-write can never leave a -//! truncated history behind. +//! `config.toml` and `usermenu.toml` are both read through one shared line +//! tokenizer ([`tokenize`]; configuration-files "Configuration files +//! tolerate a UTF-8 BOM and inline comments"): a leading UTF-8 BOM is +//! ignored, a `#` outside a quoted string starts a comment that runs to the +//! end of the line (on key lines and table headers alike), a `#` inside a +//! quoted value is kept, and in an unquoted `config.toml` value a `#` starts +//! a comment only at the start of the value or after whitespace, so a bare +//! `C:\Tools\C#\edit.exe` keeps working. Quoted `config.toml` values are +//! taken verbatim between the quotes (backslashes are not escapes). +//! +//! A missing `config.toml` is silent, but nothing else is ignored quietly: +//! a malformed line, an unknown key or table, a key under the wrong table, +//! an uninterpretable boolean or key binding, and a file that is not UTF-8 +//! text each produce a [`ConfigWarning`] naming the file and line +//! (configuration-files "Malformed or unrecognized configuration lines +//! produce startup warnings"). The caller shows them once at startup as a +//! one-line summary ([`summarize_warnings`]). `theme` and `panel_split` +//! keep their specified silent fallbacks. +//! +//! Every write of `config.toml` and `usermenu.toml` goes through +//! [`crate::durable::replace_file`] (unique temp file, `sync_all`, rename), +//! and the theme/panel-split rewrites are table-aware: they replace only a +//! top-level line (keeping its trailing comment and any BOM) and insert a +//! missing key before the first table header (configuration-files +//! "Persisted configuration writes are durable", "Configuration rewrites +//! keep settings in the top-level table"). +//! +//! Command history and the Ctrl+J directory-frecency index persist to +//! `history.json` next to the config, through the same durable helper +//! (unique temp file, `sync_all`, atomic replace) so an interrupted write +//! leaves either the previous or the new complete file. The document +//! carries a `"version": 2` marker: a file without it was written by an +//! earlier build whose visit times were session-relative, and +//! [`migrate_loaded_history`] re-stamps those at load. Before every write +//! the caller merges its snapshot with the file on disk +//! ([`merge_history_files`]), so two instances never erase each other's +//! history (history-persistence-hardening design D2, D5, D6). The reader +//! shares only the BOM strip with the TOML-like files; its JSON strings +//! decode losslessly (surrogate pairs combine, a bad `\u` escape becomes +//! U+FFFD) and never produce warnings. //! //! The F2 user menu is loaded from `usermenu.toml`, a real (if minimal) //! TOML array-of-tables document — unlike `config.toml`/`history.json` //! above, which are deliberately *not* real TOML/JSON parsers. Unlike those //! two, malformed `usermenu.toml` content is treated as an error condition //! (falling back to defaults and warning) rather than silently tolerated, -//! per user-menu "Create and recover the usermenu.toml file". +//! per user-menu "Create and recover the usermenu.toml file". A +//! `usermenu.toml` that is not UTF-8 text is malformed too, and is never +//! overwritten. +use std::fmt; use std::io; use std::path::{Path, PathBuf}; -use crate::quicksearch::FrecencyEntry; +use crate::quicksearch::{self, FrecencyEntry}; use crate::theme::DEFAULT_THEME_NAME; +/// The settings file in the profile directory. +pub const CONFIG_FILE: &str = "config.toml"; + /// The default Windows shell. `cmd.exe` is chosen over PowerShell because /// PowerShell costs 200 ms+ per spawn, which destroys the instant NC feel; /// PowerShell is opt-in via `shell =`. @@ -97,6 +141,11 @@ pub struct Keys { /// Copies the F5-scope entries' absolute paths to the clipboard as /// text. Default Ctrl+Shift+Ins. pub clipboard_paths: KeyBinding, + /// Clears the whole command line, ending any history recall in + /// progress, while the panels own input and the buffer is non-empty. + /// Default Ctrl+Y (command-line "Clear the command line with a + /// dedicated key"; command-line-clear-key design D1). + pub clear_line: KeyBinding, } impl Default for Keys { @@ -111,6 +160,7 @@ impl Default for Keys { split_reset: KeyBinding::new(true, false, false, "="), clipboard_files: KeyBinding::new(true, false, false, "c"), clipboard_paths: KeyBinding::new(true, false, true, "insert"), + clear_line: KeyBinding::new(true, false, false, "y"), } } } @@ -118,6 +168,11 @@ impl Default for Keys { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { pub splash: bool, + /// Whether the decorative UI animations run (ui-animations "Animations + /// are disabled by config or launch flag"). Defaults to `true`; + /// `animations = false` here or `--noanimations` at launch turns every + /// surface back into its final frame. + pub animations: bool, pub theme: String, /// `shell = ` verbatim. `None` means "use the platform default"; see /// [`crate::shell::resolve_shell`]. @@ -144,6 +199,7 @@ impl Default for Config { fn default() -> Self { Config { splash: true, + animations: true, theme: DEFAULT_THEME_NAME.to_string(), shell: None, editor: None, @@ -178,156 +234,408 @@ pub fn parse_binding(value: &str) -> Option { key.map(|key| KeyBinding { ctrl, alt, shift, key }) } -/// Parse a minimal `key = value` TOML-ish document. Tolerant: blank lines, -/// `#` comments, and unrecognized keys/malformed lines are silently -/// skipped rather than causing a parse failure. -pub fn parse(input: &str) -> Config { - let mut config = Config::default(); - // Real TOML-style table tracking, limited to `[mouse]`: once a `[mouse]` - // header is seen, subsequent `key = value` lines belong to that table - // (currently just `enabled`) until another `[...]` header appears — - // mirroring genuine TOML section scoping rather than this parser's - // usual flat `key.sub` dotted-namespace convention, because the spec - // scenario is written as a literal `[mouse]` table (mouse-input "Mouse - // capture configuration"). - let mut in_mouse_table = false; - for line in input.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; +// --------------------------------------------------------------------- +// Shared line tokenizer for config.toml and usermenu.toml +// (configuration-files; design D1) +// --------------------------------------------------------------------- + +/// Remove one leading U+FEFF (the UTF-8 byte-order mark Notepad and friends +/// write), so the first line reads exactly as if the mark were absent +/// (configuration-files "Configuration files tolerate a UTF-8 BOM and inline +/// comments"). +pub fn strip_bom(input: &str) -> &str { + input.strip_prefix('\u{feff}').unwrap_or(input) +} + +/// How a basic (`"..."`) string's closing quote is found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QuoteMode { + /// `config.toml`: the text between the quotes is verbatim, so a + /// backslash never escapes the quote (`"C:\dir\"` terminates). + Verbatim, + /// `usermenu.toml`: real TOML, where `\"` does not close the string. + TomlEscapes, +} + +/// The raw, uninterpreted value of a `key = value` line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + /// The inner text of `"…"`, not unescaped. + Basic(String), + /// The inner text of `'…'`. + Literal(String), + /// Unquoted text, surrounding whitespace trimmed. + Bare(String), +} + +impl Value { + /// The value's text, whichever way it was quoted. + pub fn text(&self) -> &str { + match self { + Value::Basic(s) | Value::Literal(s) | Value::Bare(s) => s, + } + } +} + +/// What one line of a TOML-like file is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LineKind { + /// Empty, whitespace, or a whole-line comment. + Blank, + /// `[name]` (or `[[name]]` when `array`), optionally followed by a + /// comment. + Table { name: String, array: bool }, + /// `key = value`, with the trailing `# …` comment text (from the `#`) + /// kept for the rewriter. + KeyValue { key: String, value: Value, comment: Option }, + /// No `=`, an empty key, an unterminated quote, or stray text after a + /// closing quote or table header. + Malformed { reason: String }, +} + +/// One tokenized line with its 1-based number. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Line { + pub number: usize, + pub kind: LineKind, +} + +/// Tokenize a TOML-like document into lines. The caller strips the BOM +/// first ([`strip_bom`]). Comment rules (design D1): a `#` outside quotes +/// starts a comment on key lines and table headers; a `#` in a bare value +/// starts a comment only at the value start or after whitespace; the +/// closing quote of a quoted value is the earliest matching quote that +/// leaves only whitespace or a `#` comment behind it. +pub fn tokenize(input: &str, mode: QuoteMode) -> Vec { + input.lines().enumerate().map(|(i, raw)| Line { number: i + 1, kind: tokenize_line(raw, mode) }).collect() +} + +fn tokenize_line(raw: &str, mode: QuoteMode) -> LineKind { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + return LineKind::Blank; + } + if line.starts_with('[') { + return tokenize_header(line); + } + let Some((key, rest)) = line.split_once('=') else { + return LineKind::Malformed { reason: "expected key = value".to_string() }; + }; + let key = key.trim(); + if key.is_empty() { + return LineKind::Malformed { reason: "expected a key before =".to_string() }; + } + let rest = rest.trim_start(); + let quote = match rest.chars().next() { + Some(q @ ('"' | '\'')) => Some(q), + _ => None, + }; + match quote { + Some(q) => match find_closing_quote(rest, q, mode) { + Some(end) => { + let inner = rest[1..end].to_string(); + let value = if q == '"' { Value::Basic(inner) } else { Value::Literal(inner) }; + let tail = rest[end + 1..].trim(); + let comment = if tail.is_empty() { None } else { Some(tail.to_string()) }; + LineKind::KeyValue { key: key.to_string(), value, comment } + } + None => { + let reason = if rest[1..].contains(q) { "unexpected text after the closing quote" } else { "unterminated quoted value" }; + LineKind::Malformed { reason: reason.to_string() } + } + }, + None => { + let (value, comment) = split_bare_comment(rest); + LineKind::KeyValue { key: key.to_string(), value: Value::Bare(value.trim().to_string()), comment: comment.map(str::to_string) } } - if line.starts_with('[') && line.ends_with(']') { - in_mouse_table = line[1..line.len() - 1].trim() == "mouse"; + } +} + +/// `[name]` / `[[name]]`, with an optional trailing comment. +fn tokenize_header(line: &str) -> LineKind { + let (array, open) = if line.starts_with("[[") { (true, "[[") } else { (false, "[") }; + let close = if array { "]]" } else { "]" }; + let body = &line[open.len()..]; + let Some(end) = body.find(close) else { + return LineKind::Malformed { reason: format!("expected a closing {close} after the table name") }; + }; + let name = body[..end].trim().to_string(); + let tail = body[end + close.len()..].trim_start(); + if !tail.is_empty() && !tail.starts_with('#') { + return LineKind::Malformed { reason: "unexpected text after the table header".to_string() }; + } + LineKind::Table { name, array } +} + +/// The byte index of the closing quote in `rest` (which starts with `q`): +/// the earliest `q` after which only whitespace or a `#` comment remains. +/// In [`QuoteMode::TomlEscapes`] a backslash escapes the next character. +fn find_closing_quote(rest: &str, q: char, mode: QuoteMode) -> Option { + let mut escaped = false; + for (i, c) in rest.char_indices().skip(1) { + if escaped { + escaped = false; continue; } - let Some((key, value)) = line.split_once('=') else { continue }; - let key = key.trim(); - let value = value.trim(); - if in_mouse_table { - if key == "enabled" { - if let Some(b) = parse_bool(value) { - config.mouse_enabled = b; - } - } + if mode == QuoteMode::TomlEscapes && q == '"' && c == '\\' { + escaped = true; continue; } - match key { - "splash" => { - if let Some(b) = parse_bool(value) { - config.splash = b; - } - } - "theme" => { - if let Some(s) = parse_string(value) { - config.theme = s; - } + if c == q { + let tail = rest[i + 1..].trim_start(); + if tail.is_empty() || tail.starts_with('#') { + return Some(i); } - "shell" => { - if let Some(s) = parse_string(value) { - if !s.trim().is_empty() { - config.shell = Some(s); - } + } + } + None +} + +/// Split a bare value from its trailing comment: a `#` counts only at the +/// start of the value or after whitespace, so `C:\Tools\C#\edit.exe` stays +/// whole. +fn split_bare_comment(rest: &str) -> (&str, Option<&str>) { + let mut prev_space = true; + for (i, c) in rest.char_indices() { + if c == '#' && prev_space { + return (&rest[..i], Some(&rest[i..])); + } + prev_space = c.is_whitespace(); + } + (rest, None) +} + +/// One configuration problem, tied to a file and (when `line > 0`) a line +/// (configuration-files "Malformed or unrecognized configuration lines +/// produce startup warnings"; design D2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigWarning { + pub file: &'static str, + /// 1-based line number; `0` for a whole-file problem (not UTF-8, + /// unreadable). + pub line: usize, + pub message: String, +} + +impl ConfigWarning { + fn at(file: &'static str, line: usize, message: impl Into) -> ConfigWarning { + ConfigWarning { file, line, message: message.into() } + } + + fn whole_file(file: &'static str, message: impl Into) -> ConfigWarning { + ConfigWarning { file, line: 0, message: message.into() } + } +} + +impl fmt::Display for ConfigWarning { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.line == 0 { + write!(f, "{} {}", self.file, self.message) + } else { + write!(f, "{} line {}: {}", self.file, self.line, self.message) + } + } +} + +/// The one-row startup-dialog summary of a warning list: the first warning, +/// plus ` (+N more)` when there are others. `None` for an empty list +/// (design D4). +pub fn summarize_warnings(warnings: &[W]) -> Option { + let first = warnings.first()?; + match warnings.len() { + 1 => Some(first.to_string()), + n => Some(format!("{first} (+{} more)", n - 1)), + } +} + +/// Decode file bytes as UTF-8 text. A NUL byte marks the file as not text +/// in this encoding (a UTF-16 file without its BOM would otherwise pass as +/// UTF-8 full of NULs). +fn decode_utf8_text(bytes: Vec) -> Option { + if bytes.contains(&0) { + return None; + } + String::from_utf8(bytes).ok() +} + +// --------------------------------------------------------------------- +// config.toml +// --------------------------------------------------------------------- + +/// A parsed `config.toml`: the settings plus every line that was not read +/// the way it was written. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ParsedConfig { + pub config: Config, + pub warnings: Vec, +} + +/// Which table the parser is inside. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigTable { + TopLevel, + Mouse, + /// An unrecognized header; its keys were covered by the header warning. + Unknown, +} + +/// Parse a `config.toml` document. Blank lines, comments, a BOM, and +/// trailing comments are accepted silently; everything that is ignored or +/// falls back to a default is reported in `warnings` (design D2). The +/// `[mouse]` table is the one recognized header: once seen, `key = value` +/// lines belong to it (only `enabled` is read there) until the next header +/// (mouse-input "Mouse capture configuration"). +pub fn parse(input: &str) -> ParsedConfig { + let mut config = Config::default(); + let mut warnings = Vec::new(); + let mut table = ConfigTable::TopLevel; + for line in tokenize(strip_bom(input), QuoteMode::Verbatim) { + let n = line.number; + match line.kind { + LineKind::Blank => {} + LineKind::Malformed { reason } => warnings.push(ConfigWarning::at(CONFIG_FILE, n, format!("{reason}; line ignored"))), + LineKind::Table { name, array } => { + if name == "mouse" && !array { + table = ConfigTable::Mouse; + } else { + let header = if array { format!("[[{name}]]") } else { format!("[{name}]") }; + warnings.push(ConfigWarning::at(CONFIG_FILE, n, format!("unknown table {header}; its settings are ignored"))); + table = ConfigTable::Unknown; } } - "editor" => { - if let Some(s) = parse_string(value) { - if !s.trim().is_empty() { - config.editor = Some(s); + LineKind::KeyValue { key, value, .. } => match table { + ConfigTable::Unknown => {} + ConfigTable::Mouse => { + if key == "enabled" { + apply_bool(&value, &mut config.mouse_enabled, "enabled", n, &mut warnings); + } else { + warnings.push(ConfigWarning::at( + CONFIG_FILE, + n, + format!("\"{key}\" is not a [mouse] setting and was ignored — put top-level settings before the first [table]"), + )); } } + ConfigTable::TopLevel => apply_top_level_key(&mut config, &key, &value, n, &mut warnings), + }, + } + } + ParsedConfig { config, warnings } +} + +fn apply_top_level_key(config: &mut Config, key: &str, value: &Value, line: usize, warnings: &mut Vec) { + let binding_target: Option<&mut KeyBinding> = match key { + "key.paste_name" => Some(&mut config.keys.paste_name), + "key.paste_path" => Some(&mut config.keys.paste_path), + "key.quick_filter" => Some(&mut config.keys.quick_filter), + "key.fuzzy_jump" => Some(&mut config.keys.fuzzy_jump), + "key.split_left" => Some(&mut config.keys.split_left), + "key.split_right" => Some(&mut config.keys.split_right), + "key.split_reset" => Some(&mut config.keys.split_reset), + "key.clipboard_files" => Some(&mut config.keys.clipboard_files), + "key.clipboard_paths" => Some(&mut config.keys.clipboard_paths), + "key.clear_line" => Some(&mut config.keys.clear_line), + _ => None, + }; + if let Some(target) = binding_target { + match parse_string(value).as_deref().and_then(parse_binding) { + Some(binding) => *target = binding, + None => { + warnings.push(ConfigWarning::at(CONFIG_FILE, line, format!("{key} \"{}\" is not a key binding; using the default", value.text()))) } - "key.paste_name" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.paste_name = b; - } - } - "key.paste_path" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.paste_path = b; - } - } - "key.quick_filter" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.quick_filter = b; - } - } - "key.fuzzy_jump" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.fuzzy_jump = b; - } - } - "key.split_left" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.split_left = b; - } - } - "key.split_right" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.split_right = b; - } - } - "key.split_reset" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.split_reset = b; - } + } + return; + } + match key { + "splash" => apply_bool(value, &mut config.splash, key, line, warnings), + "animations" => apply_bool(value, &mut config.animations, key, line, warnings), + "theme" => { + // Silent fallback for an unknown name is theme-selection's rule + // ("Unknown configured theme falls back to default"); only an + // empty value is treated as unset here, as before. + if let Some(s) = parse_string(value) { + config.theme = s; } - "key.clipboard_files" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.clipboard_files = b; + } + "shell" => { + if let Some(s) = parse_string(value) { + if !s.trim().is_empty() { + config.shell = Some(s); } } - "key.clipboard_paths" => { - if let Some(b) = parse_string(value).as_deref().and_then(parse_binding) { - config.keys.clipboard_paths = b; + } + "editor" => { + if let Some(s) = parse_string(value) { + if !s.trim().is_empty() { + config.editor = Some(s); } } - "panel_split" => { - // Tolerant: a non-integer or out-of-range value is treated - // as though the key were unset — the default (50) is - // already in `config.panel_split` from `Config::default` - // (panel-split "Invalid configured value falls back"). - if let Ok(percent) = value.parse::() { + } + "panel_split" => { + // Tolerant: a non-integer or out-of-range value is treated as + // though the key were unset — the default (50) is already in + // `config.panel_split` from `Config::default` (panel-split + // "Invalid configured value falls back"). No warning, by that + // requirement. + if let Value::Bare(text) = value { + if let Ok(percent) = text.parse::() { if percent <= 100 { config.panel_split = percent; } } } - _ => {} } + _ => warnings.push(ConfigWarning::at(CONFIG_FILE, line, format!("unknown setting \"{key}\"; ignored"))), + } +} + +/// Read a bare `true`/`false` into `target`, warning (and keeping the +/// default) for anything else. +fn apply_bool(value: &Value, target: &mut bool, key: &str, line: usize, warnings: &mut Vec) { + match parse_bool(value) { + Some(b) => *target = b, + None => warnings.push(ConfigWarning::at(CONFIG_FILE, line, format!("{key} must be true or false; using the default"))), } - config } -fn parse_bool(value: &str) -> Option { +fn parse_bool(value: &Value) -> Option { match value { - "true" => Some(true), - "false" => Some(false), + Value::Bare(text) if text == "true" => Some(true), + Value::Bare(text) if text == "false" => Some(false), _ => None, } } /// Accept both quoted (`"cmd.exe /C"`) and bare (`cmd.exe /C`) values — the /// shell spec is far more readable unquoted, and this parser has never been -/// strict TOML anyway. -fn parse_string(value: &str) -> Option { - let value = value.trim(); - if value.is_empty() { +/// strict TOML anyway. Quoted text is taken verbatim (no escapes), so +/// `editor = "C:\Tools\ed.exe"` keeps working. An empty value is `None`. +fn parse_string(value: &Value) -> Option { + let text = value.text(); + if text.is_empty() { return None; } - let stripped = value - .strip_prefix('"') - .and_then(|v| v.strip_suffix('"')) - .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\''))); - Some(stripped.unwrap_or(value).to_string()) + Some(text.to_string()) } -/// Load config from `path`. Tolerant of a missing file — returns -/// [`Config::default`] in that case, matching "missing config is not an -/// error" behavior expected at startup. -pub fn load(path: &Path) -> Config { - match std::fs::read_to_string(path) { - Ok(contents) => parse(&contents), - Err(_) => Config::default(), +/// Load `config.toml` from `path`. A missing file is not an error and not a +/// warning: defaults apply silently. A file that exists but is not UTF-8 +/// text, or cannot be read for another reason, also yields defaults, with +/// one whole-file warning (design D2). +pub fn load(path: &Path) -> ParsedConfig { + match std::fs::read(path) { + Ok(bytes) => match decode_utf8_text(bytes) { + Some(contents) => parse(&contents), + None => ParsedConfig { + config: Config::default(), + warnings: vec![ConfigWarning::whole_file(CONFIG_FILE, "is not UTF-8 text; using default settings")], + }, + }, + Err(e) if e.kind() == io::ErrorKind::NotFound => ParsedConfig::default(), + Err(e) => ParsedConfig { + config: Config::default(), + warnings: vec![ConfigWarning::whole_file(CONFIG_FILE, format!("could not be read ({e}); using default settings"))], + }, } } @@ -335,119 +643,125 @@ pub fn load(path: &Path) -> Config { // config.toml theme persistence (visual-themes) // --------------------------------------------------------------------- -/// Rewrite `input`'s `theme = ""` line to `theme_name`, replacing the -/// first top-level (non-comment, non-blank) `theme =` line if one exists, or -/// appending a fresh one otherwise. Every other line — comments, blank -/// lines, unrelated keys — is left byte-for-byte untouched, so applying a -/// theme never clobbers the rest of a hand-edited `config.toml` (design D3; -/// theme-selection "Applied theme persists to configuration"). -pub fn set_theme_line(input: &str, theme_name: &str) -> String { - let quoted_line = format!("theme = {}", toml_quote(theme_name)); - let mut found = false; - let mut out = String::with_capacity(input.len() + quoted_line.len() + 1); - for line in input.lines() { - let trimmed = line.trim(); - let is_theme_key = !found - && !trimmed.is_empty() - && !trimmed.starts_with('#') - && trimmed.split_once('=').map(|(k, _)| k.trim() == "theme").unwrap_or(false); - if is_theme_key { - out.push_str("ed_line); - found = true; - } else { - out.push_str(line); +/// Rewrite the top-level `key = ` line of a `config.toml` +/// document (configuration-files "Configuration rewrites keep settings in +/// the top-level table"; design D5): +/// +/// - the first `key` line *before the first table header* is replaced, +/// keeping its trailing comment; +/// - when there is none, `key = ` is inserted immediately before +/// the first table header, or appended when the file has no table — so +/// the new line can never land inside `[mouse]` and be ignored on the +/// next start; +/// - a leading BOM is re-emitted, and every other line is left unchanged. +/// +/// Line endings follow the file: CRLF when the input uses CRLF, LF +/// otherwise. +pub fn set_top_level_key(input: &str, key: &str, rendered: &str) -> String { + let body = strip_bom(input); + let bom = &input[..input.len() - body.len()]; + let eol = if body.contains("\r\n") { "\r\n" } else { "\n" }; + let raw_lines: Vec<&str> = body.lines().collect(); + let mut first_table = None; + let mut target = None; + for line in tokenize(body, QuoteMode::Verbatim) { + match line.kind { + LineKind::Table { .. } => { + first_table = Some(line.number - 1); + break; + } + LineKind::KeyValue { key: k, comment, .. } if k == key && target.is_none() => target = Some((line.number - 1, comment)), + _ => {} } - out.push('\n'); } - if !found { - out.push_str("ed_line); - out.push('\n'); + let render = |comment: Option<&str>| match comment { + Some(c) => format!("{key} = {rendered} {c}"), + None => format!("{key} = {rendered}"), + }; + let mut out = String::with_capacity(input.len() + key.len() + rendered.len() + 8); + out.push_str(bom); + let insert_at = match target { + Some(_) => None, + None => Some(first_table.unwrap_or(raw_lines.len())), + }; + for (i, raw) in raw_lines.iter().enumerate() { + if insert_at == Some(i) { + out.push_str(&render(None)); + out.push_str(eol); + } + match &target { + Some((at, comment)) if *at == i => out.push_str(&render(comment.as_deref())), + _ => out.push_str(raw), + } + out.push_str(eol); + } + if insert_at == Some(raw_lines.len()) { + out.push_str(&render(None)); + out.push_str(eol); } out } -/// Write the updated `theme =` key to `config.toml` at `path` atomically — -/// same temp-file-then-rename discipline as [`save_history_atomic`], so a -/// crash mid-write can never leave a truncated `config.toml` behind -/// (theme-selection "Config write is atomic"). Reads the existing file (a -/// missing file is treated as empty, matching [`load`]'s tolerance) so every -/// other line survives the round trip. -pub fn save_theme_atomic(path: &Path, theme_name: &str) -> io::Result<()> { - let existing = std::fs::read_to_string(path).unwrap_or_default(); - let updated = set_theme_line(&existing, theme_name); - let tmp = temp_sibling(path); +/// Rewrite `input`'s top-level `theme = ""` line to `theme_name` via +/// [`set_top_level_key`], so applying a theme never clobbers the rest of a +/// hand-edited `config.toml` (theme-selection "Applied theme persists to +/// configuration"). +pub fn set_theme_line(input: &str, theme_name: &str) -> String { + set_top_level_key(input, "theme", &toml_quote(theme_name)) +} + +/// Read the current `config.toml` text for a rewrite. A missing file is +/// empty (matching [`load`]'s tolerance); a file that is not UTF-8 text is +/// refused rather than replaced by a one-line file, because the rewrite +/// could not keep its other settings. +fn read_config_for_rewrite(path: &Path) -> io::Result { + match std::fs::read(path) { + Ok(bytes) => decode_utf8_text(bytes) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, format!("{CONFIG_FILE} is not UTF-8 text; not rewritten"))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(e), + } +} + +/// Write `updated` to `config.toml` at `path` through the shared durable +/// helper: a uniquely named temp file, `sync_all`, then an atomic replace, +/// with the temp file removed on failure (configuration-files "Persisted +/// configuration writes are durable"; theme-selection "Config write is +/// atomic"; panel-split "Config write is atomic"). +fn write_config_durably(path: &Path, updated: &str) -> io::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } - std::fs::write(&tmp, updated)?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - let _ = std::fs::remove_file(&tmp); - Err(e) - } - } + crate::durable::replace_file(path, updated.as_bytes()) +} + +/// Write the updated `theme =` key to `config.toml` at `path` durably (see +/// `write_config_durably`). Reads the existing file so every other line +/// survives the round trip. +pub fn save_theme_atomic(path: &Path, theme_name: &str) -> io::Result<()> { + let existing = read_config_for_rewrite(path)?; + write_config_durably(path, &set_theme_line(&existing, theme_name)) } // --------------------------------------------------------------------- // config.toml panel-split persistence (panel-split) // --------------------------------------------------------------------- -/// Rewrite `input`'s `panel_split = ` line to `percent`, replacing -/// the first top-level (non-comment, non-blank) `panel_split =` line if one -/// exists, or appending a fresh one otherwise — every other line is left -/// byte-for-byte untouched, mirroring [`set_theme_line`] exactly (panel- -/// split "Split persistence to configuration"). +/// Rewrite `input`'s top-level `panel_split = ` line to `percent` +/// via [`set_top_level_key`], mirroring [`set_theme_line`] exactly +/// (panel-split "Split persistence to configuration"). pub fn set_panel_split_line(input: &str, percent: u16) -> String { - let new_line = format!("panel_split = {percent}"); - let mut found = false; - let mut out = String::with_capacity(input.len() + new_line.len() + 1); - for line in input.lines() { - let trimmed = line.trim(); - let is_split_key = !found - && !trimmed.is_empty() - && !trimmed.starts_with('#') - && trimmed.split_once('=').map(|(k, _)| k.trim() == "panel_split").unwrap_or(false); - if is_split_key { - out.push_str(&new_line); - found = true; - } else { - out.push_str(line); - } - out.push('\n'); - } - if !found { - out.push_str(&new_line); - out.push('\n'); - } - out + set_top_level_key(input, "panel_split", &percent.to_string()) } -/// Write the updated `panel_split =` key to `config.toml` at `path` -/// atomically — same temp-file-then-rename discipline as -/// [`save_theme_atomic`], reusing the same write-back path per design's -/// "whichever change builds second reuses the same atomic write-back path -/// rather than adding a parallel one" (panel-split "Config write is -/// atomic"). +/// Write the updated `panel_split =` key to `config.toml` at `path` through +/// the same durable write-back path as [`save_theme_atomic`] (panel-split +/// "Config write is atomic"). pub fn save_panel_split_atomic(path: &Path, percent: u16) -> io::Result<()> { - let existing = std::fs::read_to_string(path).unwrap_or_default(); - let updated = set_panel_split_line(&existing, percent); - let tmp = temp_sibling(path); - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } - } - std::fs::write(&tmp, updated)?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - let _ = std::fs::remove_file(&tmp); - Err(e) - } - } + let existing = read_config_for_rewrite(path)?; + write_config_durably(path, &set_panel_split_line(&existing, percent)) } // --------------------------------------------------------------------- @@ -504,40 +818,90 @@ fn json_escape(s: &str) -> String { /// Parse a JSON array of strings. Tolerant: anything that isn't a /// well-formed string array yields whatever strings could be read, so a /// corrupt history file degrades to a shorter history rather than an error. +/// A leading BOM is ignored and string escapes decode losslessly +/// (`decode_json_string`), so no entry is lost to an escape the reader +/// does not understand (configuration-files "History file strings decode +/// without losing entries"). pub fn parse_history(input: &str) -> Vec { let mut out = Vec::new(); - let mut chars = input.chars().peekable(); + let mut chars = strip_bom(input).chars().peekable(); while let Some(c) = chars.next() { if c != '"' { continue; } - let mut s = String::new(); - loop { - match chars.next() { - None => return out, - Some('"') => break, - Some('\\') => match chars.next() { - Some('n') => s.push('\n'), - Some('r') => s.push('\r'), - Some('t') => s.push('\t'), - Some('u') => { - let hex: String = (0..4).filter_map(|_| chars.next()).collect(); - match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - Some(c) => s.push(c), - None => return out, - } - } - Some(other) => s.push(other), - None => return out, - }, - Some(other) => s.push(other), - } + match decode_json_string(&mut chars) { + Some(s) => out.push(s), + None => break, } - out.push(s); } out } +/// Decode the body of a JSON string literal; `chars` is positioned just +/// after the opening quote and is left just after the closing quote. Returns +/// `None` only when the string is unterminated (design D7): +/// +/// - a `\uD800`–`\uDBFF` escape followed by a `\uDC00`–`\uDFFF` escape +/// decodes to the one character they encode; +/// - a lone surrogate, or a `\u` without four hex digits, decodes to U+FFFD +/// and decoding continues; +/// - other escapes follow JSON (`\n`, `\r`, `\t`, `\b`, `\f`, `\"`, `\\`, +/// `\/`); an unknown `\x` yields `x`, as before. +fn decode_json_string(chars: &mut std::iter::Peekable>) -> Option { + let mut s = String::new(); + loop { + match chars.next()? { + '"' => return Some(s), + '\\' => match chars.next()? { + 'n' => s.push('\n'), + 'r' => s.push('\r'), + 't' => s.push('\t'), + 'b' => s.push('\u{8}'), + 'f' => s.push('\u{c}'), + 'u' => s.push(decode_unicode_escape(chars)), + other => s.push(other), + }, + c => s.push(c), + } + } +} + +/// The four hex digits following `\u`. Nothing is consumed unless all four +/// are present, so a short escape (`\u12"`) neither swallows the closing +/// quote nor loses the characters after the `\u`. +fn take_hex4(chars: &mut std::iter::Peekable>) -> Option { + let mut ahead = chars.clone(); + let mut value = 0u32; + for _ in 0..4 { + let digit = ahead.next()?.to_digit(16)?; + value = value * 16 + digit; + } + *chars = ahead; + Some(value) +} + +/// Decode the code unit(s) after a `\u` into one character, combining a +/// surrogate pair and substituting U+FFFD for anything that is not a scalar. +fn decode_unicode_escape(chars: &mut std::iter::Peekable>) -> char { + let Some(unit) = take_hex4(chars) else { return '\u{fffd}' }; + if (0xD800..=0xDBFF).contains(&unit) { + // A high surrogate: look ahead for `\u` + low surrogate without + // consuming anything unless the pair is complete. + let mut ahead = chars.clone(); + if ahead.next() == Some('\\') && ahead.next() == Some('u') { + if let Some(low) = take_hex4(&mut ahead) { + if (0xDC00..=0xDFFF).contains(&low) { + *chars = ahead; + let scalar = 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00); + return char::from_u32(scalar).unwrap_or('\u{fffd}'); + } + } + } + return '\u{fffd}'; + } + char::from_u32(unit).unwrap_or('\u{fffd}') +} + /// Read persisted history. A missing or unreadable file is an empty /// history, never an error. pub fn load_history(path: &Path) -> Vec { @@ -547,33 +911,24 @@ pub fn load_history(path: &Path) -> Vec { } } -/// Write history atomically: serialize to a sibling temp file, then rename -/// over the target. `std::fs::rename` replaces an existing file on both -/// Windows and Unix, so a reader never observes a partial write. +/// Write the plain-array history durably through +/// [`crate::durable::replace_file`] (unique temp file, `sync_all`, atomic +/// replace), so a reader never observes a partial write and no two +/// instances share a temp file name. pub fn save_history_atomic(path: &Path, entries: &[String]) -> io::Result<()> { - let tmp = temp_sibling(path); + ensure_parent_dir(path)?; + crate::durable::replace_file(path, render_history(entries).as_bytes()) +} + +/// Create `path`'s parent directory when it is missing, so a first write +/// into a fresh profile directory succeeds. +fn ensure_parent_dir(path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } - std::fs::write(&tmp, render_history(entries))?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - let _ = std::fs::remove_file(&tmp); - Err(e) - } - } -} - -fn temp_sibling(path: &Path) -> std::path::PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_else(|| std::ffi::OsString::from("history.json")); - name.push(".tmp"); - match path.parent() { - Some(parent) => parent.join(name), - None => std::path::PathBuf::from(name), - } + Ok(()) } /// Append `command` as the newest history entry, dropping any earlier @@ -613,9 +968,14 @@ pub struct HistoryFile { pub directories: Vec, } -/// Render a [`HistoryFile`] to its on-disk JSON object form. +/// The `"version"` a `history.json` written by this build carries. A file +/// without the key predates wall-clock visit times (design D2). +pub const HISTORY_FORMAT_VERSION: u32 = 2; + +/// Render a [`HistoryFile`] to its on-disk JSON object form, with the +/// format marker first. pub fn render_history_file(file: &HistoryFile) -> String { - let mut out = String::from("{\n \"commands\": "); + let mut out = format!("{{\n \"version\": {HISTORY_FORMAT_VERSION},\n \"commands\": "); out.push_str(&render_string_array(&file.commands)); out.push_str(",\n \"directories\": ["); for (i, d) in file.directories.iter().enumerate() { @@ -639,50 +999,197 @@ pub fn render_history_file(file: &HistoryFile) -> String { /// malformed or hand-edited file degrades gracefully (fuzzy-jump "Missing /// or malformed history file"). pub fn parse_history_file(input: &str) -> HistoryFile { + parse_history_document(input).file +} + +/// A parsed `history.json` plus whether it predates the format marker +/// (design D2): a `legacy` file's `last_visited_ms` values are +/// session-relative and must be re-stamped by [`migrate_loaded_history`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HistoryLoad { + pub file: HistoryFile, + pub legacy: bool, +} + +/// [`parse_history_file`] that also reports whether the top-level +/// `"version"` key was absent. The key is found with the same top-level-only +/// lookup as the arrays, so a `"version"` inside a command string is never +/// mistaken for the marker. Tolerance for malformed content is unchanged: a +/// file that is not an object at all is empty history, and `legacy`, which +/// is harmless for an empty file. +pub fn parse_history_document(input: &str) -> HistoryLoad { + let input = strip_bom(input); let commands = extract_value_span(input, "commands").map(parse_history).unwrap_or_default(); - let directories = extract_value_span(input, "directories").map(|span| parse_directories(span)).unwrap_or_default(); - HistoryFile { commands, directories } + let directories = extract_value_span(input, "directories").map(parse_directories).unwrap_or_default(); + let legacy = top_level_value(input, "version").is_none(); + HistoryLoad { file: HistoryFile { commands, directories }, legacy } } /// Read a combined `history.json`. A missing or unreadable file, or one /// that fails to parse at all, is empty history — never an error (fuzzy-jump /// "Missing or malformed history file"). pub fn load_history_file(path: &Path) -> HistoryFile { + load_history_document(path).file +} + +/// [`load_history_file`] that also reports the legacy flag (see +/// [`parse_history_document`]). A missing file is empty and not legacy: +/// there is nothing to migrate. +pub fn load_history_document(path: &Path) -> HistoryLoad { match std::fs::read_to_string(path) { - Ok(contents) => parse_history_file(&contents), - Err(_) => HistoryFile::default(), + Ok(contents) => parse_history_document(&contents), + Err(_) => HistoryLoad::default(), + } +} + +/// Bring a just-loaded history up to date (fuzzy-jump "Legacy history file +/// is migrated at load", "Future last-visit time is clamped"; design D2): +/// +/// - for a `legacy` file, every directory's `last_visited_ms` becomes +/// `now_epoch`, so established directories rank by visit count and then +/// age normally; +/// - for any file, a `last_visited_ms` in the future (a corrected system +/// clock) is clamped to `now_epoch`; +/// - the directory list is pruned to [`quicksearch::MAX_DIR_HISTORY`]. +pub fn migrate_loaded_history(file: &mut HistoryFile, legacy: bool, now_epoch: u64) { + for entry in &mut file.directories { + if legacy || entry.last_visited_ms > now_epoch { + entry.last_visited_ms = now_epoch; + } + } + quicksearch::prune_to_cap(&mut file.directories, quicksearch::MAX_DIR_HISTORY, now_epoch); +} + +/// Combine the `history.json` on disk with this instance's snapshot before a +/// write, so concurrently running instances do not discard each other's +/// entries (fuzzy-jump "Two instances keep each other's history"; design +/// D5): +/// +/// - directories are the union by path — a path in both keeps the larger +/// visit count and the later visit time — this instance's entries first in +/// their order, then disk-only entries in disk order, pruned to +/// [`quicksearch::MAX_DIR_HISTORY`]; +/// - commands present only on disk come first, in disk order, then +/// `mine.commands`, with the oldest dropped to fit [`MAX_HISTORY`]. +/// +/// `max` is idempotent, so repeated flushes never inflate counts. +pub fn merge_history_files(disk: &HistoryFile, mine: &HistoryFile, now_epoch: u64) -> HistoryFile { + let mut directories = mine.directories.clone(); + for theirs in &disk.directories { + match directories.iter_mut().find(|e| e.path == theirs.path) { + Some(ours) => { + ours.visit_count = ours.visit_count.max(theirs.visit_count); + ours.last_visited_ms = ours.last_visited_ms.max(theirs.last_visited_ms); + } + None => directories.push(theirs.clone()), + } + } + quicksearch::prune_to_cap(&mut directories, quicksearch::MAX_DIR_HISTORY, now_epoch); + + let mut commands: Vec = disk.commands.iter().filter(|c| !mine.commands.contains(c)).cloned().collect(); + commands.extend(mine.commands.iter().cloned()); + if commands.len() > MAX_HISTORY { + let excess = commands.len() - MAX_HISTORY; + commands.drain(0..excess); } + HistoryFile { commands, directories } } -/// Write a combined `history.json` atomically: temp file + rename, mirroring -/// [`save_history_atomic`]. +/// Write a combined `history.json` durably through +/// [`crate::durable::replace_file`]: a uniquely named temp file in the same +/// directory, `sync_all`, then an atomic replace, with the temp file removed +/// on failure (fuzzy-jump "Interrupted write leaves a complete file"; design +/// D6). pub fn save_history_file_atomic(path: &Path, file: &HistoryFile) -> io::Result<()> { - let tmp = temp_sibling(path); - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; + ensure_parent_dir(path)?; + crate::durable::replace_file(path, render_history_file(file).as_bytes()) +} + +/// Find the value of top-level `key` in the JSON object `object` and return +/// the text from its first character onward. Only keys of the outermost +/// object are considered, and string literals are skipped, so `"commands"` +/// inside a command string can never be mistaken for the key +/// (configuration-files "Key text inside a string is not mistaken for a +/// key"; design D7). `None` when the text is not an object or the key is +/// absent. +fn top_level_value<'a>(object: &'a str, key: &str) -> Option<&'a str> { + let object = object.trim_start(); + let bytes = object.as_bytes(); + if bytes.first() != Some(&b'{') { + return None; + } + // JSON's structural characters are ASCII, so a byte scan is exact and + // every index below lands on a char boundary. + let mut i = 1; + let mut depth = 1i32; + let mut expect_key = true; + while i < bytes.len() { + match bytes[i] { + b'"' => { + let start = i + 1; + let end = json_string_end(bytes, start)?; + i = end + 1; + if depth == 1 && expect_key { + while bytes.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + if bytes.get(i) != Some(&b':') { + return None; + } + i += 1; + while bytes.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + // The keys this file uses are plain ASCII, so the raw + // literal is compared without unescaping. + if &object[start..end] == key { + return Some(&object[i..]); + } + expect_key = false; + } + } + b'{' | b'[' => { + depth += 1; + i += 1; + } + b'}' | b']' => { + depth -= 1; + if depth == 0 { + return None; + } + i += 1; + } + b',' if depth == 1 => { + expect_key = true; + i += 1; + } + _ => i += 1, } } - std::fs::write(&tmp, render_history_file(file))?; - match std::fs::rename(&tmp, path) { - Ok(()) => Ok(()), - Err(e) => { - let _ = std::fs::remove_file(&tmp); - Err(e) + None +} + +/// The index of the quote that closes the JSON string whose body starts at +/// `start`, skipping backslash escapes. `None` when unterminated. +fn json_string_end(bytes: &[u8], start: usize) -> Option { + let mut i = start; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'"' => return Some(i), + _ => i += 1, } } + None } -/// Find `"key": ` in `input` and return the raw text of the balanced -/// `[...]` or `{...}` that follows, honoring string-literal quoting (so a -/// bracket inside a quoted path never confuses the balance count). `None` -/// when the key is absent or its value isn't a bracketed span. +/// Find top-level `"key": ` in `input` and return the raw text of +/// the balanced `[...]` or `{...}` that follows, honoring string-literal +/// quoting (so a bracket inside a quoted path never confuses the balance +/// count). `None` when the key is absent or its value isn't a bracketed +/// span. fn extract_value_span<'a>(input: &'a str, key: &str) -> Option<&'a str> { - let needle = format!("\"{key}\""); - let key_pos = input.find(&needle)?; - let after_key = &input[key_pos + needle.len()..]; - let colon_pos = after_key.find(':')?; - let value = after_key[colon_pos + 1..].trim_start(); + let value = top_level_value(input, key)?; let open = value.chars().next()?; let close = match open { '[' => ']', @@ -762,41 +1269,18 @@ fn split_top_level_objects(array_span: &str) -> Vec<&str> { } /// Extract a quoted string field `"key": "value"` from within one object -/// substring, honoring the same backslash escapes as [`parse_history`]. +/// substring, decoding it with the same [`decode_json_string`] as +/// [`parse_history`]. fn extract_string_field(obj: &str, key: &str) -> Option { - let needle = format!("\"{key}\""); - let pos = obj.find(&needle)?; - let after = &obj[pos + needle.len()..]; - let colon = after.find(':')?; - let after_colon = after[colon + 1..].trim_start(); - let mut chars = after_colon.strip_prefix('"')?.chars(); - let mut s = String::new(); - loop { - match chars.next()? { - '"' => return Some(s), - '\\' => match chars.next()? { - 'n' => s.push('\n'), - 'r' => s.push('\r'), - 't' => s.push('\t'), - 'u' => { - let hex: String = (0..4).filter_map(|_| chars.next()).collect(); - s.push(u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)?); - } - other => s.push(other), - }, - c => s.push(c), - } - } + let value = top_level_value(obj, key)?; + let mut chars = value.strip_prefix('"')?.chars().peekable(); + decode_json_string(&mut chars) } /// Extract an unsigned integer field `"key": 123` from within one object /// substring. fn extract_number_field(obj: &str, key: &str) -> Option { - let needle = format!("\"{key}\""); - let pos = obj.find(&needle)?; - let after = &obj[pos + needle.len()..]; - let colon = after.find(':')?; - let after_colon = after[colon + 1..].trim_start(); + let after_colon = top_level_value(obj, key)?; let digits: String = after_colon.chars().take_while(|c| c.is_ascii_digit()).collect(); digits.parse().ok() } @@ -845,60 +1329,81 @@ pub fn default_user_menu_entries() -> Vec { /// The result of parsing `usermenu.toml` content: entries in file order, /// plus non-fatal warnings (an unknown key on an otherwise-recognized -/// entry, or a key found outside any `[[entry]]` block). `malformed` is set -/// when the content contains a line that is neither blank, a comment, an -/// `[[entry]]` header, nor a `key = "value"` pair — real invalid TOML, -/// distinct from a merely-unrecognized key (user-menu "Unknown keys warn -/// rather than fail" vs "Malformed file warns and falls back"). +/// entry, a key found outside any `[[entry]]` block, or an entry missing a +/// field), each naming its line. `malformed` is set when the content +/// contains a line that is neither blank, a comment, an `[[entry]]` header, +/// nor a `key = "value"` pair — real invalid TOML, distinct from a +/// merely-unrecognized key (user-menu "Unknown keys warn rather than fail" +/// vs "Malformed file warns and falls back"). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedUserMenu { pub entries: Vec, - pub warnings: Vec, + pub warnings: Vec, pub malformed: bool, } +/// An `[[entry]]` block being accumulated: the header's line number plus +/// the `label` and `command` seen so far. +struct PendingEntry { + line: usize, + label: Option, + command: Option, +} + /// Parse `usermenu.toml` content: a sequence of `[[entry]]` blocks, each /// with `label = "..."` and `command = "..."` string keys (basic -/// double-quoted or literal single-quoted TOML strings). Pure and -/// terminal-free, so parsing is unit-testable without touching a file -/// (user-menu "Parsing SHALL be performed by the `config` module ... MUST -/// be unit-testable without a terminal"). +/// double-quoted or literal single-quoted TOML strings), read through the +/// shared [`tokenize`] in [`QuoteMode::TomlEscapes`] so a BOM, trailing +/// comments, and `[[entry]] # c` are accepted (design D3). A bare value, a +/// malformed line, or any table other than `[[entry]]` makes the file +/// malformed. Pure and terminal-free, so parsing is unit-testable without +/// touching a file (user-menu "Parsing SHALL be performed by the `config` +/// module ... MUST be unit-testable without a terminal"). pub fn parse_usermenu(input: &str) -> ParsedUserMenu { let mut entries = Vec::new(); let mut warnings = Vec::new(); let mut malformed = false; - let mut current: Option<(Option, Option)> = None; + let mut current: Option = None; let mut in_entry = false; - for raw_line in input.lines() { - let line = raw_line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - if line == "[[entry]]" { - flush_usermenu_entry(&mut current, &mut entries, &mut warnings); - current = Some((None, None)); - in_entry = true; - continue; - } - let Some((key, value)) = line.split_once('=') else { - malformed = true; - continue; - }; - let key = key.trim(); - let Some(value) = parse_toml_string_strict(value.trim()) else { - malformed = true; - continue; - }; - if !in_entry { - warnings.push(format!("`{key}` outside of an [[entry]] block is ignored")); - continue; - } - let Some((label, command)) = current.as_mut() else { continue }; - match key { - "label" => *label = Some(value), - "command" => *command = Some(value), - other => warnings.push(format!("unknown key `{other}` in a usermenu.toml entry")), + for line in tokenize(strip_bom(input), QuoteMode::TomlEscapes) { + let n = line.number; + match line.kind { + LineKind::Blank => {} + LineKind::Table { name, array: true } if name == "entry" => { + flush_usermenu_entry(&mut current, &mut entries, &mut warnings); + current = Some(PendingEntry { line: n, label: None, command: None }); + in_entry = true; + } + LineKind::Table { .. } | LineKind::Malformed { .. } => malformed = true, + LineKind::KeyValue { key, value, .. } => { + let value = match value { + Value::Basic(raw) => match unescape_toml_basic(&raw) { + Some(v) => v, + None => { + malformed = true; + continue; + } + }, + Value::Literal(raw) => raw, + // usermenu.toml is real TOML: an unquoted value is a + // parse error, not a readability shorthand. + Value::Bare(_) => { + malformed = true; + continue; + } + }; + if !in_entry { + warnings.push(ConfigWarning::at(USERMENU_FILE, n, format!("\"{key}\" outside of an [[entry]] block; ignored"))); + continue; + } + let Some(entry) = current.as_mut() else { continue }; + match key.as_str() { + "label" => entry.label = Some(value), + "command" => entry.command = Some(value), + other => warnings.push(ConfigWarning::at(USERMENU_FILE, n, format!("unknown key \"{other}\" in an entry; ignored"))), + } + } } } flush_usermenu_entry(&mut current, &mut entries, &mut warnings); @@ -908,33 +1413,19 @@ pub fn parse_usermenu(input: &str) -> ParsedUserMenu { /// Close out the entry block being accumulated (if any), pushing it as a /// completed [`UserMenuEntry`] when both `label` and `command` were set, or -/// warning and dropping it when either is missing. -fn flush_usermenu_entry(current: &mut Option<(Option, Option)>, entries: &mut Vec, warnings: &mut Vec) { - let Some((label, command)) = current.take() else { return }; +/// warning (naming the `[[entry]]` line) and dropping it when either is +/// missing. +fn flush_usermenu_entry(current: &mut Option, entries: &mut Vec, warnings: &mut Vec) { + let Some(PendingEntry { line, label, command }) = current.take() else { return }; match (label, command) { (Some(label), Some(command)) => entries.push(UserMenuEntry { label, command }), (label, _) => { let missing = if label.is_none() { "label" } else { "command" }; - warnings.push(format!("entry missing `{missing}`, skipped")); + warnings.push(ConfigWarning::at(USERMENU_FILE, line, format!("entry missing \"{missing}\"; skipped"))); } } } -/// Parse a strict basic (`"..."`, backslash escapes) or literal (`'...'`, -/// no escapes) TOML string. Unlike [`parse_string`], a bare/unquoted value -/// is rejected (`None`) rather than accepted — `usermenu.toml` is real -/// TOML, so an unquoted value is malformed content, not a readability -/// shorthand. -fn parse_toml_string_strict(value: &str) -> Option { - if value.len() >= 2 && value.starts_with('"') && value.ends_with('"') { - unescape_toml_basic(&value[1..value.len() - 1]) - } else if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { - Some(value[1..value.len() - 1].to_string()) - } else { - None - } -} - fn unescape_toml_basic(s: &str) -> Option { let mut out = String::with_capacity(s.len()); let mut chars = s.chars(); @@ -985,59 +1476,149 @@ fn toml_quote(s: &str) -> String { out } +/// Why a `usermenu.toml` was treated as malformed (design D3), so the +/// startup warning can say how to recover. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UserMenuProblem { + /// The file is UTF-8 text but not valid `usermenu.toml` content. + InvalidToml, + /// The file exists but is not UTF-8 text (an ANSI or UTF-16 save). + NotUtf8, + /// The file exists but could not be read; carries the OS message. + Unreadable(String), +} + /// The outcome of loading `usermenu.toml` at startup (user-menu "Create and /// recover the usermenu.toml file"). #[derive(Debug, Clone, PartialEq, Eq)] pub struct UserMenuLoadResult { pub entries: Vec, - /// Non-fatal parse warnings (empty unless `Loaded`-equivalent content - /// had unknown keys). - pub warnings: Vec, - /// The file did not exist (or could not be read) and default entries - /// were written to `path`. + /// Non-fatal parse warnings, each naming the file and line (empty for a + /// malformed or freshly created file). + pub warnings: Vec, + /// The file did not exist and default entries were written to `path`. pub created_default: bool, - /// The file existed but its content was malformed; defaults are in - /// effect in memory and the on-disk file was left untouched — a - /// startup warning should be shown to the user. + /// The file existed but could not be used; defaults are in effect in + /// memory and the on-disk file was left untouched — a startup warning + /// should be shown to the user. pub malformed: bool, + /// Why `malformed` is set; `None` otherwise. + pub problem: Option, +} + +impl UserMenuLoadResult { + fn fallback(problem: UserMenuProblem) -> UserMenuLoadResult { + UserMenuLoadResult { + entries: default_user_menu_entries(), + warnings: Vec::new(), + created_default: false, + malformed: true, + problem: Some(problem), + } + } } /// Load the F2 user menu from `path`. Three outcomes (user-menu "Missing /// file is created with defaults", "Malformed file warns and falls back /// without overwriting", well-formed load): /// -/// - Missing/unreadable file: default entries are written to `path` and -/// returned (`created_default: true`). -/// - Malformed content: default entries are returned without touching the -/// file (`malformed: true`). +/// - Missing file (`NotFound` only): default entries are written to `path` +/// through the durable helper and returned (`created_default: true`). +/// - Malformed content — invalid TOML, a file that is not UTF-8 text +/// (including UTF-16), or any other read error: default entries are +/// returned without touching the file (`malformed: true`, with the +/// reason in `problem`; configuration-files "Non-UTF-8 usermenu.toml is +/// kept, not overwritten"). /// - Well-formed content: the parsed entries (possibly with warnings) are /// returned, including the zero-entries case (a deliberately empty menu /// is not malformed). pub fn load_user_menu(path: &Path) -> UserMenuLoadResult { - let contents = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(_) => { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == io::ErrorKind::NotFound => { let entries = default_user_menu_entries(); if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { let _ = std::fs::create_dir_all(parent); } } - let _ = std::fs::write(path, render_usermenu(&entries)); - return UserMenuLoadResult { entries, warnings: Vec::new(), created_default: true, malformed: false }; + let _ = crate::durable::replace_file(path, render_usermenu(&entries).as_bytes()); + return UserMenuLoadResult { entries, warnings: Vec::new(), created_default: true, malformed: false, problem: None }; } + Err(e) => return UserMenuLoadResult::fallback(UserMenuProblem::Unreadable(e.to_string())), + }; + let Some(contents) = decode_utf8_text(bytes) else { + return UserMenuLoadResult::fallback(UserMenuProblem::NotUtf8); }; let parsed = parse_usermenu(&contents); if parsed.malformed { - return UserMenuLoadResult { entries: default_user_menu_entries(), warnings: Vec::new(), created_default: false, malformed: true }; + return UserMenuLoadResult::fallback(UserMenuProblem::InvalidToml); } - UserMenuLoadResult { entries: parsed.entries, warnings: parsed.warnings, created_default: false, malformed: false } + UserMenuLoadResult { entries: parsed.entries, warnings: parsed.warnings, created_default: false, malformed: false, problem: None } +} + +// --------------------------------------------------------------------- +// Configuration profile directory (config-profile-directory) +// --------------------------------------------------------------------- + +/// The three configuration files, in the order the legacy notice lists +/// them (design D3). `"config.toml"` is also the file whose presence in +/// the profile directory silences the notice. +const PROFILE_FILE_NAMES: [&str; 3] = ["config.toml", HISTORY_FILE, USERMENU_FILE]; + +/// Which of the three configuration files sit in `cwd` while the resolved +/// `profile` directory has no `config.toml` — the pre-profile, +/// per-launch-directory layout (config-profile-directory "Legacy files in +/// the working directory produce a notice, not a migration"; design D3). +/// The result is empty when the profile already has a `config.toml` (the +/// user has moved their settings) or when `cwd` and `profile` are the same +/// directory — portable mode and the last-resort cwd fallback, where those +/// files are the live configuration, not legacy copies. Pure probing over +/// two paths so the notice is unit-testable with temp dirs; nothing here +/// copies, moves, or deletes anything. +pub fn legacy_files_present(cwd: &Path, profile: &Path) -> Vec<&'static str> { + if cwd == profile || profile.join("config.toml").exists() { + return Vec::new(); + } + PROFILE_FILE_NAMES.iter().copied().filter(|name| cwd.join(name).exists()).collect() } #[cfg(test)] mod tests { use super::*; + /// The settings-only view of [`super::parse`], for the many tests here + /// that care about the resulting `Config` and not its warnings (this + /// local definition shadows the glob-imported `parse`). Warning-focused + /// tests call `super::parse` directly. + fn parse(input: &str) -> Config { + super::parse(input).config + } + + /// The rendered warning lines of a `config.toml` document. + fn warnings_of(input: &str) -> Vec { + super::parse(input).warnings.iter().map(ToString::to_string).collect() + } + + /// A fresh, empty, uniquely named temp directory for a file test. + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-config-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Every `.fc-tmp` sibling left in `dir` (there must be none after a + /// completed or failed durable write). + fn leftover_temp_files(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".fc-tmp") || n.ends_with(".tmp")) + .collect() + } + #[test] fn defaults_when_empty() { let config = parse(""); @@ -1108,6 +1689,31 @@ mod tests { assert_eq!(config.theme, "nc-mono"); } + #[test] + fn animations_default_to_on() { + assert!(parse("").animations, "ui-animations: the default is animated"); + assert!(Config::default().animations); + assert!(parse("splash = false\n").animations, "unrelated keys leave it on"); + } + + #[test] + fn animations_can_be_disabled_in_config() { + assert!(!parse("animations = false\n").animations); + assert!(parse("animations = true\n").animations); + } + + #[test] + fn animations_unparsable_value_falls_back_to_the_default() { + assert!(parse("animations = maybe\n").animations); + } + + #[test] + fn animations_key_inside_the_mouse_table_is_not_a_general_option() { + // Only `enabled` is read under `[mouse]`; a stray `animations` line + // there must not flip the general option. + assert!(parse("[mouse]\nanimations = false\n").animations); + } + #[test] fn tolerates_comments_and_unknown_keys() { let config = parse("# a comment\nfoo = \"bar\"\nsplash=true\n"); @@ -1123,8 +1729,9 @@ mod tests { #[test] fn load_missing_file_returns_default() { - let config = load(Path::new("this/path/definitely/does/not/exist/config.toml")); - assert_eq!(config, Config::default()); + let parsed = load(Path::new("this/path/definitely/does/not/exist/config.toml")); + assert_eq!(parsed.config, Config::default()); + assert!(parsed.warnings.is_empty(), "a missing config.toml is not a warning"); } #[test] @@ -1171,6 +1778,18 @@ mod tests { // Ctrl+Shift+Ins for Paths. assert_eq!(keys.clipboard_files, KeyBinding::new(true, false, false, "c")); assert_eq!(keys.clipboard_paths, KeyBinding::new(true, false, true, "insert")); + // command-line "Clear the command line with a dedicated key": + // Ctrl+Y by default. + assert_eq!(keys.clear_line, KeyBinding::new(true, false, false, "y")); + } + + #[test] + fn parses_overridable_clear_line_binding() { + // command-line "Clear the command line with a dedicated key" + // scenario "Rebinding the clear key". + let config = parse("key.clear_line = \"ctrl+k\"\n"); + assert_eq!(config.keys.clear_line, KeyBinding::new(true, false, false, "k")); + assert_eq!(parse("").keys.clear_line, KeyBinding::new(true, false, false, "y"), "unset keeps the default"); } #[test] @@ -1200,6 +1819,308 @@ mod tests { assert_eq!(parse_binding("ctrl"), None); } + // ------------------------------------------------------------------- + // Shared tokenizer (configuration-files "Configuration files tolerate a + // UTF-8 BOM and inline comments"; design D1) + // ------------------------------------------------------------------- + + fn kv(key: &str, value: Value, comment: Option<&str>) -> LineKind { + LineKind::KeyValue { key: key.to_string(), value, comment: comment.map(str::to_string) } + } + + fn one(input: &str, mode: QuoteMode) -> LineKind { + tokenize_line(input, mode) + } + + #[test] + fn strip_bom_removes_exactly_one_leading_mark() { + assert_eq!(strip_bom("\u{feff}splash = false"), "splash = false"); + assert_eq!(strip_bom("splash = false"), "splash = false"); + assert_eq!(strip_bom("\u{feff}\u{feff}x"), "\u{feff}x"); + assert_eq!(strip_bom(""), ""); + } + + #[test] + fn tokenizer_blank_lines_and_whole_line_comments() { + assert_eq!(one("", QuoteMode::Verbatim), LineKind::Blank); + assert_eq!(one(" ", QuoteMode::Verbatim), LineKind::Blank); + assert_eq!(one("# a comment", QuoteMode::Verbatim), LineKind::Blank); + assert_eq!(one(" # indented comment", QuoteMode::Verbatim), LineKind::Blank); + } + + #[test] + fn tokenizer_table_headers_with_and_without_comments() { + assert_eq!(one("[mouse]", QuoteMode::Verbatim), LineKind::Table { name: "mouse".to_string(), array: false }); + assert_eq!(one("[mouse] # pointer settings", QuoteMode::Verbatim), LineKind::Table { name: "mouse".to_string(), array: false }); + assert_eq!(one("[[entry]]", QuoteMode::TomlEscapes), LineKind::Table { name: "entry".to_string(), array: true }); + assert_eq!(one("[[entry]] # c", QuoteMode::TomlEscapes), LineKind::Table { name: "entry".to_string(), array: true }); + assert!(matches!(one("[mouse", QuoteMode::Verbatim), LineKind::Malformed { .. })); + assert!(matches!(one("[mouse] x", QuoteMode::Verbatim), LineKind::Malformed { .. })); + } + + #[test] + fn tokenizer_quoted_values_keep_hashes_and_drop_comments() { + assert_eq!(one("k = \"a # b\"", QuoteMode::Verbatim), kv("k", Value::Basic("a # b".to_string()), None)); + assert_eq!(one("k = \"x\" # c \"d\"", QuoteMode::Verbatim), kv("k", Value::Basic("x".to_string()), Some("# c \"d\""))); + assert_eq!(one("k = 'x' # c", QuoteMode::Verbatim), kv("k", Value::Literal("x".to_string()), Some("# c"))); + assert_eq!( + one("k = \"C:\\dir\\\"", QuoteMode::Verbatim), + kv("k", Value::Basic("C:\\dir\\".to_string()), None), + "a trailing backslash still terminates in Verbatim mode" + ); + assert_eq!( + one("k = \"say \\\"hi\\\"\"", QuoteMode::Verbatim), + kv("k", Value::Basic("say \\\"hi\\\"".to_string()), None), + "verbatim inner text" + ); + assert_eq!(one("k = \"a\" b\"", QuoteMode::Verbatim), kv("k", Value::Basic("a\" b".to_string()), None), "the last quote qualifies"); + assert_eq!(one("k = \"\"", QuoteMode::Verbatim), kv("k", Value::Basic(String::new()), None)); + } + + #[test] + fn tokenizer_toml_escapes_mode_does_not_close_on_an_escaped_quote() { + assert_eq!(one("k = \"a \\\" # b\" # c", QuoteMode::TomlEscapes), kv("k", Value::Basic("a \\\" # b".to_string()), Some("# c"))); + assert!( + matches!(one("k = \"C:\\dir\\\"", QuoteMode::TomlEscapes), LineKind::Malformed { .. }), + "in TOML the backslash escapes the quote, so it is unterminated" + ); + } + + #[test] + fn tokenizer_bare_values_start_a_comment_only_after_whitespace() { + assert_eq!(one("k = C:\\Tools\\C#\\edit.exe", QuoteMode::Verbatim), kv("k", Value::Bare("C:\\Tools\\C#\\edit.exe".to_string()), None)); + assert_eq!(one("k = v # c", QuoteMode::Verbatim), kv("k", Value::Bare("v".to_string()), Some("# c"))); + assert_eq!(one("k = #c", QuoteMode::Verbatim), kv("k", Value::Bare(String::new()), Some("#c"))); + assert_eq!(one("k = true", QuoteMode::Verbatim), kv("k", Value::Bare("true".to_string()), None)); + assert_eq!(one("k =", QuoteMode::Verbatim), kv("k", Value::Bare(String::new()), None)); + assert_eq!(one("k=v", QuoteMode::Verbatim), kv("k", Value::Bare("v".to_string()), None)); + } + + #[test] + fn tokenizer_malformed_lines() { + assert_eq!(one("k = \"x", QuoteMode::Verbatim), LineKind::Malformed { reason: "unterminated quoted value".to_string() }); + assert_eq!(one("k = \"x\" y", QuoteMode::Verbatim), LineKind::Malformed { reason: "unexpected text after the closing quote".to_string() }); + assert_eq!(one("k \"x\"", QuoteMode::Verbatim), LineKind::Malformed { reason: "expected key = value".to_string() }); + assert!(matches!(one("= x", QuoteMode::Verbatim), LineKind::Malformed { .. })); + } + + #[test] + fn tokenizer_line_numbers_are_one_based_and_survive_crlf() { + let lines = tokenize("# c\r\n\r\nsplash = false\r\n[mouse]\r\nenabled = true", QuoteMode::Verbatim); + let numbers: Vec = lines.iter().map(|l| l.number).collect(); + assert_eq!(numbers, vec![1, 2, 3, 4, 5]); + assert_eq!(lines[2].kind, kv("splash", Value::Bare("false".to_string()), None)); + assert_eq!(lines[3].kind, LineKind::Table { name: "mouse".to_string(), array: false }); + assert_eq!(lines[4].kind, kv("enabled", Value::Bare("true".to_string()), None)); + } + + // ------------------------------------------------------------------- + // config.toml: comments, BOM, and warnings (configuration-files + // "Malformed or unrecognized configuration lines produce startup + // warnings"; design D2) + // ------------------------------------------------------------------- + + /// Render a `Config` as a complete `config.toml`, every recognized key + /// present, optionally with a comment after each value. Quoted values + /// are verbatim (`config.toml` has no escapes), so `vq`, not + /// `toml_quote`. + fn render_full_config(config: &Config, comment: &str) -> String { + let vq = |s: &str| format!("\"{s}\""); + let b = |k: &KeyBinding| { + let mut parts = Vec::new(); + if k.ctrl { + parts.push("ctrl"); + } + if k.alt { + parts.push("alt"); + } + if k.shift { + parts.push("shift"); + } + parts.push(&k.key); + vq(&parts.join("+")) + }; + let keys = &config.keys; + let lines = [ + format!("splash = {}", config.splash), + format!("animations = {}", config.animations), + format!("theme = {}", vq(&config.theme)), + format!("shell = {}", vq(config.shell.as_deref().unwrap_or("cmd.exe /C"))), + format!("editor = {}", vq(config.editor.as_deref().unwrap_or("code -w"))), + format!("panel_split = {}", config.panel_split), + format!("key.paste_name = {}", b(&keys.paste_name)), + format!("key.paste_path = {}", b(&keys.paste_path)), + format!("key.quick_filter = {}", b(&keys.quick_filter)), + format!("key.fuzzy_jump = {}", b(&keys.fuzzy_jump)), + format!("key.split_left = {}", b(&keys.split_left)), + format!("key.split_right = {}", b(&keys.split_right)), + format!("key.split_reset = {}", b(&keys.split_reset)), + format!("key.clipboard_files = {}", b(&keys.clipboard_files)), + format!("key.clipboard_paths = {}", b(&keys.clipboard_paths)), + format!("key.clear_line = {}", b(&keys.clear_line)), + "[mouse]".to_string(), + format!("enabled = {}", config.mouse_enabled), + ]; + lines.iter().map(|l| format!("{l}{comment}\n")).collect() + } + + #[test] + fn default_config_rendered_as_a_file_parses_back_with_zero_warnings() { + let rendered = render_full_config(&Config::default(), ""); + let parsed = super::parse(&rendered); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + let expected = Config { shell: Some("cmd.exe /C".to_string()), editor: Some("code -w".to_string()), ..Config::default() }; + assert_eq!(parsed.config, expected); + } + + /// R-16 regression: an inline comment after every recognized key's + /// value yields the same `Config` as the uncommented file. + #[test] + fn inline_comments_after_every_recognized_key_do_not_change_the_config() { + let custom = Config { + splash: false, + animations: false, + theme: "purple-lights".to_string(), + shell: Some("pwsh -NoLogo -Command".to_string()), + editor: Some("C:\\Tools\\C# Editor\\edit.exe".to_string()), + panel_split: 40, + mouse_enabled: false, + keys: Keys { fuzzy_jump: KeyBinding::new(true, false, true, "g"), ..Keys::default() }, + }; + let plain = super::parse(&render_full_config(&custom, "")); + let commented = super::parse(&render_full_config(&custom, " # a comment")); + assert!(plain.warnings.is_empty(), "{:?}", plain.warnings); + assert!(commented.warnings.is_empty(), "{:?}", commented.warnings); + assert_eq!(plain.config, custom); + assert_eq!(commented.config, plain.config); + } + + #[test] + fn bom_and_inline_comments_are_read_as_intended() { + let p = super::parse("\u{feff}splash = false # start straight in the panels\ntheme = \"purple-lights\" # favourite\npanel_split = 40 # c\n"); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert!(!p.config.splash); + assert_eq!(p.config.theme, "purple-lights"); + assert_eq!(p.config.panel_split, 40); + assert_eq!(parse("editor = \"C:\\Tools\\C# Editor\\edit.exe\"\n").editor.as_deref(), Some("C:\\Tools\\C# Editor\\edit.exe")); + assert_eq!(parse("editor = C:\\Tools\\C#\\edit.exe\n").editor.as_deref(), Some("C:\\Tools\\C#\\edit.exe")); + assert!(!parse("[mouse] # pointer settings\nenabled = false\n").mouse_enabled); + } + + /// The README example as it stood before this change: `[mouse]` before + /// the ten `key.*` lines. + const OLD_README_EXAMPLE: &str = "splash = true\nanimations = true\ntheme = \"nc-classic\"\nshell = \"cmd.exe /C\"\neditor = \"code -w\"\npanel_split = 50\n\n[mouse]\nenabled = true\n\nkey.paste_name = \"ctrl+enter\"\nkey.paste_path = \"ctrl+]\"\nkey.quick_filter = \"ctrl+p\"\nkey.fuzzy_jump = \"ctrl+j\"\nkey.split_left = \"ctrl+left\"\nkey.split_right = \"ctrl+right\"\nkey.split_reset = \"ctrl+=\"\nkey.clipboard_files = \"ctrl+c\"\nkey.clipboard_paths = \"ctrl+shift+insert\"\nkey.clear_line = \"ctrl+y\"\n"; + + /// R-16 regression: the old README example silently dropped every + /// binding. Now each misplaced `key.*` line is reported by number. + #[test] + fn readme_example_with_mouse_table_first_warns_for_every_misplaced_binding() { + let parsed = super::parse(OLD_README_EXAMPLE); + assert_eq!(parsed.config.keys, Keys::default(), "the bindings stay at their defaults, as before"); + assert_eq!(parsed.warnings.len(), 10, "{:?}", parsed.warnings); + for (i, w) in parsed.warnings.iter().enumerate() { + assert_eq!(w.file, CONFIG_FILE); + assert_eq!(w.line, 11 + i, "{w}"); + assert!(w.message.contains("not a [mouse] setting"), "{w}"); + assert!(w.message.contains("before the first [table]"), "{w}"); + } + assert_eq!( + parsed.warnings[3].to_string(), + "config.toml line 14: \"key.fuzzy_jump\" is not a [mouse] setting and was ignored — put top-level settings before the first [table]" + ); + assert_eq!(summarize_warnings(&parsed.warnings).unwrap(), format!("{} (+9 more)", parsed.warnings[0])); + } + + #[test] + fn corrected_readme_example_parses_with_zero_warnings_and_all_bindings() { + // `[mouse]` moved to the end, and `key.fuzzy_jump` changed so the + // bindings demonstrably apply. + let corrected = + OLD_README_EXAMPLE.replace("[mouse]\nenabled = true\n\n", "").replace("key.fuzzy_jump = \"ctrl+j\"", "key.fuzzy_jump = \"ctrl+g\"") + + "\n[mouse]\nenabled = false\n"; + let parsed = super::parse(&corrected); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + assert_eq!(parsed.config.keys.fuzzy_jump, KeyBinding::new(true, false, false, "g")); + assert!(!parsed.config.mouse_enabled); + let expected_keys = Keys { fuzzy_jump: KeyBinding::new(true, false, false, "g"), ..Keys::default() }; + assert_eq!(parsed.config.keys, expected_keys, "all ten bindings applied"); + } + + #[test] + fn warnings_for_unknown_key_malformed_line_unknown_table_and_bad_values() { + assert_eq!(warnings_of("colour = \"red\"\nsplash = false\n"), vec!["config.toml line 1: unknown setting \"colour\"; ignored"]); + assert_eq!(warnings_of("# c\nsplash false\n"), vec!["config.toml line 2: expected key = value; line ignored"]); + assert!(super::parse("# c\nsplash false\n").config.splash, "the line is ignored and the default stands"); + assert_eq!( + warnings_of("[colors]\nfg = \"red\"\nbg = \"blue\"\n"), + vec!["config.toml line 1: unknown table [colors]; its settings are ignored"], + "one warning for the header covers its keys" + ); + assert_eq!(warnings_of("[[entry]]\n"), vec!["config.toml line 1: unknown table [[entry]]; its settings are ignored"]); + let p = super::parse("animations = yes\n"); + assert!(p.config.animations, "the default stands"); + assert_eq!(p.warnings[0].to_string(), "config.toml line 1: animations must be true or false; using the default"); + assert_eq!( + warnings_of("splash = \"true\"\n"), + vec!["config.toml line 1: splash must be true or false; using the default"], + "booleans are bare in TOML" + ); + assert_eq!(warnings_of("[mouse]\nenabled = maybe\n"), vec!["config.toml line 2: enabled must be true or false; using the default"]); + let p = super::parse("key.fuzzy_jump = \"\"\n"); + assert_eq!(p.config.keys.fuzzy_jump, Keys::default().fuzzy_jump); + assert_eq!(p.warnings[0].to_string(), "config.toml line 1: key.fuzzy_jump \"\" is not a key binding; using the default"); + assert_eq!( + warnings_of("key.paste_name = \"ctrl\"\n"), + vec!["config.toml line 1: key.paste_name \"ctrl\" is not a key binding; using the default"] + ); + assert_eq!(warnings_of("key.paste_name = \"ctrl+\"\n"), Vec::::new(), "a trailing + is the + key, as parse_binding always read it"); + } + + #[test] + fn theme_and_panel_split_values_keep_their_silent_fallbacks() { + assert_eq!(warnings_of("theme = \"no-such-theme\"\n"), Vec::::new()); + assert_eq!(warnings_of("panel_split = \"wide\"\npanel_split = 150\n"), Vec::::new()); + let p = super::parse("# c\n# c\ntheme = \"purple-lights\n"); + assert_eq!(p.config.theme, DEFAULT_THEME_NAME, "the unterminated line is ignored"); + assert_eq!( + p.warnings.iter().map(ToString::to_string).collect::>(), + vec!["config.toml line 3: unterminated quoted value; line ignored"] + ); + } + + #[test] + fn summarize_warnings_formats_first_plus_count() { + let none: Vec = Vec::new(); + assert_eq!(summarize_warnings(&none), None); + assert_eq!(summarize_warnings(&["a".to_string()]).as_deref(), Some("a")); + assert_eq!(summarize_warnings(&["a".to_string(), "b".to_string(), "c".to_string()]).as_deref(), Some("a (+2 more)")); + let w = ConfigWarning::whole_file(CONFIG_FILE, "is not UTF-8 text; using default settings"); + assert_eq!(w.to_string(), "config.toml is not UTF-8 text; using default settings"); + } + + /// `load`: missing → defaults silently; Windows-1252 → defaults plus the + /// not-UTF-8 warning; BOM → the first key is read. + #[test] + fn load_distinguishes_missing_not_utf8_and_bom_files() { + let dir = temp_dir("load"); + let path = dir.join(CONFIG_FILE); + + let missing = load(&path); + assert_eq!(missing, ParsedConfig::default()); + + std::fs::write(&path, b"theme = \"caf\xe9\"\nsplash = false\n").unwrap(); + let ansi = load(&path); + assert_eq!(ansi.config, Config::default(), "defaults apply"); + assert_eq!(ansi.warnings.iter().map(ToString::to_string).collect::>(), vec!["config.toml is not UTF-8 text; using default settings"]); + + std::fs::write(&path, "\u{feff}splash = false\n").unwrap(); + let bom = load(&path); + assert!(!bom.config.splash, "the first key is read despite the BOM"); + assert!(bom.warnings.is_empty(), "{:?}", bom.warnings); + + let _ = std::fs::remove_dir_all(&dir); + } + // ------------------------------------------------------------------- // config.toml theme persistence (visual-themes) // ------------------------------------------------------------------- @@ -1241,9 +2162,9 @@ mod tests { save_theme_atomic(&path, "purple-lights").expect("write theme"); let contents = std::fs::read_to_string(&path).unwrap(); assert_eq!(contents, "splash = false\ntheme = \"purple-lights\"\n"); - assert!(!path.with_file_name("config.toml.tmp").exists(), "temp file must be renamed away"); + assert_eq!(leftover_temp_files(&dir), Vec::::new(), "temp file must be renamed away"); - let loaded = load(&path); + let loaded = load(&path).config; assert_eq!(loaded.theme, "purple-lights"); assert!(!loaded.splash, "unrelated keys survive the theme write"); let _ = std::fs::remove_dir_all(&dir); @@ -1256,7 +2177,44 @@ mod tests { let path = dir.join("config.toml"); save_theme_atomic(&path, "inverted").expect("write theme to a fresh file"); - assert_eq!(load(&path).theme, "inverted"); + assert_eq!(load(&path).config.theme, "inverted"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// configuration-files "Failed configuration write leaves the file + /// untouched": when the replace step fails (here: another handle holds + /// `config.toml` open with sharing denied, so the rename over it is + /// refused), the target is byte-identical and no temp file remains. + #[cfg(windows)] + #[test] + fn save_theme_atomic_failure_leaves_config_untouched_and_no_temp_file() { + use std::os::windows::fs::OpenOptionsExt; + let dir = temp_dir("theme-write-fails"); + let path = dir.join("config.toml"); + let original = "splash = false\ntheme = \"nc-classic\" # keep\n"; + std::fs::write(&path, original).unwrap(); + let lock = std::fs::OpenOptions::new().read(true).share_mode(0).open(&path).expect("open config.toml with sharing denied"); + + let err = save_theme_atomic(&path, "purple-lights").expect_err("the replace must fail while the target is locked"); + assert_ne!(err.kind(), io::ErrorKind::NotFound); + drop(lock); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original, "the target is unchanged"); + assert_eq!(leftover_temp_files(&dir), Vec::::new(), "the temp file is removed on failure"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `config.toml` that is not UTF-8 text is refused rather than + /// replaced by a one-line file: the rewrite could not keep its other + /// settings. + #[test] + fn save_theme_atomic_refuses_to_clobber_a_non_utf8_config() { + let dir = temp_dir("theme-write-ansi"); + let path = dir.join("config.toml"); + let ansi = b"theme = \"caf\xe9\"\n".to_vec(); + std::fs::write(&path, &ansi).unwrap(); + save_theme_atomic(&path, "inverted").expect_err("non-UTF-8 config.toml is not rewritten"); + assert_eq!(std::fs::read(&path).unwrap(), ansi); + assert_eq!(leftover_temp_files(&dir), Vec::::new()); let _ = std::fs::remove_dir_all(&dir); } @@ -1299,9 +2257,9 @@ mod tests { save_panel_split_atomic(&path, 66).expect("write panel_split"); let contents = std::fs::read_to_string(&path).unwrap(); assert_eq!(contents, "splash = false\npanel_split = 66\n"); - assert!(!path.with_file_name("config.toml.tmp").exists(), "temp file must be renamed away"); + assert_eq!(leftover_temp_files(&dir), Vec::::new(), "temp file must be renamed away"); - let loaded = load(&path); + let loaded = load(&path).config; assert_eq!(loaded.panel_split, 66); assert!(!loaded.splash, "unrelated keys survive the write"); let _ = std::fs::remove_dir_all(&dir); @@ -1314,10 +2272,83 @@ mod tests { let path = dir.join("config.toml"); save_panel_split_atomic(&path, 40).expect("write panel_split to a fresh file"); - assert_eq!(load(&path).panel_split, 40); + assert_eq!(load(&path).config.panel_split, 40); let _ = std::fs::remove_dir_all(&dir); } + // ------------------------------------------------------------------- + // Table-aware rewrites (configuration-files "Configuration rewrites + // keep settings in the top-level table"; design D5) + // ------------------------------------------------------------------- + + #[test] + fn set_top_level_key_inserts_a_missing_key_above_the_first_table() { + let input = "[mouse]\nenabled = false\n"; + let out = set_theme_line(input, "purple-lights"); + assert_eq!(out, "theme = \"purple-lights\"\n[mouse]\nenabled = false\n"); + let parsed = super::parse(&out); + assert_eq!(parsed.config.theme, "purple-lights"); + assert!(!parsed.config.mouse_enabled, "the [mouse] table still applies"); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + + let out = set_panel_split_line(input, 60); + assert_eq!(out, "panel_split = 60\n[mouse]\nenabled = false\n"); + assert_eq!(super::parse(&out).config.panel_split, 60); + } + + #[test] + fn set_top_level_key_replaces_keeping_the_trailing_comment() { + let input = "# header\nsplash = true\ntheme = \"nc-classic\" # my pick\nshell = \"cmd.exe /C\"\n"; + let out = set_theme_line(input, "yellow-storm"); + assert_eq!(out, "# header\nsplash = true\ntheme = \"yellow-storm\" # my pick\nshell = \"cmd.exe /C\"\n"); + let parsed = super::parse(&out); + assert_eq!(parsed.config.theme, "yellow-storm"); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + } + + #[test] + fn set_top_level_key_preserves_the_bom_with_exactly_one_key_line() { + let input = "\u{feff}theme = \"nc-classic\"\nsplash = true\n"; + let out = set_theme_line(input, "inverted"); + assert_eq!(out, "\u{feff}theme = \"inverted\"\nsplash = true\n"); + assert_eq!(out.matches("theme =").count(), 1); + assert_eq!(super::parse(&out).config.theme, "inverted"); + } + + #[test] + fn set_top_level_key_does_not_replace_a_key_inside_a_table() { + let input = "splash = true\n[mouse]\ntheme = \"nc-mono\"\n"; + let out = set_theme_line(input, "inverted"); + assert_eq!( + out, "splash = true\ntheme = \"inverted\"\n[mouse]\ntheme = \"nc-mono\"\n", + "the in-table line is left as it was; the new line goes above the table" + ); + assert_eq!(super::parse(&out).config.theme, "inverted"); + } + + #[test] + fn set_top_level_key_appends_when_there_is_no_table_and_keeps_crlf() { + assert_eq!(set_theme_line("splash = true\n", "x"), "splash = true\ntheme = \"x\"\n"); + assert_eq!(set_theme_line("splash = true\r\n# c\r\n", "x"), "splash = true\r\n# c\r\ntheme = \"x\"\r\n", "CRLF input stays CRLF"); + assert_eq!(set_theme_line("splash = true\r\ntheme = \"a\" # k\r\n", "b"), "splash = true\r\ntheme = \"b\" # k\r\n"); + } + + #[test] + fn set_top_level_key_round_trips_with_no_warnings() { + let input = "# comment\nsplash = false # inline\neditor = C:\\Tools\\C#\\edit.exe\n\n[mouse] # pointer\nenabled = false\n"; + let out = set_panel_split_line(input, 35); + let parsed = super::parse(&out); + assert_eq!(parsed.config.panel_split, 35); + assert_eq!(parsed.config.editor.as_deref(), Some("C:\\Tools\\C#\\edit.exe")); + assert!(!parsed.config.splash); + assert!(!parsed.config.mouse_enabled); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + // Every original line is still present, byte for byte. + for line in input.lines() { + assert!(out.lines().any(|l| l == line), "line {line:?} missing from {out:?}"); + } + } + #[test] fn history_json_roundtrips_including_escapes() { let entries = vec![r#"echo "hi""#.to_string(), "dir C:\\Users".to_string(), "line\nbreak".to_string()]; @@ -1336,6 +2367,64 @@ mod tests { assert_eq!(parse_history("[\"complete\", \"trunc"), vec!["complete".to_string()]); } + // ------------------------------------------------------------------- + // Lossless history string decoding (configuration-files "History file + // strings decode without losing entries"; design D7) + // ------------------------------------------------------------------- + + #[test] + fn history_surrogate_pair_decodes_to_one_character() { + assert_eq!(parse_history(r#"["echo \uD83D\uDE00"]"#), vec!["echo 😀".to_string()]); + } + + #[test] + fn history_lone_or_bad_unicode_escapes_become_replacement_chars_without_dropping_entries() { + assert_eq!(parse_history(r#"["cd a","cd b\uD83D","cd c"]"#), vec!["cd a".to_string(), "cd b\u{fffd}".to_string(), "cd c".to_string()]); + assert_eq!( + parse_history(r#"["x\uZZZZy","z"]"#), + vec!["x\u{fffd}ZZZZy".to_string(), "z".to_string()], + "a \\u without hex digits is U+FFFD and parsing continues" + ); + assert_eq!(parse_history(r#"["a\uDE00b"]"#), vec!["a\u{fffd}b".to_string()], "a stray low surrogate"); + assert_eq!( + parse_history(r#"["a\uD83Db","c"]"#), + vec!["a\u{fffd}b".to_string(), "c".to_string()], + "a high surrogate not followed by a low one" + ); + assert_eq!(parse_history(r#"["a\u00e9\u0041\n\t\b\f\/\\\""]"#), vec!["aéA\n\t\u{8}\u{c}/\\\"".to_string()]); + assert_eq!(parse_history(r#"["short\u12"]"#), vec!["short\u{fffd}12".to_string()], "a short escape never swallows the closing quote"); + } + + #[test] + fn history_file_key_text_inside_a_string_is_not_mistaken_for_the_key() { + let input = "{\n \"commands\": [\"directories\", \"cd C:\\\\\"],\n \"directories\": [\n {\"path\": \"C:\\\\a\", \"visit_count\": 2, \"last_visited_ms\": 5},\n {\"path\": \"C:\\\\b\", \"visit_count\": 1, \"last_visited_ms\": 6}\n ]\n}\n"; + let parsed = parse_history_file(input); + assert_eq!(parsed.commands, vec!["directories".to_string(), "cd C:\\".to_string()]); + assert_eq!(parsed.directories.len(), 2, "{:?}", parsed.directories); + assert_eq!(parsed.directories[0].path, PathBuf::from("C:\\a")); + assert_eq!(parsed.directories[1].path, PathBuf::from("C:\\b")); + // And a directory object whose path *contains* the field name text. + let tricky = "{\"commands\": [], \"directories\": [{\"visit_count\": 3, \"path\": \"C:\\\\\\\"path\\\"\", \"last_visited_ms\": 1}]}"; + let parsed = parse_history_file(tricky); + assert_eq!(parsed.directories, vec![FrecencyEntry { path: PathBuf::from("C:\\\"path\""), visit_count: 3, last_visited_ms: 1 }]); + } + + #[test] + fn history_directory_with_a_surrogate_escape_in_its_path_is_kept() { + let input = "{\"commands\": [], \"directories\": [{\"path\": \"C:\\\\\\uD83D\", \"visit_count\": 1, \"last_visited_ms\": 1}, {\"path\": \"C:\\\\ok\", \"visit_count\": 1, \"last_visited_ms\": 2}]}"; + let parsed = parse_history_file(input); + assert_eq!(parsed.directories.len(), 2); + assert_eq!(parsed.directories[0].path, PathBuf::from("C:\\\u{fffd}")); + } + + #[test] + fn bom_prefixed_history_file_parses() { + let file = sample_history_file(); + let rendered = format!("\u{feff}{}", render_history_file(&file)); + assert_eq!(parse_history_file(&rendered), file); + assert_eq!(parse_history("\u{feff}[\"a\"]"), vec!["a".to_string()]); + } + #[test] fn push_history_dedupes_and_caps() { let mut history = vec!["a".to_string(), "b".to_string()]; @@ -1406,7 +2495,10 @@ mod tests { fn history_file_paths_with_backslashes_round_trip() { // Windows paths are the norm here; the escaped backslashes must // survive the JSON round trip intact. - let file = HistoryFile { commands: vec![], directories: vec![FrecencyEntry { path: PathBuf::from(r"C:\A\B\C"), visit_count: 5, last_visited_ms: 42 }] }; + let file = HistoryFile { + commands: vec![], + directories: vec![FrecencyEntry { path: PathBuf::from(r"C:\A\B\C"), visit_count: 5, last_visited_ms: 42 }], + }; let rendered = render_history_file(&file); assert_eq!(parse_history_file(&rendered), file); } @@ -1446,6 +2538,149 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // ------------------------------------------------------------------- + // history-persistence-hardening: format marker, migration, cap, merge, + // durable write (fuzzy-jump "Frecency ranking", "Directory history + // persistence"; design D2, D3, D5, D6) + // ------------------------------------------------------------------- + + fn dir(path: &str, count: u32, ms: u64) -> FrecencyEntry { + FrecencyEntry { path: PathBuf::from(path), visit_count: count, last_visited_ms: ms } + } + + const LEGACY_FILE: &str = "{\n \"commands\": [\"dir\"],\n \"directories\": [\n {\"path\": \"C:\\\\a\", \"visit_count\": 3, \"last_visited_ms\": 12345},\n {\"path\": \"C:\\\\b\", \"visit_count\": 1, \"last_visited_ms\": 999}\n ]\n}\n"; + + #[test] + fn render_writes_the_version_marker_and_a_round_trip_is_not_legacy() { + let rendered = render_history_file(&sample_history_file()); + assert!(rendered.starts_with("{\n \"version\": 2,\n \"commands\": "), "{rendered}"); + let loaded = parse_history_document(&rendered); + assert!(!loaded.legacy); + assert_eq!(loaded.file, sample_history_file()); + assert!(!parse_history_document(&render_history_file(&HistoryFile::default())).legacy); + } + + #[test] + fn a_file_without_the_version_key_is_legacy_and_a_version_inside_a_string_does_not_count() { + assert!(parse_history_document(LEGACY_FILE).legacy); + assert_eq!(parse_history_document(LEGACY_FILE).file.commands, vec!["dir".to_string()]); + let tricky = "{\n \"commands\": [\"echo \\\"version\\\": 2\"],\n \"directories\": []\n}\n"; + assert!(parse_history_document(tricky).legacy, "the marker is only ever a top-level key"); + assert_eq!(parse_history_document(tricky).file.commands, vec!["echo \"version\": 2".to_string()]); + assert!(parse_history_document("not json at all").legacy); + assert_eq!(parse_history_document("not json at all").file, HistoryFile::default(), "the existing malformed-file tolerance is unchanged"); + assert_eq!(load_history_document(Path::new("no/such/history.json")), HistoryLoad::default(), "a missing file is empty and not legacy"); + } + + #[test] + fn migrate_restamps_every_legacy_visit_to_now() { + let mut loaded = parse_history_document(LEGACY_FILE); + let now = 1_787_000_000_000; + migrate_loaded_history(&mut loaded.file, loaded.legacy, now); + assert_eq!(loaded.file.directories, vec![dir(r"C:\a", 3, now), dir(r"C:\b", 1, now)], "counts carry over, times become the load time"); + } + + #[test] + fn migrate_keeps_v2_timestamps_except_future_ones_which_clamp_to_now() { + let now = 1_787_000_000_000; + let mut file = HistoryFile { + commands: vec![], + directories: vec![dir(r"C:\past", 2, now - 5_000), dir(r"C:\future", 2, now + 60_000), dir(r"C:\now", 1, now)], + }; + migrate_loaded_history(&mut file, false, now); + assert_eq!(file.directories, vec![dir(r"C:\past", 2, now - 5_000), dir(r"C:\future", 2, now), dir(r"C:\now", 1, now)]); + } + + #[test] + fn migrate_prunes_an_oversized_file_to_the_cap() { + let now = 1_787_000_000_000; + let directories: Vec = (0..1_250).map(|i| dir(&format!(r"C:\d{i}"), i as u32 + 1, now)).collect(); + let mut file = HistoryFile { commands: vec![], directories }; + migrate_loaded_history(&mut file, false, now); + assert_eq!(file.directories.len(), quicksearch::MAX_DIR_HISTORY); + assert!(file.directories.iter().all(|d| d.visit_count > 250), "the 250 lowest-frecency entries were the ones dropped"); + } + + #[test] + fn merge_unions_disjoint_directories_with_this_instances_entries_first() { + let now = 1_000; + let disk = HistoryFile { commands: vec![], directories: vec![dir(r"C:\disk-only", 2, 10)] }; + let mine = HistoryFile { commands: vec![], directories: vec![dir(r"C:\mine-1", 1, 20), dir(r"C:\mine-2", 1, 30)] }; + let merged = merge_history_files(&disk, &mine, now); + assert_eq!(merged.directories, vec![dir(r"C:\mine-1", 1, 20), dir(r"C:\mine-2", 1, 30), dir(r"C:\disk-only", 2, 10)]); + } + + /// fuzzy-jump "Merge keeps the larger count and the later visit". + #[test] + fn merge_takes_max_count_and_max_time_for_an_overlapping_path_and_is_idempotent() { + let now = 5_000; + let disk = HistoryFile { commands: vec![], directories: vec![dir(r"C:\src", 9, 1_000)] }; + let mine = HistoryFile { commands: vec![], directories: vec![dir(r"C:\src", 4, 2_000)] }; + let merged = merge_history_files(&disk, &mine, now); + assert_eq!(merged.directories, vec![dir(r"C:\src", 9, 2_000)]); + let again = merge_history_files(&merged, &mine, now); + assert_eq!(again, merged, "merging the result with `mine` again changes nothing"); + let and_again = merge_history_files(&again, &mine, now); + assert_eq!(and_again, merged); + } + + /// fuzzy-jump "Another instance's commands are kept". + #[test] + fn merge_puts_disk_only_commands_first_without_duplicates_and_keeps_the_newest_two_hundred() { + let disk = HistoryFile { commands: vec!["cd D:\\build".to_string(), "shared".to_string(), "older".to_string()], directories: vec![] }; + let mine = HistoryFile { commands: vec!["shared".to_string(), "mine-newest".to_string()], directories: vec![] }; + let merged = merge_history_files(&disk, &mine, 0); + assert_eq!(merged.commands, vec!["cd D:\\build".to_string(), "older".to_string(), "shared".to_string(), "mine-newest".to_string()]); + + let disk = HistoryFile { commands: (0..150).map(|i| format!("disk{i}")).collect(), directories: vec![] }; + let mine = HistoryFile { commands: (0..100).map(|i| format!("mine{i}")).collect(), directories: vec![] }; + let merged = merge_history_files(&disk, &mine, 0); + assert_eq!(merged.commands.len(), MAX_HISTORY); + assert_eq!(merged.commands.last().unwrap(), "mine99", "the newest command is kept"); + assert_eq!(merged.commands[0], "disk50", "the oldest disk-only commands are dropped first"); + } + + #[test] + fn merge_prunes_a_directory_union_above_the_cap() { + let now = 1_787_000_000_000; + let disk = HistoryFile { commands: vec![], directories: (0..700).map(|i| dir(&format!(r"C:\disk{i}"), 5, now)).collect() }; + let mine = HistoryFile { commands: vec![], directories: (0..700).map(|i| dir(&format!(r"C:\mine{i}"), 1, now)).collect() }; + let merged = merge_history_files(&disk, &mine, now); + assert_eq!(merged.directories.len(), quicksearch::MAX_DIR_HISTORY); + assert_eq!(merged.directories.iter().filter(|d| d.visit_count == 5).count(), 700, "the higher-frecency disk entries all survive"); + } + + /// fuzzy-jump "Interrupted write leaves a complete file": a failure at + /// the replace step (the target held open with sharing denied) leaves + /// the previous `history.json` byte-identical and no `.fc-tmp` sibling. + #[cfg(windows)] + #[test] + fn save_history_file_atomic_failure_leaves_the_previous_file_and_no_temp() { + use std::os::windows::fs::OpenOptionsExt; + let dir = temp_dir("history-write-fails"); + let path = dir.join(HISTORY_FILE); + save_history_file_atomic(&path, &sample_history_file()).unwrap(); + let original = std::fs::read(&path).unwrap(); + let lock = std::fs::OpenOptions::new().read(true).share_mode(0).open(&path).expect("open history.json with sharing denied"); + save_history_file_atomic(&path, &HistoryFile::default()).expect_err("the replace must fail while the target is locked"); + drop(lock); + assert_eq!(std::fs::read(&path).unwrap(), original, "the previous file is byte-identical"); + assert_eq!(leftover_temp_files(&dir), Vec::::new(), "no .fc-tmp sibling remains"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn history_writes_leave_no_shared_temp_name_behind() { + let dir = temp_dir("history-write-temps"); + let path = dir.join(HISTORY_FILE); + save_history_file_atomic(&path, &sample_history_file()).unwrap(); + save_history_atomic(&dir.join("plain.json"), &["dir".to_string()]).unwrap(); + let names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names.len(), 2, "{names:?}"); + assert!(leftover_temp_files(&dir).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn load_history_file_missing_or_malformed_is_empty() { assert_eq!(load_history_file(Path::new("no/such/history.json")), HistoryFile::default()); @@ -1484,7 +2719,53 @@ mod tests { let parsed = parse_usermenu(input); assert!(!parsed.malformed, "an unknown key is a warning, not a hard failure"); assert_eq!(parsed.entries, vec![UserMenuEntry { label: "Backup".to_string(), command: "robocopy . D:\\backup /E".to_string() }]); - assert!(parsed.warnings.iter().any(|w| w.contains("icon")), "expected a warning mentioning the unknown key: {:?}", parsed.warnings); + assert!(parsed.warnings.iter().any(|w| w.message.contains("icon")), "expected a warning mentioning the unknown key: {:?}", parsed.warnings); + assert_eq!(parsed.warnings[0].to_string(), "usermenu.toml line 4: unknown key \"icon\" in an entry; ignored"); + } + + // ------------------------------------------------------------------- + // usermenu.toml on the shared tokenizer (configuration-files + // "BOM-prefixed usermenu.toml loads its entries", "Inline comment in + // usermenu.toml"; design D3) + // ------------------------------------------------------------------- + + #[test] + fn usermenu_accepts_a_bom_trailing_comments_and_a_commented_header() { + let input = "\u{feff}[[entry]] # first\nlabel = \"Build\" # main build\ncommand = 'cargo build' # c\n"; + let parsed = parse_usermenu(input); + assert!(!parsed.malformed, "{parsed:?}"); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); + assert_eq!(parsed.entries, vec![UserMenuEntry { label: "Build".to_string(), command: "cargo build".to_string() }]); + } + + #[test] + fn usermenu_escaped_quote_does_not_close_the_string() { + let parsed = parse_usermenu("[[entry]]\nlabel = \"Say \\\"hi\\\"\" # c\ncommand = \"echo \\\"hi\\\" # not a comment\"\n"); + assert!(!parsed.malformed, "{parsed:?}"); + assert_eq!(parsed.entries, vec![UserMenuEntry { label: "Say \"hi\"".to_string(), command: "echo \"hi\" # not a comment".to_string() }]); + } + + #[test] + fn usermenu_other_tables_and_unterminated_quotes_are_still_malformed() { + assert!(parse_usermenu("[foo]\n").malformed, "[foo] is not [[entry]]"); + assert!(parse_usermenu("[[entry]]\nlabel = \"A\ncommand = \"a\"\n").malformed, "unterminated quote"); + assert!(parse_usermenu("[[entry]]\nlabel = A\ncommand = \"a\"\n").malformed, "bare value"); + } + + #[test] + fn usermenu_warnings_carry_line_numbers() { + let parsed = parse_usermenu("stray = \"x\"\n\n[[entry]]\nlabel = \"A\"\n\n[[entry]]\nlabel = \"B\"\ncommand = \"b\"\nicon = \"disk\"\n"); + assert!(!parsed.malformed); + let rendered: Vec = parsed.warnings.iter().map(ToString::to_string).collect(); + assert_eq!( + rendered, + vec![ + "usermenu.toml line 1: \"stray\" outside of an [[entry]] block; ignored".to_string(), + "usermenu.toml line 3: entry missing \"command\"; skipped".to_string(), + "usermenu.toml line 9: unknown key \"icon\" in an entry; ignored".to_string(), + ] + ); + assert_eq!(parsed.entries.len(), 1); } #[test] @@ -1556,12 +2837,50 @@ mod tests { let result = load_user_menu(&path); assert!(result.malformed); + assert_eq!(result.problem, Some(UserMenuProblem::InvalidToml)); assert!(!result.created_default); assert_eq!(result.entries, default_user_menu_entries()); assert_eq!(std::fs::read_to_string(&path).unwrap(), garbage, "a malformed file must be left untouched on disk"); let _ = std::fs::remove_dir_all(&dir); } + /// configuration-files "Non-UTF-8 usermenu.toml is kept, not + /// overwritten": a UTF-16LE save and a Windows-1252 save are each + /// malformed (not missing), fall back to the defaults, and are left + /// byte-for-byte on disk. + #[test] + fn load_user_menu_never_overwrites_a_non_utf8_file() { + let utf16: Vec = + [0xFFu8, 0xFE].into_iter().chain("[[entry]]\nlabel = \"A\"\ncommand = \"a\"\n".encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let ansi = b"[[entry]]\nlabel = \"Caf\xe9\"\ncommand = \"a\"\n".to_vec(); + for (name, bytes) in [("utf16", utf16), ("ansi", ansi)] { + let dir = temp_dir(&format!("usermenu-{name}")); + let path = dir.join(USERMENU_FILE); + std::fs::write(&path, &bytes).unwrap(); + let result = load_user_menu(&path); + assert!(result.malformed, "{name}: treated as malformed"); + assert_eq!(result.problem, Some(UserMenuProblem::NotUtf8), "{name}"); + assert!(!result.created_default, "{name}: not treated as missing"); + assert_eq!(result.entries, default_user_menu_entries()); + assert!(result.warnings.is_empty()); + assert_eq!(std::fs::read(&path).unwrap(), bytes, "{name}: bytes on disk unchanged"); + assert_eq!(leftover_temp_files(&dir), Vec::::new()); + let _ = std::fs::remove_dir_all(&dir); + } + } + + #[test] + fn load_user_menu_default_file_is_written_durably_with_no_temp_left() { + let dir = temp_dir("usermenu-default-durable"); + let path = dir.join(USERMENU_FILE); + let result = load_user_menu(&path); + assert!(result.created_default); + assert_eq!(result.problem, None); + assert_eq!(std::fs::read_to_string(&path).unwrap(), render_usermenu(&default_user_menu_entries())); + assert_eq!(leftover_temp_files(&dir), Vec::::new()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn load_user_menu_returns_well_formed_entries_unchanged() { let dir = std::env::temp_dir().join(format!("filecommand-usermenu-test-wellformed-{}", std::process::id())); @@ -1577,6 +2896,73 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + // ------------------------------------------------------------------- + // Configuration profile directory (config-profile-directory "Legacy + // files in the working directory produce a notice, not a migration"; + // design D3) + // ------------------------------------------------------------------- + + /// A `cwd`/`profile` pair of empty directories under one uniquely + /// named temp root, returned with the root so the caller can clean up. + fn legacy_fixture(name: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("filecommand-legacy-{name}-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let cwd = root.join("cwd"); + let profile = root.join("profile"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + (root, cwd, profile) + } + + #[test] + fn legacy_files_present_reports_each_file_individually() { + for (name, file) in [("config", "config.toml"), ("history", HISTORY_FILE), ("usermenu", USERMENU_FILE)] { + let (root, cwd, profile) = legacy_fixture(name); + std::fs::write(cwd.join(file), "").unwrap(); + assert_eq!(legacy_files_present(&cwd, &profile), vec![file], "{file} alone is reported"); + let _ = std::fs::remove_dir_all(&root); + } + } + + #[test] + fn legacy_files_present_lists_all_three_in_order() { + let (root, cwd, profile) = legacy_fixture("all"); + for file in ["config.toml", HISTORY_FILE, USERMENU_FILE] { + std::fs::write(cwd.join(file), "").unwrap(); + } + assert_eq!(legacy_files_present(&cwd, &profile), vec!["config.toml", HISTORY_FILE, USERMENU_FILE]); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn legacy_files_present_is_empty_when_cwd_has_none_of_the_files() { + let (root, cwd, profile) = legacy_fixture("none"); + std::fs::write(cwd.join("unrelated.txt"), "").unwrap(); + assert_eq!(legacy_files_present(&cwd, &profile), Vec::<&'static str>::new()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn legacy_files_present_is_empty_when_the_profile_already_has_a_config() { + let (root, cwd, profile) = legacy_fixture("populated"); + std::fs::write(cwd.join("config.toml"), "").unwrap(); + std::fs::write(cwd.join(HISTORY_FILE), "").unwrap(); + std::fs::write(profile.join("config.toml"), "").unwrap(); + assert_eq!(legacy_files_present(&cwd, &profile), Vec::<&'static str>::new(), "a populated profile means the user already moved on"); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn legacy_files_present_is_empty_when_cwd_is_the_profile() { + // Portable mode and the last-resort cwd fallback: the files in the + // profile directory itself are the live configuration, not legacy + // copies — a `history.json` there must not trigger the notice. + let (root, cwd, _profile) = legacy_fixture("samedir"); + std::fs::write(cwd.join(HISTORY_FILE), "").unwrap(); + assert_eq!(legacy_files_present(&cwd, &cwd), Vec::<&'static str>::new()); + let _ = std::fs::remove_dir_all(&root); + } + // ------------------------------------------------------------------- // Shared identity strings (help-and-about "Identity header matches the // shared source of truth"; user-menu is unrelated but this lives in the diff --git a/crates/filecommand-core/src/dialogs.rs b/crates/filecommand-core/src/dialogs.rs index 3c69942..7837e5c 100644 --- a/crates/filecommand-core/src/dialogs.rs +++ b/crates/filecommand-core/src/dialogs.rs @@ -54,12 +54,7 @@ pub fn clamp_overlay_dim(preferred: u16, minimum: u16, terminal: u16) -> u16 { pub fn overlay_rect(preferred: (u16, u16), minimum: (u16, u16), terminal: (u16, u16)) -> OverlayRect { let width = clamp_overlay_dim(preferred.0, minimum.0, terminal.0); let height = clamp_overlay_dim(preferred.1, minimum.1, terminal.1); - OverlayRect { - x: terminal.0.saturating_sub(width) / 2, - y: terminal.1.saturating_sub(height) / 2, - width, - height, - } + OverlayRect { x: terminal.0.saturating_sub(width) / 2, y: terminal.1.saturating_sub(height) / 2, width, height } } /// The open F2 user menu: just a cursor over `State::user_menu_entries`, @@ -72,6 +67,13 @@ pub fn overlay_rect(preferred: (u16, u16), minimum: (u16, u16), terminal: (u16, #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct UserMenuState { pub cursor: usize, + /// `State::clock_ms` when the menu opened, stamped by the reducer (left + /// `0` by [`UserMenuState::new`]); drives the top-down unfold + /// (ui-animations "Pop-up menus unfold and highlights settle"). + pub opened_at_ms: u64, + /// `State::clock_ms` when the highlight last moved; `None` until it + /// does, so opening the menu unfolds without also flashing row 0. + pub selected_at_ms: Option, } impl UserMenuState { @@ -91,6 +93,45 @@ impl UserMenuState { } } +// --------------------------------------------------------------------- +// Alt+F8 command-history dialog (pulldown-menu-completion; design D2) +// --------------------------------------------------------------------- + +/// The open Alt+F8 command-history dialog: a cursor over `State::history` +/// rendered newest-first — `cursor == 0` is the *newest* entry +/// (command-line "Command history dialog (Alt+F8)"). The same "cursor over +/// a list" shape as [`UserMenuState`], stamped by the reducer for the same +/// unfold/highlight-settle animations (ui-animations "Pop-up menus unfold +/// and highlights settle"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HistoryDialogState { + pub cursor: usize, + /// `State::clock_ms` when the dialog opened, stamped by the reducer (left + /// `0` by [`HistoryDialogState::new`]). + pub opened_at_ms: u64, + /// `State::clock_ms` when the highlight last moved; `None` until it + /// does, so opening the dialog unfolds without also flashing row 0. + pub selected_at_ms: Option, +} + +impl HistoryDialogState { + pub fn new() -> HistoryDialogState { + HistoryDialogState::default() + } + + /// Move the highlight by `delta`, clamped within `[0, len)` — the same + /// clamp-not-wrap convention as [`UserMenuState::move_cursor`]. A no-op + /// on an empty history (its single `(no history)` row never highlights). + pub fn move_cursor(&mut self, delta: isize, len: usize) { + if len == 0 { + self.cursor = 0; + return; + } + let next = self.cursor as isize + delta; + self.cursor = next.clamp(0, len as isize - 1) as usize; + } +} + // --------------------------------------------------------------------- // Options -> Themes picker // --------------------------------------------------------------------- @@ -128,14 +169,17 @@ impl ThemePickerState { // Enter-on-file action menu (file-action-menu) // --------------------------------------------------------------------- -/// One entry in the file-action menu, in menu order. `Run` is included only -/// when the target is executable, and always sorts first (file-action-menu -/// "Menu contents, ordering, and navigation"). `SendToClipboard` sits -/// immediately after `Edit` (or first, for directory targets, which omit +/// One entry in the file-action menu. `Open` launches the target through +/// the platform's file association and always sorts first — except for +/// executables, where `Run` (included only then) stays first and `Open` +/// follows it (open-with-associated-app design D2; file-action-menu "Menu +/// contents, ordering, and navigation"). `SendToClipboard` sits immediately +/// after `Edit` (or right after `Open`, for directory targets, which omit /// `View`/`Edit`/`Run`) and never mutates the filesystem (file-action-menu /// "No mutation without an intervening dialog"). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FileActionMenuEntry { + Open, Run, View, Edit, @@ -152,6 +196,7 @@ impl FileActionMenuEntry { /// activate that entry directly"). pub fn label(self) -> &'static str { match self { + FileActionMenuEntry::Open => "Open", FileActionMenuEntry::Run => "Run", FileActionMenuEntry::View => "View", FileActionMenuEntry::Edit => "Edit", @@ -186,13 +231,21 @@ pub struct FileActionMenuState { /// requirement ("Enter-key invocation SHALL remain single-target and /// file-only"). pub selection_scoped: bool, + /// `State::clock_ms` when the menu opened, stamped by the reducer (left + /// `0` by the constructors); drives the top-down unfold (ui-animations + /// "Pop-up menus unfold and highlights settle"). + pub opened_at_ms: u64, + /// `State::clock_ms` when the highlight last moved; `None` until it + /// does. + pub selected_at_ms: Option, } impl FileActionMenuState { /// The keyboard Enter path: file-only, never selection-scoped /// (file-action-menu "Enter stays single-target"). Opens with the - /// first entry highlighted — `Run` when `executable`, else `View` - /// (file-action-menu "Menu contents, ordering, and navigation"). + /// first entry highlighted — `Run` when `executable`, else `Open` + /// (file-action-menu "Menu contents, ordering, and navigation"; + /// open-with-associated-app design D2). pub fn new(target_name: OsString, executable: bool) -> FileActionMenuState { Self::open(target_name, false, executable, false) } @@ -200,27 +253,24 @@ impl FileActionMenuState { /// The general constructor `new` delegates to, and mouse right-click /// uses directly (mouse-input "Right-click opens the action menu"). /// `is_dir` omits View, Edit, and Run from the menu — they have no - /// meaning for a directory (file-action-menu "Directory targets and - /// selection-scoped invocation": "Directory menu contents"). + /// meaning for a directory — leaving Open first to show the directory + /// in the platform file browser (file-action-menu "Directory targets + /// and selection-scoped invocation": "Directory menu contents"). /// `selection_scoped` is recorded for `activate_file_action` to read /// later; it does not change which entries are listed. pub fn open(target_name: OsString, is_dir: bool, executable: bool, selection_scoped: bool) -> FileActionMenuState { - let mut entries = Vec::with_capacity(7); + let mut entries = Vec::with_capacity(9); + if !is_dir && executable { + entries.push(FileActionMenuEntry::Run); + } + entries.push(FileActionMenuEntry::Open); if !is_dir { - if executable { - entries.push(FileActionMenuEntry::Run); - } entries.push(FileActionMenuEntry::View); entries.push(FileActionMenuEntry::Edit); } entries.push(FileActionMenuEntry::SendToClipboard); - entries.extend([ - FileActionMenuEntry::Copy, - FileActionMenuEntry::Rename, - FileActionMenuEntry::Move, - FileActionMenuEntry::Delete, - ]); - FileActionMenuState { target_name, entries, cursor: 0, selection_scoped } + entries.extend([FileActionMenuEntry::Copy, FileActionMenuEntry::Rename, FileActionMenuEntry::Move, FileActionMenuEntry::Delete]); + FileActionMenuState { target_name, entries, cursor: 0, selection_scoped, opened_at_ms: 0, selected_at_ms: None } } /// Move the highlight by `delta`, clamped within the entry list — same @@ -407,7 +457,7 @@ const KEYBOARD_REFERENCE: &str = "\ F-key bar modifier variants: Ctrl+F3..F7 Sort by name/ext/time/size/unsorted Alt+F1/F2 Drive select (left/right panel) - Alt+F7 Find file + Alt+F7/F8 Find file / command history Plain F-keys: F1 Help F2 User menu F3 View F4 Edit F5 Copy F6 Move/rename F7 Mkdir F8 Delete @@ -423,6 +473,7 @@ Other bindings: Ctrl+P Quick filter Ctrl+J Fuzzy directory jump Ctrl+T/W New/close tab + Ctrl+U Swap panels Alt+1..9 Switch to tab N Alt+letter Type-ahead jump to entry Ctrl+Left/Right Adjust the panel split @@ -459,12 +510,34 @@ const FILE_OPERATIONS: &str = "\ F5 copies, F6 moves/renames, F7 creates a directory, and F8 deletes the selected entries (or the entry under the cursor when nothing is selected). Ins toggles selection on one entry; +, -, and * select, -deselect, and invert a wildcard group."; +deselect, and invert a wildcard group. + +Enter on a file opens the action menu. Its Open entry — first and +highlighted for a non-executable, so Enter, Enter opens a document — +launches the target in the application associated with it, without +leaving FileCommand; on a directory (right-click menu) Open shows it +in the system file browser. Executables list Run first instead, which +suspends FileCommand so a console program's output stays visible. A +failed Open (no association for the extension) is reported on the +panel's status line. + +The destination, name, and Rename fields are full editing lines: the +caret moves with Left/Right/Home/End (by words with Ctrl held), +Backspace/Delete remove single characters (whole words with Ctrl held), +and Shift+Ins or Ctrl+V pastes the clipboard's text at the caret."; const MENUS: &str = "\ F9 opens the menu bar. Left/Right/Files/Commands/Options each drop down a pull-down; Left and Right act on their own panel regardless -of which one is focused. Esc closes a pull-down, then the bar."; +of which one is focused. Esc closes a pull-down, then the bar. + +Every listed item works. Left/Right offer display modes, sort +modes, Filter, Re-read, Drive select, and New/Close tab; Files +mirrors the F-key file operations plus clipboard export; Commands +holds Find file, History (the Alt+F8 command-history dialog), +Swap panels (Ctrl+U), Panels on/off, Fuzzy jump, and Menu file +edit, which opens usermenu.toml in the F4 editor and reloads the +F2 menu on return; Options opens the theme picker."; const VIEWER: &str = "\ F3 opens the read-only viewer. F4 toggles text/hex mode, F2 toggles @@ -480,9 +553,15 @@ if the buffer is modified. Undo is a single level."; const COMMAND_LINE: &str = "\ Typing over a panel with nothing else active enters text on the -command line. Enter runs it in the active panel's directory; `cd` +command line at the caret. Left/Right move the caret while the line +has text (on an empty line they do nothing); Ctrl+Backspace deletes +the word before it; Ctrl+V pastes the clipboard's text. Home/End, +PgUp/PgDn, Delete, and Shift+Ins keep their panel meanings. +Enter runs the line in the active panel's directory; `cd` navigates the panel instead of spawning a shell. Up/Down recall -history while something is typed; Esc clears the line."; +history while something is typed. Ctrl+Y clears the whole line, +ending any history recall (rebindable via key.clear_line); Esc asks +to quit."; const MODERN_EXTRAS: &str = "\ Ctrl+P narrows the active panel to substring matches as you type; @@ -500,11 +579,16 @@ also offers copying just their names. All three act on the same selection scope as F5 Copy."; const CONFIGURATION: &str = "\ -config.toml (next to the executable) sets the theme, shell, F4 +config.toml in the profile directory sets the theme, shell, F4 external-editor command, and the persisted panel_split percentage, -and can remap the quick-filter, fuzzy-jump, panel-split, and -clipboard (key.clipboard_files, key.clipboard_paths) keys. -usermenu.toml defines the F2 user menu's entries. --nosplash skips +and can remap the quick-filter, fuzzy-jump, panel-split, clipboard +(key.clipboard_files, key.clipboard_paths), and clear-line +(key.clear_line) keys. +usermenu.toml defines the F2 user menu's entries. Both live with +history.json in one profile directory: %APPDATA%\\FileCommand on +Windows, ~/.config/filecommand elsewhere, or beside the executable +when a 'portable' file sits next to it; --config-dir +overrides that for a session. --nosplash skips the startup splash. --theme (or --theme=) starts the session in a built-in theme instead of the configured one, for this launch only: it never writes config.toml, so applying a theme from @@ -526,6 +610,23 @@ mod tests { assert_eq!(m.cursor, 0, "an empty menu holds the cursor at zero"); } + #[test] + fn history_dialog_move_cursor_clamps_and_holds_on_an_empty_history() { + let mut d = HistoryDialogState::new(); + d.move_cursor(-3, 3); + assert_eq!(d.cursor, 0); + d.move_cursor(10, 3); + assert_eq!(d.cursor, 2, "clamped at the last row, not wrapped"); + // Home/End/PgUp/PgDn arrive as huge deltas (input maps them the same + // way drive-select does) and land exactly on the ends. + d.move_cursor(isize::MIN / 2, 3); + assert_eq!(d.cursor, 0); + d.move_cursor(isize::MAX / 2, 3); + assert_eq!(d.cursor, 2); + d.move_cursor(1, 0); + assert_eq!(d.cursor, 0, "an empty history holds the cursor at zero"); + } + #[test] fn theme_picker_opens_with_the_active_theme_highlighted() { let picker = ThemePickerState::open("terminal-green"); @@ -552,19 +653,34 @@ mod tests { } #[test] - fn file_action_menu_lists_run_first_only_when_executable() { + fn file_action_menu_lists_open_first_unless_the_target_is_executable() { + // open-with-associated-app design D2 / file-action-menu "Non- + // executable menu contents": Open first and highlighted. let m = FileActionMenuState::new(OsString::from("notes.txt"), false); - assert_eq!(m.entries[0], FileActionMenuEntry::View, "non-executable: View highlighted first"); + assert_eq!( + m.entries, + vec![ + FileActionMenuEntry::Open, + FileActionMenuEntry::View, + FileActionMenuEntry::Edit, + FileActionMenuEntry::SendToClipboard, + FileActionMenuEntry::Copy, + FileActionMenuEntry::Rename, + FileActionMenuEntry::Move, + FileActionMenuEntry::Delete, + ] + ); assert!(!m.entries.contains(&FileActionMenuEntry::Run)); - assert_eq!(m.selected(), FileActionMenuEntry::View); + assert_eq!(m.selected(), FileActionMenuEntry::Open, "non-executable: Open highlighted first"); + // "Executable gets Run first": Run stays first and highlighted, + // Open second. let m = FileActionMenuState::new(OsString::from("setup.exe"), true); - assert_eq!(m.entries[0], FileActionMenuEntry::Run, "executable: Run highlighted first"); - assert_eq!(m.selected(), FileActionMenuEntry::Run); assert_eq!( m.entries, vec![ FileActionMenuEntry::Run, + FileActionMenuEntry::Open, FileActionMenuEntry::View, FileActionMenuEntry::Edit, FileActionMenuEntry::SendToClipboard, @@ -574,14 +690,18 @@ mod tests { FileActionMenuEntry::Delete, ] ); + assert_eq!(m.selected(), FileActionMenuEntry::Run, "executable: Run highlighted first"); } #[test] - fn file_action_menu_directory_target_lists_send_to_clipboard_first() { + fn file_action_menu_directory_target_lists_open_first() { + // file-action-menu "Directory menu contents": Open, Send to + // clipboard, Copy, Rename, Move, Delete — Open highlighted. let m = FileActionMenuState::open(OsString::from("src"), true, false, false); assert_eq!( m.entries, vec![ + FileActionMenuEntry::Open, FileActionMenuEntry::SendToClipboard, FileActionMenuEntry::Copy, FileActionMenuEntry::Rename, @@ -589,6 +709,7 @@ mod tests { FileActionMenuEntry::Delete, ] ); + assert_eq!(m.selected(), FileActionMenuEntry::Open, "directory: Open highlighted first"); } #[test] @@ -617,6 +738,21 @@ mod tests { assert_eq!(m.hotkey_action('r'), Some(FileActionMenuEntry::Rename), "no Run entry: R falls through to Rename"); } + /// file-action-menu "O activates Open": `O` is unique to Open in every + /// menu layout (open-with-associated-app design D2). + #[test] + fn file_action_menu_hotkey_o_resolves_to_open_for_every_target_kind() { + let m = FileActionMenuState::new(OsString::from("report.pdf"), false); + assert_eq!(m.hotkey_action('o'), Some(FileActionMenuEntry::Open)); + assert_eq!(m.hotkey_action('O'), Some(FileActionMenuEntry::Open)); + + let m = FileActionMenuState::new(OsString::from("setup.exe"), true); + assert_eq!(m.hotkey_action('o'), Some(FileActionMenuEntry::Open)); + + let m = FileActionMenuState::open(OsString::from("src"), true, false, false); + assert_eq!(m.hotkey_action('o'), Some(FileActionMenuEntry::Open)); + } + #[test] fn help_state_opens_on_about_filecommand() { let h = HelpState::new(); diff --git a/crates/filecommand-core/src/drives.rs b/crates/filecommand-core/src/drives.rs index 18a841d..fa5fd88 100644 --- a/crates/filecommand-core/src/drives.rs +++ b/crates/filecommand-core/src/drives.rs @@ -46,9 +46,7 @@ impl DriveSelect { /// that drive is present. pub fn new(target: PanelSide, letters: Vec, current: Option) -> DriveSelect { let drives: Vec = letters.into_iter().map(|letter| DriveEntry { letter, label: None }).collect(); - let selected = current - .and_then(|c| drives.iter().position(|d| d.letter.eq_ignore_ascii_case(&c))) - .unwrap_or(0); + let selected = current.and_then(|c| drives.iter().position(|d| d.letter.eq_ignore_ascii_case(&c))).unwrap_or(0); DriveSelect { target, drives, selected, generation: 0 } } @@ -86,10 +84,7 @@ pub struct DiskSpace { /// Turn the `GetLogicalDrives` bitmask into drive letters. Bit 0 is `A:`. /// Pure, so the bit-twiddling is testable without a Windows host. pub fn drives_from_bitmask(mask: u32) -> Vec { - (0..26u32) - .filter(|i| mask & (1 << i) != 0) - .filter_map(|i| char::from_u32(u32::from(b'A') + i)) - .collect() + (0..26u32).filter(|i| mask & (1 << i) != 0).filter_map(|i| char::from_u32(u32::from(b'A') + i)).collect() } /// The root directory of a drive letter, e.g. `C:\`. @@ -120,62 +115,43 @@ pub fn drive_letter_of(path: &Path) -> Option { #[cfg(windows)] mod platform { + //! Win32 calls go through `windows-sys` (ci-and-build-tooling design + //! D6): prototypes, constants and struct layouts come from the binding + //! crate, never from hand-written copies. use super::DiskSpace; use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; - - /// Suppress the "There is no disk in the drive" system modal — without - /// this, probing an empty `A:` pops a dialog the user must dismiss. - const SEM_FAILCRITICALERRORS: u32 = 0x0001; - - #[repr(C)] - struct MemoryStatusEx { - length: u32, - memory_load: u32, - total_phys: u64, - avail_phys: u64, - total_page_file: u64, - avail_page_file: u64, - total_virtual: u64, - avail_virtual: u64, - avail_extended_virtual: u64, - } - - #[link(name = "kernel32")] - extern "system" { - fn GetLogicalDrives() -> u32; - fn SetThreadErrorMode(new_mode: u32, old_mode: *mut u32) -> i32; - fn GetVolumeInformationW( - root_path: *const u16, - volume_name: *mut u16, - volume_name_size: u32, - serial: *mut u32, - max_component_len: *mut u32, - fs_flags: *mut u32, - fs_name: *mut u16, - fs_name_size: u32, - ) -> i32; - fn GetDiskFreeSpaceExW(directory: *const u16, free_to_caller: *mut u64, total: *mut u64, total_free: *mut u64) -> i32; - fn GlobalMemoryStatusEx(buffer: *mut MemoryStatusEx) -> i32; - } + use windows_sys::Win32::Storage::FileSystem::{GetDiskFreeSpaceExW, GetLogicalDrives, GetVolumeInformationW}; + use windows_sys::Win32::System::Diagnostics::Debug::{SetThreadErrorMode, SEM_FAILCRITICALERRORS}; + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; fn wide(s: &str) -> Vec { OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() } /// Run `f` with the critical-error dialog suppressed for this thread. + /// `SEM_FAILCRITICALERRORS` suppresses the "There is no disk in the + /// drive" system modal — without it, probing an empty `A:` pops a + /// dialog the user must dismiss. fn quietly(f: impl FnOnce() -> T) -> T { let mut previous = 0u32; + // SAFETY: `previous` is a live local that receives the old mode; the + // call touches nothing else. let changed = unsafe { SetThreadErrorMode(SEM_FAILCRITICALERRORS, &mut previous) } != 0; let out = f(); if changed { + // SAFETY: `previous` was filled in by the successful call above, + // and a null old-mode pointer is permitted by the API. unsafe { SetThreadErrorMode(previous, std::ptr::null_mut()) }; } out } pub fn enumerate_drives() -> Vec { - super::drives_from_bitmask(unsafe { GetLogicalDrives() }) + // SAFETY: `GetLogicalDrives` takes no arguments and only returns a + // bitmask; it cannot violate any invariant. + let mask = unsafe { GetLogicalDrives() }; + super::drives_from_bitmask(mask) } /// `(label, serial)` for a drive, or `None` when the volume can't be @@ -184,17 +160,23 @@ mod platform { let root = wide(&format!("{}:\\", letter.to_ascii_uppercase())); let mut name = [0u16; 261]; let mut serial = 0u32; - let ok = quietly(|| unsafe { - GetVolumeInformationW( - root.as_ptr(), - name.as_mut_ptr(), - name.len() as u32, - &mut serial, - std::ptr::null_mut(), - std::ptr::null_mut(), - std::ptr::null_mut(), - 0, - ) + let ok = quietly(|| { + // SAFETY: `root` is NUL-terminated and outlives the call; `name` + // is a 261-element buffer whose length is passed alongside it; + // `serial` is a live local; the remaining out-pointers are null + // with a zero size, which the API permits. + unsafe { + GetVolumeInformationW( + root.as_ptr(), + name.as_mut_ptr(), + name.len() as u32, + &mut serial, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + ) + } }); if ok == 0 { return None; @@ -209,7 +191,11 @@ mod platform { let mut free_to_caller = 0u64; let mut total = 0u64; let mut total_free = 0u64; - let ok = quietly(|| unsafe { GetDiskFreeSpaceExW(root.as_ptr(), &mut free_to_caller, &mut total, &mut total_free) }); + let ok = quietly(|| { + // SAFETY: `root` is NUL-terminated and outlives the call; the + // three out-pointers are live locals. + unsafe { GetDiskFreeSpaceExW(root.as_ptr(), &mut free_to_caller, &mut total, &mut total_free) } + }); if ok == 0 { return None; } @@ -217,21 +203,23 @@ mod platform { } pub fn available_memory() -> Option { - let mut status = MemoryStatusEx { - length: std::mem::size_of::() as u32, - memory_load: 0, - total_phys: 0, - avail_phys: 0, - total_page_file: 0, - avail_page_file: 0, - total_virtual: 0, - avail_virtual: 0, - avail_extended_virtual: 0, + let mut status = MEMORYSTATUSEX { + dwLength: std::mem::size_of::() as u32, + dwMemoryLoad: 0, + ullTotalPhys: 0, + ullAvailPhys: 0, + ullTotalPageFile: 0, + ullAvailPageFile: 0, + ullTotalVirtual: 0, + ullAvailVirtual: 0, + ullAvailExtendedVirtual: 0, }; + // SAFETY: `status` is a live `MEMORYSTATUSEX` whose `dwLength` is + // preset to the struct size, as the API requires before the call. if unsafe { GlobalMemoryStatusEx(&mut status) } == 0 { return None; } - Some(status.avail_phys) + Some(status.ullAvailPhys) } /// NC renders volume serials as `XXXX-XXXX` hex. diff --git a/crates/filecommand-core/src/durable.rs b/crates/filecommand-core/src/durable.rs new file mode 100644 index 0000000..87114f8 --- /dev/null +++ b/crates/filecommand-core/src/durable.rs @@ -0,0 +1,439 @@ +//! Crash-safe whole-file replacement (editor-encoding-safety; design D4). +//! +//! [`replace_file`] writes new content to a uniquely named temporary file in +//! the target's directory, flushes it to stable storage, and only then swaps +//! it over the original in one rename-style step. At every moment the file +//! at the target path holds either its complete previous content or its +//! complete new content — never a truncated mix — and a failure at any step +//! leaves the original byte-for-byte intact with the temporary file removed. +//! +//! On Windows the swap is `ReplaceFileW`, which carries the original's +//! creation time, attributes, DACL and alternate data streams (for example +//! `Zone.Identifier`) over to the replacement; elsewhere it is +//! `std::fs::rename` followed by a best-effort directory `sync_all`. +//! +//! The helper is deliberately generic — a path and the bytes — so the F4 +//! editor's F2 save, `config.toml` rewrites and `history.json` persistence +//! can all share it instead of each growing its own temp-file-and-rename +//! code. Every path goes through [`to_extended_path`] (design D9). + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +use crate::fs_ops::path::to_extended_path; + +/// Suffix of the temporary file the new content is written to before it +/// replaces the original. Distinctive and dotfile-styled so a leftover from +/// a crash mid-save is recognisable and inert. +const TMP_SUFFIX: &str = "fc-tmp"; +/// Suffix of the backup name handed to `ReplaceFileW` on Windows; deleted on +/// success, and only ever left behind by a crash inside the replace itself. +#[cfg(windows)] +const BAK_SUFFIX: &str = "fc-bak"; +/// How many sibling names to try before giving up on a unique temporary +/// file (a collision means another save of the same file by the same +/// process is already in flight, which `create_new` makes visible). +const MAX_NAME_ATTEMPTS: u32 = 64; + +/// Atomically replace the file at `path` with `bytes`, keeping its metadata. +/// +/// Steps (design D4): resolve a symbolic link or other name-surrogate +/// reparse point to its final target (the link itself stays a link); +/// snapshot the original's metadata; create a `....fc-tmp` +/// sibling with `create_new`, carrying the original's hidden/system/ +/// archive/not-content-indexed attributes (Windows) or permission mode +/// (elsewhere); write everything and `sync_all`; then replace the original. +/// Any failure before the replace succeeds removes the temporary file and +/// returns the error unchanged, with the original untouched. A missing +/// original (deleted while it was being edited) is simply created. +pub fn replace_file(path: &Path, bytes: &[u8]) -> io::Result<()> { + let target = resolve_target(&to_extended_path(path))?; + let original = match std::fs::metadata(&target) { + Ok(meta) => Some(meta), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + let (temp, file) = create_temp(&target, original.as_ref())?; + let result = write_and_replace(&target, &temp, file, bytes, original.is_some()); + if result.is_err() { + // Cleanup on failure: the original is untouched and the partial or + // complete-but-unswapped temporary file must not be left behind. + // `ReplaceFileW` partial-failure recovery (Windows) has already put + // the original back under its own name by the time we get here. + let _ = std::fs::remove_file(&temp); + } + result +} + +/// Follow a symbolic link (or, on Windows, a junction or other name- +/// surrogate reparse point) to the file it names, so the *target* is +/// replaced and the link stays a link — mirroring what `std::fs::write` +/// through the link did. A plain file, or a path that does not exist yet, +/// is returned as-is. +fn resolve_target(path: &Path) -> io::Result { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => std::fs::canonicalize(path), + Ok(_) => Ok(path.to_path_buf()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path.to_path_buf()), + Err(e) => Err(e), + } +} + +/// A `....` sibling of `target`. +fn sibling_name(target: &Path, n: u32, suffix: &str) -> io::Result { + let parent = target + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target path has no parent directory"))?; + let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target path has no file name"))?; + let mut tmp = std::ffi::OsString::from("."); + tmp.push(name); + tmp.push(format!(".{}.{n}.{suffix}", std::process::id())); + Ok(parent.join(tmp)) +} + +/// Create the temporary file next to `target` with `create_new`, retrying +/// with the next `` on a name collision. The original's attribute bits +/// (Windows) or permission mode (elsewhere) are applied at creation so a +/// plain `rename` swap would already preserve them; on Windows +/// `ReplaceFileW` makes this redundant but harmless (design D4). +fn create_temp(target: &Path, original: Option<&std::fs::Metadata>) -> io::Result<(PathBuf, File)> { + for n in 0..MAX_NAME_ATTEMPTS { + let temp = sibling_name(target, n, TMP_SUFFIX)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + apply_original_metadata(&mut options, original); + match options.open(&temp) { + Ok(file) => return Ok((temp, file)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not find a free temporary file name next to the target")) +} + +#[cfg(windows)] +fn apply_original_metadata(options: &mut OpenOptions, original: Option<&std::fs::Metadata>) { + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_ARCHIVE, FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NOT_CONTENT_INDEXED, FILE_ATTRIBUTE_SYSTEM, + }; + const CARRIED: u32 = FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM | FILE_ATTRIBUTE_ARCHIVE | FILE_ATTRIBUTE_NOT_CONTENT_INDEXED; + if let Some(meta) = original { + let bits = meta.file_attributes() & CARRIED; + if bits != 0 { + options.attributes(bits); + } + } +} + +#[cfg(unix)] +fn apply_original_metadata(options: &mut OpenOptions, original: Option<&std::fs::Metadata>) { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + if let Some(meta) = original { + options.mode(meta.permissions().mode()); + } +} + +#[cfg(not(any(windows, unix)))] +fn apply_original_metadata(_options: &mut OpenOptions, _original: Option<&std::fs::Metadata>) {} + +/// Write, flush, close, then swap. Split out from [`replace_file`] so the +/// caller has one place to remove the temporary file on any error. +fn write_and_replace(target: &Path, temp: &Path, mut file: File, bytes: &[u8], original_exists: bool) -> io::Result<()> { + write_all(&mut file, bytes)?; + file.sync_all()?; + drop(file); + before_replace()?; + replace(target, temp, original_exists) +} + +/// `File::write_all`, with the test-only mid-write fault seam in front of +/// it (task 2.5) so "disk full after N bytes" is unit-testable. +fn write_all(file: &mut File, bytes: &[u8]) -> io::Result<()> { + #[cfg(test)] + if let Some(n) = fault::take_after_bytes() { + file.write_all(&bytes[..n.min(bytes.len())])?; + return Err(io::Error::new(io::ErrorKind::StorageFull, "injected write failure")); + } + file.write_all(bytes) +} + +/// The test-only "fail just before the replace" fault seam (task 2.5): the +/// temporary file is complete and flushed, the original still untouched. +fn before_replace() -> io::Result<()> { + #[cfg(test)] + if fault::take_before_replace() { + return Err(io::Error::other("injected pre-replace failure")); + } + Ok(()) +} + +/// Windows swap: `ReplaceFileW` with a sibling backup name and the two +/// "ignore" flags, so volumes without ACLs or named streams are non-fatal. +/// The documented partial-failure codes are recovered with both copies +/// intact, and a vanished original falls back to a plain rename. +#[cfg(windows)] +fn replace(target: &Path, temp: &Path, original_exists: bool) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{ + ERROR_FILE_NOT_FOUND, ERROR_UNABLE_TO_MOVE_REPLACEMENT, ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, ERROR_UNABLE_TO_REMOVE_REPLACED, + }; + use windows_sys::Win32::Storage::FileSystem::{ReplaceFileW, REPLACEFILE_IGNORE_ACL_ERRORS, REPLACEFILE_IGNORE_MERGE_ERRORS}; + + if !original_exists { + // Deleted while it was being edited: nothing to carry metadata + // over from, so a plain rename creates it. + return std::fs::rename(temp, target); + } + + let backup = sibling_name(target, 0, BAK_SUFFIX)?; + let wide = |p: &Path| -> Vec { p.as_os_str().encode_wide().chain(std::iter::once(0)).collect() }; + let target_w = wide(target); + let temp_w = wide(temp); + let backup_w = wide(&backup); + + // SAFETY: the three path arguments are NUL-terminated UTF-16 buffers + // that live for the duration of the call; the exclude and reserved + // pointers are documented as reserved and passed as null. The call + // reads the buffers only. + let ok = unsafe { + ReplaceFileW( + target_w.as_ptr(), + temp_w.as_ptr(), + backup_w.as_ptr(), + REPLACEFILE_IGNORE_MERGE_ERRORS | REPLACEFILE_IGNORE_ACL_ERRORS, + std::ptr::null(), + std::ptr::null(), + ) + }; + if ok != 0 { + // The backup is only a safety net for the swap itself; failing to + // delete it is not a save failure. + let _ = std::fs::remove_file(&backup); + return Ok(()); + } + + let err = io::Error::last_os_error(); + match err.raw_os_error().map(|c| c as u32) { + // The original vanished between the metadata snapshot and the + // swap: create it from the temporary file instead. + Some(ERROR_FILE_NOT_FOUND) if !target.exists() => std::fs::rename(temp, target), + // Both files keep their own names; the caller removes the temp. + Some(ERROR_UNABLE_TO_REMOVE_REPLACED) | Some(ERROR_UNABLE_TO_MOVE_REPLACEMENT) => Err(err), + // The original was moved to the backup name but the replacement + // could not take its place: put the original back first. + Some(ERROR_UNABLE_TO_MOVE_REPLACEMENT_2) => { + let _ = std::fs::rename(&backup, target); + Err(err) + } + _ => Err(err), + } +} + +/// Non-Windows swap: `rename` is atomic on POSIX filesystems; the parent +/// directory `sync_all` makes the new directory entry durable too, but is +/// best-effort because some filesystems refuse to fsync a directory. +#[cfg(not(windows))] +fn replace(target: &Path, temp: &Path, _original_exists: bool) -> io::Result<()> { + std::fs::rename(temp, target)?; + if let Some(parent) = target.parent().filter(|p| !p.as_os_str().is_empty()) { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } + Ok(()) +} + +/// Test-only fault injection (task 2.5). A fault is armed per thread and +/// consumed by the one `replace_file` call that reaches it, so parallel +/// tests never see each other's faults. +#[cfg(test)] +pub(crate) mod fault { + use std::cell::Cell; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum Fault { + /// The write stops after this many bytes with `StorageFull`. + AfterBytes(usize), + /// Everything is written and flushed, then the step just before + /// the replace fails. + BeforeReplace, + } + + thread_local! { + static ARMED: Cell> = const { Cell::new(None) }; + } + + pub fn arm(fault: Fault) { + ARMED.with(|c| c.set(Some(fault))); + } + + pub(super) fn take_after_bytes() -> Option { + ARMED.with(|c| match c.get() { + Some(Fault::AfterBytes(n)) => { + c.set(None); + Some(n) + } + _ => None, + }) + } + + pub(super) fn take_before_replace() -> bool { + ARMED.with(|c| match c.get() { + Some(Fault::BeforeReplace) => { + c.set(None); + true + } + _ => false, + }) + } +} + +#[cfg(test)] +mod tests { + use super::fault::{arm, Fault}; + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-durable-test-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// Names in `dir` other than the target itself — any `.fc-tmp` or + /// `.fc-bak` leftovers show up here. + fn leftovers(dir: &Path, target: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| Some(n.as_str()) != target.file_name().and_then(|f| f.to_str())) + .collect(); + names.sort(); + names + } + + #[test] + fn success_replaces_the_content_and_leaves_no_temp_or_backup_files() { + let dir = temp_dir("success"); + let target = dir.join("notes.txt"); + std::fs::write(&target, b"old content\n").unwrap(); + replace_file(&target, b"new content\n").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new content\n"); + assert_eq!(leftovers(&dir, &target), Vec::::new()); + } + + #[test] + fn failure_mid_write_leaves_the_original_intact_and_no_temp_sibling() { + let dir = temp_dir("mid-write"); + let target = dir.join("notes.txt"); + std::fs::write(&target, b"old content\n").unwrap(); + arm(Fault::AfterBytes(3)); + let err = replace_file(&target, b"new content that is longer\n").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::StorageFull); + assert_eq!(std::fs::read(&target).unwrap(), b"old content\n", "the original is byte-identical"); + assert_eq!(leftovers(&dir, &target), Vec::::new(), "no .fc-tmp sibling remains"); + } + + #[test] + fn failure_before_the_replace_leaves_the_original_intact() { + let dir = temp_dir("pre-replace"); + let target = dir.join("notes.txt"); + std::fs::write(&target, b"old content\n").unwrap(); + arm(Fault::BeforeReplace); + replace_file(&target, b"new content\n").unwrap_err(); + assert_eq!(std::fs::read(&target).unwrap(), b"old content\n"); + assert_eq!(leftovers(&dir, &target), Vec::::new()); + } + + #[test] + fn a_missing_original_is_created() { + let dir = temp_dir("missing"); + let target = dir.join("fresh.txt"); + assert!(!target.exists()); + replace_file(&target, b"brand new\n").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"brand new\n"); + assert_eq!(leftovers(&dir, &target), Vec::::new()); + } + + #[test] + fn a_target_without_a_parent_directory_is_an_error_not_a_panic() { + let dir = temp_dir("no-parent"); + let target = dir.join("nope").join("deeper.txt"); + let err = replace_file(&target, b"x").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn the_temporary_file_takes_a_fresh_name_when_a_sibling_already_exists() { + let dir = temp_dir("collision"); + let target = dir.join("notes.txt"); + std::fs::write(&target, b"old\n").unwrap(); + let taken = sibling_name(&to_extended_path(&target), 0, TMP_SUFFIX).unwrap(); + std::fs::write(&taken, b"someone else's save in flight").unwrap(); + replace_file(&target, b"new\n").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new\n"); + assert_eq!(std::fs::read(&taken).unwrap(), b"someone else's save in flight", "the pre-existing sibling is never touched"); + let _ = std::fs::remove_file(&taken); + } + + #[cfg(windows)] + #[test] + fn hidden_and_system_attributes_and_creation_time_survive_on_windows() { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::{FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_SYSTEM}; + let dir = temp_dir("attrs"); + let target = dir.join("hidden.txt"); + std::fs::write(&target, b"old\n").unwrap(); + let status = std::process::Command::new("attrib").arg("+H").arg("+S").arg(&target).status().unwrap(); + assert!(status.success(), "attrib +H +S"); + let before = std::fs::metadata(&target).unwrap(); + assert_ne!(before.file_attributes() & FILE_ATTRIBUTE_HIDDEN, 0); + let created = before.created().unwrap(); + + replace_file(&target, b"new\n").unwrap(); + + let after = std::fs::metadata(&target).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new\n"); + assert_ne!(after.file_attributes() & FILE_ATTRIBUTE_HIDDEN, 0, "hidden survives"); + assert_ne!(after.file_attributes() & FILE_ATTRIBUTE_SYSTEM, 0, "system survives"); + assert_eq!(after.created().unwrap(), created, "creation time is unchanged"); + assert_eq!(leftovers(&dir, &target), Vec::::new()); + let _ = std::process::Command::new("attrib").arg("-H").arg("-S").arg(&target).status(); + } + + #[cfg(windows)] + #[test] + fn an_alternate_data_stream_survives_on_windows() { + let dir = temp_dir("ads"); + let target = dir.join("download.ps1"); + std::fs::write(&target, b"Write-Host hi\n").unwrap(); + let stream = format!("{}:Zone.Identifier", target.display()); + std::fs::write(&stream, b"[ZoneTransfer]\r\nZoneId=3\r\n").unwrap(); + + replace_file(&target, b"Write-Host bye\n").unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"Write-Host bye\n"); + assert_eq!(std::fs::read(&stream).unwrap(), b"[ZoneTransfer]\r\nZoneId=3\r\n", "Zone.Identifier survives with the same content"); + } + + #[cfg(unix)] + #[test] + fn saving_through_a_symlink_replaces_the_target_and_keeps_the_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = temp_dir("symlink"); + let real = dir.join("real.txt"); + std::fs::write(&real, b"old\n").unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap(); + let link = dir.join("link.txt"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + replace_file(&link, b"new\n").unwrap(); + + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link stays a link"); + assert_eq!(std::fs::read(&real).unwrap(), b"new\n", "the link target holds the new content"); + assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o640, "the permission mode is preserved"); + } +} diff --git a/crates/filecommand-core/src/editor.rs b/crates/filecommand-core/src/editor.rs index fe0fcc3..22a39ea 100644 --- a/crates/filecommand-core/src/editor.rs +++ b/crates/filecommand-core/src/editor.rs @@ -13,15 +13,27 @@ //! long-path abstraction (design D9) — mirroring how [`crate::config`] and //! the viewer's `byte_source` already do direct-but-extended-path-aware //! whole-file I/O, while [`crate::fs_ops::fs::FileSystem`] remains reserved -//! for the copy/move/delete file-management engine it already models. Every -//! other method here is a pure buffer transformation, unit-testable without -//! touching a filesystem or a terminal (§8). +//! for the copy/move/delete file-management engine it already models. The +//! save itself goes through [`crate::durable::replace_file`] so it is +//! atomic and keeps the file's metadata (editor-encoding-safety; design +//! D4). Every other method here is a pure buffer transformation, +//! unit-testable without touching a filesystem or a terminal (§8). +//! +//! The editor edits UTF-8 text only. [`classify_editor_bytes`] refuses +//! UTF-16, NUL-containing and otherwise non-UTF-8 content at open so the +//! buffer never holds anything F2 could not write back byte-exactly (apart +//! from edits and line-ending normalization); such files are redirected to +//! the F3 viewer (design D1). A UTF-8 byte-order mark is stripped into +//! [`EditorState::has_bom`] and written back on save (design D3). use std::io; use std::path::{Path, PathBuf}; use crate::fs_ops::path::to_extended_path; -use crate::viewer::decode::{decode_lossy, logical_lines}; +use crate::viewer::decode::logical_lines; + +/// The UTF-8 byte-order mark, stripped at load and written back on save. +const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF]; /// Files at or above this size are redirected to the F3 viewer instead of /// being loaded into the editor (builtin-editor "Editor invocation and size @@ -115,14 +127,65 @@ pub struct Caret { pub col: usize, } -/// The result of a size-gated open attempt (builtin-editor "Editor -/// invocation and size cap"). +/// Why a file under the size cap still cannot be loaded into the built-in +/// editor (builtin-editor "Editor invocation and size cap"; design D1). The +/// variants are ordered by the check that produces them; the first match +/// wins, chosen so the notice tells the user the most it can. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotEditableReason { + /// Starts with a UTF-16 byte-order mark (`FF FE` or `FE FF`; this also + /// catches UTF-32LE's `FF FE 00 00`). + Utf16, + /// Contains a NUL (`0x00`) byte anywhere — a real binary, or BOM-less + /// UTF-16 whose ASCII range is full of NULs. + Binary, + /// Not valid UTF-8 (after an optional UTF-8 byte-order mark). + NotUtf8, +} + +/// Validated editor input: the UTF-8 text after any byte-order mark, plus +/// whether that mark was present. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EditorText<'a> { + pub text: &'a str, + pub has_bom: bool, +} + +/// Decide whether `bytes` are editable UTF-8 text (design D1). Checks run in +/// this order, the first match wins: a UTF-16 BOM → [`NotEditableReason::Utf16`]; +/// any NUL byte → [`NotEditableReason::Binary`]; then an optional UTF-8 BOM +/// is noted and the remainder must pass strict `from_utf8`, else +/// [`NotEditableReason::NotUtf8`]. Pure, so every boundary is unit-testable +/// without a file. +pub fn classify_editor_bytes(bytes: &[u8]) -> Result, NotEditableReason> { + if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { + return Err(NotEditableReason::Utf16); + } + if bytes.contains(&0) { + return Err(NotEditableReason::Binary); + } + let (has_bom, rest) = match bytes.strip_prefix(&UTF8_BOM) { + Some(rest) => (true, rest), + None => (false, bytes), + }; + let text = std::str::from_utf8(rest).map_err(|_| NotEditableReason::NotUtf8)?; + Ok(EditorText { text, has_bom }) +} + +/// The result of a size- and encoding-gated open attempt (builtin-editor +/// "Editor invocation and size cap"). #[derive(Debug)] pub enum LoadResult { - Loaded(EditorState), + /// Boxed so the result is not sized by the whole editor buffer + /// (clippy `large_enum_variant`). + Loaded(Box), /// The file is `size` bytes, at or above [`EDITOR_MAX_BYTES`]; the /// caller should redirect to the F3 viewer instead. TooLarge { size: u64 }, + /// The file is under the cap but is not UTF-8 text the editor can write + /// back byte-exactly; the caller should redirect to the F3 viewer with a + /// reason-specific notice (design D1/D2). + NotEditable { reason: NotEditableReason }, } /// The F4-in-editor search-and-replace prompt's two stages: the literal @@ -181,20 +244,35 @@ pub struct EditorState { pub quit_confirm: bool, /// The message from the most recent failed F2 save (including a failed /// save attempted as part of the save-on-exit confirm), or `None`. - /// Cleared by the next edit or successful save. + /// Cleared by the next edit or successful save (design D6). pub save_error: Option, + /// `true` from the moment F2 (or the save-on-exit confirm's "Y") + /// dispatches `Effect::SaveEditor` until `Command::EditorSaved`/ + /// `EditorSaveFailed` replies. The save runs on a worker thread; while + /// it is outstanding the editor accepts no edits, caret moves, or + /// further save/quit commands and shows a `Saving…` notice on its last + /// body row (application-shell "Slow storage never blocks the event + /// loop"; offload-blocking-io design D6). + pub save_pending: bool, + /// Whether the file began with a UTF-8 byte-order mark on load. The mark + /// is kept out of the buffer — it is zero-width, so as a buffer + /// character the caret could silently type in front of it or delete it + /// — and written back by `Self::to_bytes` (builtin-editor "UTF-8 + /// byte-order mark is written back"; design D3). + pub has_bom: bool, } impl EditorState { - /// Build a fresh editor session from already-read file bytes: UTF-8 - /// lossy decode, split into logical lines, dominant line ending - /// detected from the raw bytes, caret at the start of the first line. - /// Pure — no I/O — so load behavior is unit-testable without a temp - /// file (builtin-editor "Small file opens in the editor"). - pub fn from_bytes(path: PathBuf, bytes: &[u8]) -> EditorState { - let text = decode_lossy(bytes); - let lines = logical_lines(&text); - EditorState { + /// Build a fresh editor session from already-read file bytes: strict + /// UTF-8 validation via [`classify_editor_bytes`] (the BOM stripped + /// into [`Self::has_bom`]), split into logical lines, dominant line + /// ending detected from the raw bytes, caret at the start of the first + /// line. Pure — no I/O — so load behavior is unit-testable without a + /// temp file (builtin-editor "Small file opens in the editor"). + pub fn from_bytes(path: PathBuf, bytes: &[u8]) -> Result { + let EditorText { text, has_bom } = classify_editor_bytes(bytes)?; + let lines = logical_lines(text); + Ok(EditorState { path, lines: lines.clone(), caret: Caret::default(), @@ -211,15 +289,19 @@ impl EditorState { replace_prompt: None, quit_confirm: false, save_error: None, - } + save_pending: false, + has_bom, + }) } /// Open `path` for editing: stat it first and redirect to /// [`LoadResult::TooLarge`] without reading its content when it is at /// or above [`EDITOR_MAX_BYTES`] (builtin-editor "Large file redirects - /// to the viewer"), otherwise read and decode it in full. Routed - /// through [`to_extended_path`] for the `\\?\` long-path abstraction - /// (design D9). + /// to the viewer"), otherwise read it in full and classify the bytes — + /// content the editor cannot write back byte-exactly is redirected as + /// [`LoadResult::NotEditable`] instead of being loaded (design D1). + /// Routed through [`to_extended_path`] for the `\\?\` long-path + /// abstraction (design D9). pub fn open(path: &Path) -> io::Result { let extended = to_extended_path(path); let size = std::fs::metadata(&extended)?.len(); @@ -227,7 +309,10 @@ impl EditorState { return Ok(LoadResult::TooLarge { size }); } let bytes = std::fs::read(&extended)?; - Ok(LoadResult::Loaded(EditorState::from_bytes(path.to_path_buf(), &bytes))) + Ok(match EditorState::from_bytes(path.to_path_buf(), &bytes) { + Ok(editor) => LoadResult::Loaded(Box::new(editor)), + Err(reason) => LoadResult::NotEditable { reason }, + }) } /// Whether the buffer differs from the last-saved (or loaded) state @@ -236,13 +321,17 @@ impl EditorState { self.lines != self.saved_snapshot } - /// The buffer encoded exactly as it would be written to disk: UTF-8, - /// joined with the recorded line ending (so a file that ended without a - /// trailing newline still doesn't gain one, and vice versa). Shared by - /// [`Self::save`] and [`Self::byte_len`] so the header's byte-size - /// figure always matches what F2 would actually write. + /// The buffer encoded exactly as it would be written to disk: the UTF-8 + /// byte-order mark when the file had one, then UTF-8 joined with the + /// recorded line ending (so a file that ended without a trailing newline + /// still doesn't gain one, and vice versa). Shared by [`Self::save`] and + /// [`Self::byte_len`] so the header's byte-size figure always matches + /// what F2 would actually write, BOM included (design D3). fn to_bytes(&self) -> Vec { let mut bytes = Vec::new(); + if self.has_bom { + bytes.extend_from_slice(&UTF8_BOM); + } for (i, line) in self.lines.iter().enumerate() { if i > 0 { bytes.extend_from_slice(self.line_ending.as_str().as_bytes()); @@ -258,13 +347,17 @@ impl EditorState { self.to_bytes().len() as u64 } - /// F2: write the buffer back to `self.path` in place, UTF-8 encoded, - /// using the recorded line ending between lines (so a file that ended - /// without a trailing newline still doesn't gain one, and vice versa), - /// then clear the modified state (builtin-editor "Save in place …"). + /// F2: save the buffer to `self.path` — UTF-8 (BOM restored if the file + /// had one), the recorded line ending between lines — atomically + /// through [`crate::durable::replace_file`], so a failure at any step + /// leaves the original byte-for-byte intact and the buffer modified + /// (builtin-editor "Save in place with line-ending and encoding + /// preservation"; design D4). A read-only file is refused up front and + /// never made writable (design D5). On success the modified state and + /// any earlier save failure are cleared. pub fn save(&mut self) -> io::Result<()> { - let bytes = self.to_bytes(); - std::fs::write(to_extended_path(&self.path), &bytes)?; + check_writable(&self.path)?; + crate::durable::replace_file(&self.path, &self.to_bytes())?; self.saved_snapshot = self.lines.clone(); self.save_error = None; Ok(()) @@ -281,6 +374,7 @@ impl EditorState { /// text entry"). pub fn type_char(&mut self, c: char) { self.snapshot_for_undo(); + self.save_error = None; let mut chars: Vec = self.lines[self.caret.line].chars().collect(); match self.mode { EntryMode::Insert => { @@ -303,6 +397,7 @@ impl EditorState { /// to the start of the new (second) line. pub fn insert_newline(&mut self) { self.snapshot_for_undo(); + self.save_error = None; let chars: Vec = self.lines[self.caret.line].chars().collect(); let at = self.caret.col.min(chars.len()); let tail: String = chars[at..].iter().collect(); @@ -321,6 +416,7 @@ impl EditorState { return; } self.snapshot_for_undo(); + self.save_error = None; if self.caret.col == 0 { let current = self.lines.remove(self.caret.line); let prev_len = self.lines[self.caret.line - 1].chars().count(); @@ -361,6 +457,7 @@ impl EditorState { pub fn cut_selection(&mut self) { let Some((start, end)) = self.selection_range() else { return }; self.snapshot_for_undo(); + self.save_error = None; let removed: Vec = self.lines.drain(start..=end).collect(); self.clipboard = removed; if self.lines.is_empty() { @@ -387,6 +484,7 @@ impl EditorState { return; } self.snapshot_for_undo(); + self.save_error = None; let at = self.caret.line.min(self.lines.len()); for (i, line) in self.clipboard.iter().enumerate() { self.lines.insert(at + i, line.clone()); @@ -424,6 +522,7 @@ impl EditorState { let found = self.search_from(self.caret.line, self.caret.col, pattern).or_else(|| self.search_from(0, 0, pattern)); let Some((line, col)) = found else { return false }; self.snapshot_for_undo(); + self.save_error = None; let chars: Vec = self.lines[line].chars().collect(); let pat_len = pattern.chars().count(); let mut new_chars = chars[..col].to_vec(); @@ -473,6 +572,7 @@ impl EditorState { /// (builtin-editor "Undo does not go back more than one level"). pub fn undo(&mut self) { if let Some(prev) = self.undo_snapshot.take() { + self.save_error = None; self.lines = prev; if self.lines.is_empty() { self.lines.push(String::new()); @@ -563,6 +663,24 @@ impl EditorState { } } +/// Refuse to save over a read-only file (builtin-editor "Read-only file is +/// not saved"; design D5): an existing target whose read-only bit is set +/// yields `PermissionDenied` with ` is read-only — not saved`, before +/// any temporary file is created. The attribute is never cleared to force +/// the save through. A target that no longer exists is fine — the save will +/// recreate it. +fn check_writable(path: &Path) -> io::Result<()> { + match std::fs::metadata(to_extended_path(path)) { + Ok(meta) if meta.permissions().readonly() => { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("{name} is read-only — not saved"))) + } + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + #[cfg(test)] mod tests { use super::*; @@ -585,14 +703,23 @@ mod tests { replace_prompt: None, quit_confirm: false, save_error: None, + save_pending: false, + has_bom: false, } } fn temp_file(name: &str, contents: &[u8]) -> PathBuf { let dir = std::env::temp_dir().join(format!("filecommand-editor-test-{}-{}", std::process::id(), name)); + let path = dir.join("file.txt"); + // A previous failed run may have left the fixture read-only, which + // would block the cleanup below. + if let Ok(meta) = std::fs::metadata(&path) { + if meta.permissions().readonly() { + set_readonly(&path, false); + } + } let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("file.txt"); std::fs::write(&path, contents).unwrap(); path } @@ -615,7 +742,19 @@ mod tests { let path = temp_file("too-large", &big); match EditorState::open(&path).unwrap() { LoadResult::TooLarge { size } => assert_eq!(size, EDITOR_MAX_BYTES), - LoadResult::Loaded(_) => panic!("expected TooLarge"), + other => panic!("expected TooLarge, got {other:?}"), + } + } + + #[test] + fn open_redirects_a_too_large_file_before_classifying_its_bytes() { + // A NUL-filled file at the cap is reported as TooLarge, not Binary: + // the size gate runs first and never reads the content (design D1). + let big = vec![0u8; EDITOR_MAX_BYTES as usize]; + let path = temp_file("too-large-binary", &big); + match EditorState::open(&path).unwrap() { + LoadResult::TooLarge { size } => assert_eq!(size, EDITOR_MAX_BYTES), + other => panic!("expected TooLarge, got {other:?}"), } } @@ -627,11 +766,256 @@ mod tests { assert_eq!(editor.lines, vec!["line one".to_string(), "line two".to_string(), "".to_string()]); assert_eq!(editor.caret, Caret { line: 0, col: 0 }); assert!(!editor.is_modified()); + assert!(!editor.has_bom); } - LoadResult::TooLarge { .. } => panic!("expected Loaded"), + other => panic!("expected Loaded, got {other:?}"), } } + // -- byte classification (design D1) --------------------------------- + + #[test] + fn classify_utf16_boms_as_utf16_before_anything_else() { + assert_eq!(classify_editor_bytes(&[0xFF, 0xFE, b'h', 0, b'i', 0]), Err(NotEditableReason::Utf16)); + assert_eq!(classify_editor_bytes(&[0xFE, 0xFF, 0, b'h', 0, b'i']), Err(NotEditableReason::Utf16)); + // UTF-32LE starts with the UTF-16LE BOM and is reported the same way. + assert_eq!(classify_editor_bytes(&[0xFF, 0xFE, 0x00, 0x00, b'h', 0, 0, 0]), Err(NotEditableReason::Utf16)); + } + + #[test] + fn classify_an_embedded_nul_as_binary() { + assert_eq!(classify_editor_bytes(b"plain ascii\x00with a nul"), Err(NotEditableReason::Binary)); + // BOM-less UTF-16 ASCII is NULs interleaved with letters: Binary is + // still an accurate description. + assert_eq!(classify_editor_bytes(&[b'h', 0, b'i', 0]), Err(NotEditableReason::Binary)); + } + + #[test] + fn classify_invalid_utf8_as_not_utf8() { + // A lone Windows-1252 `é`. + assert_eq!(classify_editor_bytes(b"caf\xE9"), Err(NotEditableReason::NotUtf8)); + // A multi-byte sequence truncated at end of file. + assert_eq!(classify_editor_bytes(b"caf\xC3"), Err(NotEditableReason::NotUtf8)); + // A UTF-8 BOM does not excuse an invalid remainder. + assert_eq!(classify_editor_bytes(b"\xEF\xBB\xBFcaf\xE9"), Err(NotEditableReason::NotUtf8)); + } + + #[test] + fn classify_accepts_empty_plain_and_bom_prefixed_utf8() { + assert_eq!(classify_editor_bytes(b""), Ok(EditorText { text: "", has_bom: false })); + assert_eq!(classify_editor_bytes("héllo\r\n".as_bytes()), Ok(EditorText { text: "héllo\r\n", has_bom: false })); + assert_eq!(classify_editor_bytes(b"\xEF\xBB\xBFhello"), Ok(EditorText { text: "hello", has_bom: true })); + // Control characters other than NUL are valid UTF-8 and round-trip. + assert_eq!(classify_editor_bytes(b"\x1B[0m\ttab\x07"), Ok(EditorText { text: "\x1B[0m\ttab\x07", has_bom: false })); + } + + #[test] + fn open_redirects_a_windows_1252_file_and_leaves_it_unchanged() { + let original: &[u8] = b"@echo off\r\necho caf\xE9\r\n"; + let path = temp_file("cp1252", original); + match EditorState::open(&path).unwrap() { + LoadResult::NotEditable { reason } => assert_eq!(reason, NotEditableReason::NotUtf8), + other => panic!("expected NotEditable, got {other:?}"), + } + assert_eq!(std::fs::read(&path).unwrap(), original, "the file on disk is byte-identical"); + } + + #[test] + fn open_redirects_utf16_and_nul_files() { + let utf16 = temp_file("utf16", &[0xFF, 0xFE, b'h', 0, b'i', 0]); + assert!(matches!(EditorState::open(&utf16).unwrap(), LoadResult::NotEditable { reason: NotEditableReason::Utf16 })); + let nul = temp_file("nul", b"abc\x00def"); + assert!(matches!(EditorState::open(&nul).unwrap(), LoadResult::NotEditable { reason: NotEditableReason::Binary })); + } + + // -- UTF-8 byte-order mark (design D3) -------------------------------- + + #[test] + fn a_bom_file_opens_without_the_bom_in_the_buffer_and_writes_it_back() { + let path = temp_file("bom", b"\xEF\xBB\xBFhello\nworld\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + assert!(editor.has_bom); + assert_eq!(editor.lines[0], "hello", "no U+FEFF in the first line"); + assert!(!editor.lines[0].starts_with('\u{FEFF}')); + assert_eq!(editor.caret, Caret { line: 0, col: 0 }); + assert_eq!(editor.byte_len(), 3 + "hello\nworld\n".len() as u64, "byte_len includes the 3 BOM bytes"); + assert!(!editor.is_modified()); + + editor.move_home(); + editor.type_char('X'); + editor.save().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"\xEF\xBB\xBFXhello\nworld\n", "exactly one BOM, then the edited text"); + assert_eq!(std::fs::read(&path).unwrap().len() as u64, editor.byte_len()); + } + + #[test] + fn a_file_without_a_bom_does_not_gain_one() { + let path = temp_file("no-bom", b"hello\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + assert!(!editor.has_bom); + editor.type_char('X'); + editor.save().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"Xhello\n"); + } + + // -- save safety (design D4/D5/D6) ------------------------------------ + + fn set_readonly(path: &Path, readonly: bool) { + let mut perms = std::fs::metadata(path).unwrap().permissions(); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(readonly); + std::fs::set_permissions(path, perms).unwrap(); + } + + #[test] + fn saving_a_read_only_file_is_refused_and_leaves_it_untouched() { + let path = temp_file("readonly", b"locked\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + set_readonly(&path, true); + editor.type_char('X'); + + let err = editor.save().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(err.to_string(), "file.txt is read-only — not saved"); + assert!(editor.is_modified(), "the buffer stays modified"); + assert_eq!(std::fs::read(&path).unwrap(), b"locked\n", "the file is unchanged"); + assert!(std::fs::metadata(&path).unwrap().permissions().readonly(), "still read-only"); + let siblings: Vec<_> = std::fs::read_dir(path.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(siblings, vec![std::ffi::OsString::from("file.txt")], "no temporary file was created"); + set_readonly(&path, false); + } + + #[test] + fn a_failed_save_leaves_the_original_intact_and_the_buffer_modified() { + let path = temp_file("failed-save", b"original\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + editor.type_char('X'); + crate::durable::fault::arm(crate::durable::fault::Fault::AfterBytes(2)); + let err = editor.save().unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::StorageFull); + assert!(editor.is_modified()); + assert_eq!(std::fs::read(&path).unwrap(), b"original\n"); + let siblings: Vec<_> = std::fs::read_dir(path.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(siblings, vec![std::ffi::OsString::from("file.txt")], "no temporary file remains"); + } + + #[test] + fn the_next_edit_clears_a_save_failure_message() { + let path = temp_file("save-error-clears", b"locked\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + set_readonly(&path, true); + editor.type_char('X'); + let err = editor.save().unwrap_err(); + // The reducer stores the failure on `save_error` (`Command::EditorSaveFailed`). + editor.save_error = Some(err.to_string()); + editor.type_char('Y'); + assert_eq!(editor.save_error, None, "typing clears the message"); + set_readonly(&path, false); + } + + #[test] + fn every_buffer_mutation_clears_a_save_failure_message() { + type Mutation = fn(&mut EditorState); + let mutations: Vec<(&str, Mutation)> = vec![ + ("type_char", |e| e.type_char('x')), + ("insert_newline", |e| e.insert_newline()), + ("backspace", |e| { + e.caret.col = 1; + e.backspace() + }), + ("cut_selection", |e| { + e.start_mark(); + e.cut_selection() + }), + ("paste", |e| { + e.clipboard = vec!["p".to_string()]; + e.paste() + }), + ("replace_first", |e| { + assert!(e.replace_first("a", "b")); + }), + ("undo", |e| { + e.type_char('x'); + e.save_error = Some("boom".to_string()); + e.undo() + }), + ]; + for (name, mutate) in mutations { + let mut e = state(&["abc"]); + e.save_error = Some("boom".to_string()); + mutate(&mut e); + assert_eq!(e.save_error, None, "{name} clears save_error"); + } + } + + #[test] + fn a_successful_save_clears_a_save_failure_message() { + let path = temp_file("save-ok-clears", b"hello\n"); + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + editor.type_char('X'); + editor.save_error = Some("earlier failure".to_string()); + editor.save().unwrap(); + assert_eq!(editor.save_error, None); + } + + #[cfg(windows)] + #[test] + fn hidden_system_attributes_and_creation_time_survive_a_save_on_windows() { + use std::os::windows::fs::MetadataExt; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let path = temp_file("win-attrs", b"hidden text\r\n"); + let status = std::process::Command::new("attrib").arg("+H").arg("+S").arg(&path).status().unwrap(); + assert!(status.success(), "attrib +H +S"); + let before = std::fs::metadata(&path).unwrap(); + let created = before.created().unwrap(); + + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + editor.type_char('X'); + editor.save().unwrap(); + + let after = std::fs::metadata(&path).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"Xhidden text\r\n"); + assert_ne!(after.file_attributes() & HIDDEN, 0, "still hidden"); + assert_ne!(after.file_attributes() & SYSTEM, 0, "still system"); + assert_eq!(after.created().unwrap(), created, "creation time unchanged"); + let _ = std::process::Command::new("attrib").arg("-H").arg("-S").arg(&path).status(); + } + + #[cfg(windows)] + #[test] + fn a_zone_identifier_stream_survives_a_save_on_windows() { + let path = temp_file("win-ads", b"Write-Host hi\r\n"); + let stream = format!("{}:Zone.Identifier", path.display()); + let zone: &[u8] = b"[ZoneTransfer]\r\nZoneId=3\r\n"; + std::fs::write(&stream, zone).unwrap(); + + let LoadResult::Loaded(mut editor) = EditorState::open(&path).unwrap() else { panic!("expected Loaded") }; + editor.type_char('X'); + editor.save().unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"XWrite-Host hi\r\n"); + assert_eq!(std::fs::read(&stream).unwrap(), zone, "the stream survives with its previous content"); + } + + #[cfg(unix)] + #[test] + fn saving_through_a_symlink_replaces_the_target_and_keeps_the_mode() { + use std::os::unix::fs::PermissionsExt; + let real = temp_file("symlink-target", b"old\n"); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap(); + let link = real.parent().unwrap().join("link.txt"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let LoadResult::Loaded(mut editor) = EditorState::open(&link).unwrap() else { panic!("expected Loaded") }; + editor.type_char('X'); + editor.save().unwrap(); + + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a symlink"); + assert_eq!(std::fs::read(&real).unwrap(), b"Xold\n", "the link target was replaced"); + assert_eq!(std::fs::metadata(&real).unwrap().permissions().mode() & 0o777, 0o640, "mode preserved"); + } + // -- line-ending detection & CRLF/LF round trip --------------------- #[test] diff --git a/crates/filecommand-core/src/find_file.rs b/crates/filecommand-core/src/find_file.rs index 2f30f53..f65cb7a 100644 --- a/crates/filecommand-core/src/find_file.rs +++ b/crates/filecommand-core/src/find_file.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use crate::line_edit::{LineEdit, TextEditOp}; use crate::listing::FindMatch; /// The open find-file dialog. @@ -18,7 +19,9 @@ pub struct FindFileState { /// The subtree root the walk searches — the active panel's directory at /// the moment the dialog opened. pub root: PathBuf, - pub pattern: String, + /// The name pattern — a shared text field per text-field-editing ("the + /// pattern field is a shared text field"). + pub pattern: LineEdit, /// Results streamed in so far, in arrival order. pub results: Vec, pub cursor: usize, @@ -39,21 +42,16 @@ impl FindFileState { /// A freshly opened dialog rooted at `root`, with an empty pattern and /// no search yet submitted (find-file "Find-file invocation"). pub fn new(root: PathBuf) -> FindFileState { - FindFileState { root, pattern: String::new(), results: Vec::new(), cursor: 0, request: None, done: false } + FindFileState { root, pattern: LineEdit::default(), results: Vec::new(), cursor: 0, request: None, done: false } } - /// Append `c` to the pattern while still in the input stage. A no-op - /// once a search is in flight — the pattern is fixed for that search; - /// Esc-then-reopen is how the user starts over. - pub fn push(&mut self, c: char) { + /// Apply one editing op to the pattern while still in the input stage + /// (find-file "Pattern is editable mid-string"). A no-op once a search + /// is in flight — the pattern is fixed for that search; Esc-then-reopen + /// is how the user starts over. + pub fn apply(&mut self, op: &TextEditOp) { if self.request.is_none() { - self.pattern.push(c); - } - } - - pub fn backspace(&mut self) { - if self.request.is_none() { - self.pattern.pop(); + self.pattern.apply(op); } } @@ -106,27 +104,38 @@ mod tests { use std::ffi::OsString; fn a_match(name: &str) -> FindMatch { - FindMatch { - relative_path: PathBuf::from(name), - entry: Entry { name: OsString::from(name), kind: EntryKind::File, size: 0, modified: None }, - } + FindMatch { relative_path: PathBuf::from(name), entry: Entry::new(OsString::from(name), EntryKind::File, 0, None, false, false) } } #[test] fn typing_is_ignored_once_a_search_is_submitted() { let mut f = FindFileState::new(PathBuf::from("/root")); - f.push('a'); - f.push('b'); - assert_eq!(f.pattern, "ab"); + f.apply(&TextEditOp::Insert('a')); + f.apply(&TextEditOp::Insert('b')); + assert_eq!(f.pattern.text(), "ab"); f.submit(1); - f.push('c'); - assert_eq!(f.pattern, "ab", "the pattern is fixed once the walk is submitted"); + f.apply(&TextEditOp::Insert('c')); + assert_eq!(f.pattern.text(), "ab", "the pattern is fixed once the walk is submitted"); + } + + #[test] + fn the_pattern_is_editable_mid_string_until_submitted() { + // find-file "Pattern is editable mid-string": `reprt`, Left x2, `o` + // -> `report`. + let mut f = FindFileState::new(PathBuf::from("/root")); + for c in "reprt".chars() { + f.apply(&TextEditOp::Insert(c)); + } + f.apply(&TextEditOp::Left); + f.apply(&TextEditOp::Left); + f.apply(&TextEditOp::Insert('o')); + assert_eq!(f.pattern.text(), "report"); } #[test] fn submit_clears_prior_results_and_marks_in_flight() { let mut f = FindFileState::new(PathBuf::from("/root")); - f.push('x'); + f.apply(&TextEditOp::Insert('x')); f.submit(1); f.push_match(1, a_match("x1")); assert_eq!(f.results.len(), 1); diff --git a/crates/filecommand-core/src/fs_ops/conflict.rs b/crates/filecommand-core/src/fs_ops/conflict.rs index 3ad46b8..f2d9614 100644 --- a/crates/filecommand-core/src/fs_ops/conflict.rs +++ b/crates/filecommand-core/src/fs_ops/conflict.rs @@ -26,6 +26,14 @@ pub enum ConflictChoice { Skip, SkipAll, Rename(OsString), + /// Abort the whole job from the conflict prompt (operation-dialogs + /// "Overwrite-conflict dialog": Esc, `C`, or the Cancel control). The + /// conflicting target is left untouched and is *not* recorded as a + /// skipped item; the job ends as cancelled through the same path as + /// the progress dialog's Cancel. Also what a job waiting on a conflict + /// answer resolves to when cancellation is requested or the answer can + /// no longer arrive (process-lifecycle-hardening design D5/D7). + Cancel, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -122,6 +130,15 @@ mod tests { assert_eq!(policy.resolve(info()), ConflictResolution::Ask(info())); } + #[test] + fn cancel_choice_does_not_latch() { + // process-lifecycle-hardening design D7: Cancel ends the job, so + // there is nothing for the policy to remember. + let mut policy = ConflictPolicy::new(); + policy.apply(&ConflictChoice::Cancel); + assert_eq!(policy.resolve(info()), ConflictResolution::Ask(info())); + } + mod proptests { use super::*; use proptest::prelude::*; @@ -135,12 +152,7 @@ mod tests { } fn arb_choice() -> impl Strategy { - prop_oneof![ - Just(Choice::Overwrite), - Just(Choice::OverwriteAll), - Just(Choice::Skip), - Just(Choice::SkipAll), - ] + prop_oneof![Just(Choice::Overwrite), Just(Choice::OverwriteAll), Just(Choice::Skip), Just(Choice::SkipAll),] } proptest! { diff --git a/crates/filecommand-core/src/fs_ops/dialog.rs b/crates/filecommand-core/src/fs_ops/dialog.rs index 125ab24..85727de 100644 --- a/crates/filecommand-core/src/fs_ops/dialog.rs +++ b/crates/filecommand-core/src/fs_ops/dialog.rs @@ -4,6 +4,7 @@ use super::conflict::ConflictInfo; use super::error::{ErrorInfo, SkippedItem}; use super::job::{JobKind, ProgressInfo, SourceItem}; +use crate::line_edit::LineEdit; use std::ffi::OsString; use std::path::PathBuf; @@ -15,12 +16,24 @@ pub enum FileOpSetup { /// Text-input dialog. For Copy/Move, `input` is the destination path /// (pre-filled with the opposite panel's cwd, or — for a drop-initiated /// dialog — the exact drop path); for Mkdir, `input` is the new - /// directory's name. `buttons` is `None` for the plain keyboard- + /// directory's name. The field is a shared text field per + /// text-field-editing (a pre-filled `input` opens with the caret at its + /// end). `buttons` is `None` for the plain keyboard- /// initiated F5/F6/Mkdir dialog (no button row, Enter/Esc only, unchanged /// from before `mouse-panel-drag`) and `Some` for a drop-initiated dialog, /// which adds a `[ Copy ] [ Move ] [ Cancel ]` row (operation-dialogs - /// "Drop-initiated destination dialog"). - DestinationInput { kind: JobKind, sources: Vec, source_dir: PathBuf, input: String, buttons: Option }, + /// "Drop-initiated destination dialog"). `hidden_by_filter` is the same + /// disclosure count as `DeleteConfirm`'s, rendered on the blank row + /// below the field (selection "Operations disclose selected entries + /// hidden by the quick filter"); 0 for Mkdir. + DestinationInput { + kind: JobKind, + sources: Vec, + source_dir: PathBuf, + input: LineEdit, + buttons: Option, + hidden_by_filter: usize, + }, DeleteConfirm { sources: Vec, source_dir: PathBuf, @@ -30,13 +43,20 @@ pub enum FileOpSetup { /// The first confirmation has already been given; a second Enter/Y /// now runs the job. confirmed_once: bool, + /// How many of `sources` are selected entries of the source panel + /// that its active quick filter hides (0 when there is no filter or + /// nothing hidden) — computed once when the dialog opens and shown + /// as its own row between the question and the buttons (selection + /// "Operations disclose selected entries hidden by the quick + /// filter"; filter-selection-safety design D2/D3). + hidden_by_filter: usize, }, /// The file-action menu's in-place Rename: a text-input dialog /// pre-filled with `original_name`, renaming within `source_dir` /// (file-action-menu "In-place Rename"). Reuses the same - /// `Command::FileOpInputChar`/`FileOpInputBackspace`/`FileOpConfirm`/ - /// `FileOpCancel` commands `DestinationInput` uses. - RenameInput { source_dir: PathBuf, original_name: OsString, is_dir: bool, input: String }, + /// `Command::TextEdit`/`FileOpConfirm`/`FileOpCancel` commands + /// `DestinationInput` uses. + RenameInput { source_dir: PathBuf, original_name: OsString, is_dir: bool, input: LineEdit }, } /// The drop-initiated destination dialog's button row (operation-dialogs @@ -52,12 +72,42 @@ pub struct DropButtons { pub focused: JobKind, } +/// The hidden-selection disclosure line the delete confirmation and the +/// copy/move destination dialog both show when some of the operation's +/// sources are selected entries the source panel's quick filter hides +/// (selection "Operations disclose selected entries hidden by the quick +/// filter"; filter-selection-safety design D3). `None` when there is +/// nothing to disclose — the dialogs are then unchanged. +pub fn hidden_by_filter_disclosure(hidden_by_filter: usize, source_count: usize) -> Option { + if hidden_by_filter == 0 { + None + } else if source_count == 1 { + Some("Selected item is hidden by filter".to_string()) + } else { + Some(format!("{hidden_by_filter} of {source_count} selected hidden by filter")) + } +} + /// Dialogs shown while a `Job` is in flight on the worker thread. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RunningDialog { - Progress { kind: JobKind, progress: ProgressInfo }, - Conflict { kind: JobKind, info: ConflictInfo, progress: ProgressInfo, rename_input: Option }, - Error { kind: JobKind, info: ErrorInfo, progress: ProgressInfo }, + Progress { + kind: JobKind, + progress: ProgressInfo, + }, + /// `rename_input` is a shared text field per text-field-editing (`Some` + /// once the user picked Rename, starting empty). + Conflict { + kind: JobKind, + info: ConflictInfo, + progress: ProgressInfo, + rename_input: Option, + }, + Error { + kind: JobKind, + info: ErrorInfo, + progress: ProgressInfo, + }, } impl RunningDialog { diff --git a/crates/filecommand-core/src/fs_ops/error.rs b/crates/filecommand-core/src/fs_ops/error.rs index c428bae..e58b9fc 100644 --- a/crates/filecommand-core/src/fs_ops/error.rs +++ b/crates/filecommand-core/src/fs_ops/error.rs @@ -23,10 +23,39 @@ pub enum ErrorResolution { Auto(ErrorChoice), } +/// Whether a [`SkippedItem`] is work the job did not do (`Skipped`) or a +/// metadata-restore failure on a file whose content *was* copied +/// (`Warning`) — the end-of-job summary groups its rows and composes its +/// title from this distinction (error-diagnostics design D5; +/// operation-dialogs "Metadata-restore failures are reported in the +/// end-of-job summary"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemKind { + Skipped, + Warning, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkippedItem { pub path: PathBuf, pub reason: String, + pub kind: ItemKind, +} + +impl SkippedItem { + /// An item the job did not process: a conflict Skip, an error Skip, or + /// an engine guard such as a copy onto its own source. + pub fn skipped(path: PathBuf, reason: String) -> SkippedItem { + SkippedItem { path, reason, kind: ItemKind::Skipped } + } + + /// A file whose content was copied but whose metadata (alternate data + /// streams, modification time, read-only attribute) could not be fully + /// restored — recorded, never silently discarded (error-diagnostics + /// design D5). + pub fn warning(path: PathBuf, reason: String) -> SkippedItem { + SkippedItem { path, reason, kind: ItemKind::Warning } + } } /// Latching policy for per-file errors: once "Skip All" is chosen, every @@ -98,11 +127,20 @@ mod tests { #[test] fn skipped_items_accumulate_in_order() { - let mut skipped = Vec::new(); - skipped.push(SkippedItem { path: PathBuf::from("/a"), reason: "denied".into() }); - skipped.push(SkippedItem { path: PathBuf::from("/b"), reason: "sharing violation".into() }); + let skipped = + [SkippedItem::skipped(PathBuf::from("/a"), "denied".into()), SkippedItem::skipped(PathBuf::from("/b"), "sharing violation".into())]; assert_eq!(skipped.len(), 2); assert_eq!(skipped[0].path, PathBuf::from("/a")); assert_eq!(skipped[1].reason, "sharing violation"); } + + /// error-diagnostics design D5: the constructors pin the `kind` field + /// so the summary's grouping never has to guess from the reason text. + #[test] + fn skipped_item_constructors_set_the_kind() { + let skipped = SkippedItem::skipped(PathBuf::from("/a"), "denied".into()); + assert_eq!(skipped.kind, ItemKind::Skipped); + let warning = SkippedItem::warning(PathBuf::from("/b"), "modification time not restored: denied".into()); + assert_eq!(warning.kind, ItemKind::Warning); + } } diff --git a/crates/filecommand-core/src/fs_ops/fs.rs b/crates/filecommand-core/src/fs_ops/fs.rs index 54ebca2..9fd4ca4 100644 --- a/crates/filecommand-core/src/fs_ops/fs.rs +++ b/crates/filecommand-core/src/fs_ops/fs.rs @@ -4,15 +4,13 @@ //! [`crate::fs_ops::path::to_extended_path`]. //! //! Two implementations exist: [`RealFs`] (the real, Windows-backed -//! filesystem) and [`FakeFs`] (an in-memory double for deterministic unit -//! tests, including error injection). +//! filesystem) and, compiled for this crate's unit tests only, `FakeFs` (an +//! in-memory double for deterministic unit tests, including error +//! injection; durable-file-copy design D9). -use std::collections::BTreeMap; -use std::ffi::{OsStr, OsString}; +use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; use std::time::SystemTime; use super::path::to_extended_path; @@ -43,6 +41,14 @@ impl ReparseKind { #[derive(Debug, Clone, PartialEq, Eq)] pub struct FsMetadata { + /// Whether the entry is a directory object. For the non-followed + /// metadata of a reparse point this means "the link itself is a + /// directory object" — a Windows junction or `mklink /D` symlink, which + /// is removed with `remove_dir` — not whether its target is a directory + /// (move-reparse-point-safety design D4). A Unix symlink is never a + /// directory object and is removed with `remove_file`. Callers that + /// recurse check `reparse` first, so a link is never traversed as a + /// directory by mistake. pub is_dir: bool, pub reparse: ReparseKind, pub size: u64, @@ -57,13 +63,86 @@ pub struct RawEntry { pub metadata: FsMetadata, } -/// What a chunked-copy progress callback tells the caller to do next. +/// What a copy progress callback tells the caller to do next. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChunkControl { Continue, Cancel, } +/// Options for [`FileSystem::copy_file`] (durable-file-copy design D2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CopyOptions { + /// The chunk size for implementations that copy in a read/write loop, + /// and so the progress-callback cadence. A hint: the Windows + /// implementation hands the copy to `CopyFileExW`, which chooses its own + /// chunking. + pub chunk_bytes: usize, + /// Flush the destination to stable storage before returning, so a flush + /// or close failure fails the copy. A move passes `true`, because it is + /// about to delete the only other copy; a plain copy passes `false` + /// (design D4). + pub flush: bool, +} + +/// One piece of metadata a copy could not carry over. The `Display` form is +/// the user-facing clause the end-of-job summary shows, followed by the +/// operating system's message (operation-dialogs "Metadata-restore failures +/// are reported in the end-of-job summary"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnrestoredKind { + /// A named alternate data stream that is missing on the copy. + Stream(OsString), + /// The source's (or the copy's) stream list could not be read, so + /// nothing is known about its streams. + StreamListing, + ModifiedTime, + CreatedTime, + ReadOnly, +} + +impl std::fmt::Display for UnrestoredKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnrestoredKind::Stream(name) => write!(f, "stream {} not copied", name.to_string_lossy()), + UnrestoredKind::StreamListing => f.write_str("alternate data streams not listed"), + UnrestoredKind::ModifiedTime => f.write_str("modification time not restored"), + UnrestoredKind::CreatedTime => f.write_str("creation time not restored"), + UnrestoredKind::ReadOnly => f.write_str("read-only attribute not restored"), + } + } +} + +/// Metadata a [`FileSystem::copy_file`] could not carry over, with the +/// operating system's message. Never fails the copy and never swallowed +/// inside the filesystem layer: the worker turns each one into a summary +/// warning (durable-file-copy design D2; error-diagnostics design D5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unrestored { + pub what: UnrestoredKind, + pub error: String, +} + +impl Unrestored { + pub fn new(what: UnrestoredKind, error: impl ToString) -> Unrestored { + Unrestored { what, error: error.to_string() } + } + + /// `": "`, the row the end-of-job summary shows. + pub fn reason(&self) -> String { + format!("{}: {}", self.what, self.error) + } +} + +/// The outcome of a successful [`FileSystem::copy_file`]. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct CopyReport { + /// Bytes of the main data stream — the same quantity the progress + /// callback reports, and the length the destination was checked against. + pub bytes: u64, + pub unrestored: Vec, +} + /// The narrow fs-access seam. `metadata` never follows a reparse point /// (lstat-like); `metadata_follow` always does (stat-like) — `fs_ops` needs /// both to implement reparse-point semantics correctly. @@ -72,28 +151,45 @@ pub trait FileSystem: Send + Sync { fn metadata_follow(&self, path: &Path) -> io::Result; fn read_dir(&self, path: &Path) -> io::Result>; fn create_dir(&self, path: &Path) -> io::Result<()>; - /// Copy `from` to `to` in chunks of roughly `chunk_bytes`, invoking - /// `on_chunk` with the cumulative byte count after each chunk. Returns - /// the total bytes copied. If `on_chunk` returns - /// [`ChunkControl::Cancel`], the copy stops and an `io::Error` of kind - /// `Interrupted` is returned (the caller distinguishes cancellation from - /// a genuine I/O failure by that error kind). - fn copy_file_chunked( - &self, - from: &Path, - to: &Path, - chunk_bytes: usize, - on_chunk: &mut dyn FnMut(u64) -> ChunkControl, - ) -> io::Result; + /// Copy `from` — data *and* metadata — to `to` (durable-file-copy design + /// D2). The contract: + /// + /// - **Exclusive create.** `to` must not exist; if it does, the call + /// fails with `AlreadyExists`. Nothing existing is ever overwritten or + /// removed to make room. + /// - **Metadata.** Named alternate data streams, the read-only, hidden, + /// system and archive attributes, and the creation and last-write + /// times are preserved as far as the platform allows (Windows: + /// `CopyFileExW` plus an explicit creation-time restore; elsewhere the + /// last-write time and read-only state). + /// - **Byte count.** [`CopyReport::bytes`] is the main data stream's + /// byte count, the quantity `on_progress` reports cumulatively. + /// - **Length check.** Before returning `Ok`, the destination's length is + /// compared with `bytes`; a mismatch is an error. + /// - **Flush.** With [`CopyOptions::flush`], the destination is flushed + /// to stable storage first, and a flush or close failure is an error. + /// - **Unrestored metadata.** Metadata that could not be carried over is + /// listed in [`CopyReport::unrestored`]. It never fails the call and is + /// never silently discarded here. + /// + /// If `on_progress` returns [`ChunkControl::Cancel`], the copy stops, the + /// partial destination is removed as far as the platform allows, and an + /// `io::Error` of kind `Interrupted` is returned (the caller + /// distinguishes cancellation from a genuine I/O failure by that kind). + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result; fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; fn remove_file(&self, path: &Path) -> io::Result<()>; fn remove_dir(&self, path: &Path) -> io::Result<()>; + /// Still on the seam after `copy_file` took over metadata restore: the + /// worker clears a target's read-only attribute before an overwrite and + /// a source's before a move removes it. fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()>; - fn set_modified(&self, path: &Path, modified: SystemTime) -> io::Result<()>; - /// Alternate Data Stream names attached to `path` (Windows NTFS only). - /// Always empty on platforms/filesystems without ADS support. - fn list_streams(&self, path: &Path) -> io::Result>; - fn copy_stream_chunked(&self, from: &Path, stream: &OsStr, to: &Path, chunk_bytes: usize) -> io::Result; + /// Whether the volume holding `path` supports named alternate data + /// streams (Windows: `GetVolumeInformationW`'s `FILE_NAMED_STREAMS` + /// flag). The copy engine asks once per job; an error is treated as + /// "supported" so genuine per-file stream failures still surface + /// individually (error-diagnostics design D5). + fn supports_named_streams(&self, path: &Path) -> io::Result; /// Whether `a` and `b` reside on the same volume — governs whether a /// move is an instant rename or a copy-then-delete. fn same_volume(&self, a: &Path, b: &Path) -> io::Result; @@ -124,6 +220,21 @@ impl RealFs { } } +/// The one way [`RealFs`] methods obtain an OS path from a caller's path. +/// `to_extended_path` leaves a relative path relative, and Windows then +/// resolves it against the process's current directory — which FileCommand +/// never changes from the launch directory — so a relative path reaching +/// `RealFs` is always an upstream bug: fail loudly in debug builds and +/// tests instead of silently acting on the wrong directory +/// (relative-destination-resolution design D4). The assertion lives at this +/// boundary rather than inside `to_extended_path` because +/// `shell::spawn_safe_path` legitimately routes relative program names such +/// as `cmd.exe` through the latter once they approach `MAX_PATH`. +fn ext(path: &Path) -> PathBuf { + debug_assert!(path.is_absolute(), "fs_ops received a relative path: {path:?}"); + to_extended_path(path) +} + #[cfg(windows)] mod windows_impl { use super::*; @@ -131,6 +242,7 @@ mod windows_impl { const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; const FILE_ATTRIBUTE_READONLY: u32 = 0x1; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; fn reparse_kind_from_metadata(md: &std::fs::Metadata, is_reparse: bool) -> ReparseKind { if !is_reparse { @@ -150,7 +262,14 @@ mod windows_impl { let attrs = md.file_attributes(); let is_reparse = attrs & FILE_ATTRIBUTE_REPARSE_POINT != 0; FsMetadata { - is_dir: md.is_dir(), + // The attribute bit, not `md.is_dir()`: the latter is false for + // every name-surrogate reparse point, so a junction's no-follow + // metadata would claim it is a file and the reparse-leaf removal + // would call `DeleteFileW` on a directory object, which Windows + // refuses with "Access is denied" (move-reparse-point-safety + // design D4). For non-reparse entries, and for followed + // metadata, the two agree. + is_dir: attrs & FILE_ATTRIBUTE_DIRECTORY != 0, reparse: reparse_kind_from_metadata(&md, is_reparse), size: md.file_size(), modified: md.modified().ok(), @@ -161,21 +280,21 @@ mod windows_impl { impl super::FileSystem for super::RealFs { fn metadata(&self, path: &Path) -> io::Result { - let p = to_extended_path(path); + let p = ext(path); let mut out = to_fs_metadata(std::fs::symlink_metadata(&p)?); out.file_id = super::identity::file_id_via_handle(&p, false); Ok(out) } fn metadata_follow(&self, path: &Path) -> io::Result { - let p = to_extended_path(path); + let p = ext(path); let mut out = to_fs_metadata(std::fs::metadata(&p)?); out.file_id = super::identity::file_id_via_handle(&p, true); Ok(out) } fn read_dir(&self, path: &Path) -> io::Result> { - let p = to_extended_path(path); + let p = ext(path); let mut out = Vec::new(); for entry in std::fs::read_dir(&p)? { let entry = entry?; @@ -186,52 +305,34 @@ mod windows_impl { } fn create_dir(&self, path: &Path) -> io::Result<()> { - std::fs::create_dir(to_extended_path(path)) + std::fs::create_dir(ext(path)) } - fn copy_file_chunked( - &self, - from: &Path, - to: &Path, - chunk_bytes: usize, - on_chunk: &mut dyn FnMut(u64) -> ChunkControl, - ) -> io::Result { - copy_chunked_impl(&to_extended_path(from), &to_extended_path(to), chunk_bytes, on_chunk) + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + super::copy_ex::copy_file(&ext(from), &ext(to), opts, on_progress) } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { - std::fs::rename(to_extended_path(from), to_extended_path(to)) + std::fs::rename(ext(from), ext(to)) } fn remove_file(&self, path: &Path) -> io::Result<()> { - std::fs::remove_file(to_extended_path(path)) + std::fs::remove_file(ext(path)) } fn remove_dir(&self, path: &Path) -> io::Result<()> { - std::fs::remove_dir(to_extended_path(path)) + std::fs::remove_dir(ext(path)) } fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()> { - let p = to_extended_path(path); + let p = ext(path); let mut perms = std::fs::metadata(&p)?.permissions(); perms.set_readonly(readonly); std::fs::set_permissions(&p, perms) } - fn set_modified(&self, path: &Path, modified: SystemTime) -> io::Result<()> { - let p = to_extended_path(path); - let file = std::fs::OpenOptions::new().write(true).open(&p)?; - file.set_modified(modified) - } - - fn list_streams(&self, path: &Path) -> io::Result> { - super::stream_enum::list_streams(&to_extended_path(path)) - } - - fn copy_stream_chunked(&self, from: &Path, stream: &OsStr, to: &Path, chunk_bytes: usize) -> io::Result { - let from_stream = super::stream_enum::stream_path(&to_extended_path(from), stream); - let to_stream = super::stream_enum::stream_path(&to_extended_path(to), stream); - copy_chunked_impl(&from_stream, &to_stream, chunk_bytes, &mut |_| ChunkControl::Continue) + fn supports_named_streams(&self, path: &Path) -> io::Result { + super::volume_caps::supports_named_streams(&ext(path)) } fn same_volume(&self, a: &Path, b: &Path) -> io::Result { @@ -243,32 +344,209 @@ mod windows_impl { } } } +} + +#[cfg(windows)] +mod copy_ex { + //! `RealFs::copy_file` on Windows: `CopyFileExW` for the data, every + //! named stream, the attributes and the last-write time, followed by an + //! explicit creation-time restore, a length check, an optional flush and + //! a post-copy stream check (durable-file-copy design D3, D4). The Win32 + //! prototypes and constants come from `windows-sys` (ci-and-build-tooling + //! design D6); only the progress-routine *callback* is defined here, with + //! the signature `LPPROGRESS_ROUTINE` expects. + use super::{cancelled_error, ChunkControl, CopyOptions, CopyReport, Unrestored, UnrestoredKind}; + use core::ffi::c_void; + use std::fs::FileTimes; + use std::io; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::fs::{FileTimesExt, OpenOptionsExt}; + use std::path::Path; + use windows_sys::Win32::Foundation::{ERROR_REQUEST_ABORTED, HANDLE}; + use windows_sys::Win32::Storage::FileSystem::{CopyFileExW, FILE_WRITE_ATTRIBUTES, LPPROGRESS_ROUTINE_CALLBACK_REASON}; + use windows_sys::Win32::System::WindowsProgramming::{ + COPY_FILE_ALLOW_DECRYPTED_DESTINATION, COPY_FILE_FAIL_IF_EXISTS, PROGRESS_CANCEL, PROGRESS_CONTINUE, + }; + + /// Everything the progress routine needs, reached through `lpData`. + struct ProgressState<'a> { + on_progress: &'a mut dyn FnMut(u64) -> ChunkControl, + /// Cumulative bytes of stream 1, the main data stream. ADS bytes + /// never move the gauge past the planned total. + stream1_bytes: u64, + /// A panic caught inside the routine, to be re-raised once + /// `CopyFileExW` has returned: unwinding across the FFI boundary is + /// undefined behavior. + panic: Option>, + } + + /// The `LPPROGRESS_ROUTINE` callback `CopyFileExW` invokes; its + /// signature matches the `windows-sys` function-pointer type exactly. + unsafe extern "system" fn progress_routine( + _total_file_size: i64, + _total_bytes_transferred: i64, + _stream_size: i64, + stream_bytes_transferred: i64, + stream_number: u32, + _callback_reason: LPPROGRESS_ROUTINE_CALLBACK_REASON, + _source_file: HANDLE, + _destination_file: HANDLE, + data: *const c_void, + ) -> u32 { + // SAFETY: `data` is the `&mut ProgressState` `copy_file` handed to + // `CopyFileExW`, which outlives the call; `CopyFileExW` invokes the + // routine synchronously on the calling thread, so no one else + // touches the state while this runs. + let state = unsafe { &mut *(data as *mut ProgressState<'_>) }; + if state.panic.is_some() { + return PROGRESS_CANCEL; + } + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + if stream_number == 1 { + state.stream1_bytes = stream_bytes_transferred.max(0) as u64; + } + // Polled on every callback, including CALLBACK_STREAM_SWITCH, so + // cancellation is observed at the OS's chunk cadence. + (state.on_progress)(state.stream1_bytes) + })); + match outcome { + Ok(ChunkControl::Continue) => PROGRESS_CONTINUE, + Ok(ChunkControl::Cancel) => PROGRESS_CANCEL, + Err(payload) => { + state.panic = Some(payload); + PROGRESS_CANCEL + } + } + } - fn copy_chunked_impl( - from: &Path, - to: &Path, - chunk_bytes: usize, - on_chunk: &mut dyn FnMut(u64) -> ChunkControl, - ) -> io::Result { - use std::io::{Read, Write}; - let mut src = std::fs::File::open(from)?; - let mut dst = std::fs::File::create(to)?; - let mut buf = vec![0u8; chunk_bytes.max(4096)]; - let mut total = 0u64; - loop { - let n = src.read(&mut buf)?; - if n == 0 { - break; + fn wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() + } + + fn length_mismatch(expected: u64, actual: u64) -> io::Error { + io::Error::other(format!("destination length {actual} does not match the {expected} bytes copied")) + } + + pub fn copy_file(from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + // Followed, like `CopyFileExW` itself (no COPY_FILE_COPY_SYMLINK): a + // file symlink is copied as its target's content. + let src_md = std::fs::metadata(from)?; + let wide_from = wide(from); + let wide_to = wide(to); + let mut state = ProgressState { on_progress, stream1_bytes: 0, panic: None }; + // SAFETY: both path buffers are null-terminated and outlive the + // call; `state` is a live local that `progress_routine` reads back + // through `data` only while `CopyFileExW` runs; the routine never + // unwinds (it catches panics and stores them in `state`). + let ok = unsafe { + CopyFileExW( + wide_from.as_ptr(), + wide_to.as_ptr(), + Some(progress_routine), + &mut state as *mut ProgressState<'_> as *const c_void, + std::ptr::null_mut(), + COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_ALLOW_DECRYPTED_DESTINATION, + ) + }; + // Taken before anything else can clobber it. + let failure = if ok == 0 { Some(io::Error::last_os_error()) } else { None }; + if let Some(payload) = state.panic.take() { + std::panic::resume_unwind(payload); + } + if let Some(e) = failure { + // A cancelled copy fails with ERROR_REQUEST_ABORTED and + // `CopyFileExW` has already deleted its partial destination. + return Err(if e.raw_os_error() == Some(ERROR_REQUEST_ABORTED as i32) { cancelled_error() } else { e }); + } + let bytes = state.stream1_bytes; + let mut unrestored = Vec::new(); + + // Creation time: not preserved by `CopyFileExW`. FILE_WRITE_ATTRIBUTES + // opens even a read-only copy. Without `flush`, the length check runs + // on this handle too. + match std::fs::OpenOptions::new().access_mode(FILE_WRITE_ATTRIBUTES).open(to) { + Ok(file) => { + match src_md.created() { + Ok(created) => { + if let Err(e) = file.set_times(FileTimes::new().set_created(created)) { + unrestored.push(Unrestored::new(UnrestoredKind::CreatedTime, e)); + } + } + Err(e) => unrestored.push(Unrestored::new(UnrestoredKind::CreatedTime, e)), + } + if !opts.flush { + let len = file.metadata()?.len(); + if len != bytes { + return Err(length_mismatch(bytes, len)); + } + } + } + Err(e) => { + unrestored.push(Unrestored::new(UnrestoredKind::CreatedTime, e)); + if !opts.flush { + let len = std::fs::metadata(to)?.len(); + if len != bytes { + return Err(length_mismatch(bytes, len)); + } + } + } + } + + // Flush: `FlushFileBuffers` needs a write handle, and a read-only + // file cannot be opened for writing, so the bit is cleared around the + // flush and restored afterwards (design D3). A flush, length or + // close failure fails the copy; a failure to restore read-only does + // not. + if opts.flush { + let was_readonly = std::fs::metadata(to)?.permissions().readonly(); + if was_readonly { + let mut perms = std::fs::metadata(to)?.permissions(); + // Windows-only module: clearing read-only toggles exactly the + // FILE_ATTRIBUTE_READONLY bit (the lint is about Unix modes). + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(to, perms)?; } - dst.write_all(&buf[..n])?; - total += n as u64; - if on_chunk(total) == ChunkControl::Cancel { - drop(dst); - let _ = std::fs::remove_file(to); - return Err(super::cancelled_error()); + let flushed = (|| -> io::Result<()> { + let file = std::fs::OpenOptions::new().write(true).open(to)?; + let len = file.metadata()?.len(); + if len != bytes { + return Err(length_mismatch(bytes, len)); + } + file.sync_all()?; + drop(file); + Ok(()) + })(); + if was_readonly { + let mut perms = std::fs::metadata(to)?.permissions(); + perms.set_readonly(true); + if let Err(e) = std::fs::set_permissions(to, perms) { + unrestored.push(Unrestored::new(UnrestoredKind::ReadOnly, e)); + } } + flushed?; } - Ok(total) + + // Named streams: `CopyFileExW` copies them itself but is not + // documented to report one it could not create, so when the source + // has any, the copy's list is compared afterwards. Files without + // named streams — the common case — pay one `FindFirstStreamW`. + match super::stream_enum::list_streams(from) { + Ok(src_streams) if !src_streams.is_empty() => match super::stream_enum::list_streams(to) { + Ok(dst_streams) => { + for name in src_streams { + if !dst_streams.contains(&name) { + unrestored.push(Unrestored::new(UnrestoredKind::Stream(name), "not present on the copy")); + } + } + } + Err(e) => unrestored.push(Unrestored::new(UnrestoredKind::StreamListing, e)), + }, + Ok(_) => {} + Err(e) => unrestored.push(Unrestored::new(UnrestoredKind::StreamListing, e)), + } + + Ok(CopyReport { bytes, unrestored }) } } @@ -279,6 +557,11 @@ mod portable_impl { fn to_fs_metadata(md: std::fs::Metadata) -> FsMetadata { let is_symlink = md.file_type().is_symlink(); FsMetadata { + // Deliberately `md.is_dir()`, unlike the Windows implementation: + // a Unix symlink is never a directory object, even when it + // points at a directory, and it is removed with `unlink` + // (`remove_file`); `rmdir` on it fails with `ENOTDIR` + // (move-reparse-point-safety design D4). is_dir: md.is_dir(), reparse: if is_symlink { ReparseKind::Symlink } else { ReparseKind::None }, size: if md.is_dir() { 0 } else { md.len() }, @@ -301,16 +584,16 @@ mod portable_impl { impl super::FileSystem for super::RealFs { fn metadata(&self, path: &Path) -> io::Result { - Ok(to_fs_metadata(std::fs::symlink_metadata(to_extended_path(path))?)) + Ok(to_fs_metadata(std::fs::symlink_metadata(ext(path))?)) } fn metadata_follow(&self, path: &Path) -> io::Result { - Ok(to_fs_metadata(std::fs::metadata(to_extended_path(path))?)) + Ok(to_fs_metadata(std::fs::metadata(ext(path))?)) } fn read_dir(&self, path: &Path) -> io::Result> { let mut out = Vec::new(); - for entry in std::fs::read_dir(to_extended_path(path))? { + for entry in std::fs::read_dir(ext(path))? { let entry = entry?; out.push(RawEntry { name: entry.file_name(), metadata: to_fs_metadata(entry.metadata()?) }); } @@ -318,20 +601,22 @@ mod portable_impl { } fn create_dir(&self, path: &Path) -> io::Result<()> { - std::fs::create_dir(to_extended_path(path)) + std::fs::create_dir(ext(path)) } - fn copy_file_chunked( - &self, - from: &Path, - to: &Path, - chunk_bytes: usize, - on_chunk: &mut dyn FnMut(u64) -> ChunkControl, - ) -> io::Result { + /// The manual read/write loop (durable-file-copy design D8): + /// exclusive create, chunked copy with cancel handling, last-write + /// time on the open handle, optional `sync_all`, a length check, and + /// read-only applied after close. Creation time, extended attributes + /// and ADS are not exposed portably and are not attempted. + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { use std::io::{Read, Write}; - let mut src = std::fs::File::open(to_extended_path(from))?; - let mut dst = std::fs::File::create(to_extended_path(to))?; - let mut buf = vec![0u8; chunk_bytes.max(4096)]; + let from = ext(from); + let to = ext(to); + let src_md = std::fs::metadata(&from)?; + let mut src = std::fs::File::open(&from)?; + let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(&to)?; + let mut buf = vec![0u8; opts.chunk_bytes.max(4096)]; let mut total = 0u64; loop { let n = src.read(&mut buf)?; @@ -340,46 +625,62 @@ mod portable_impl { } dst.write_all(&buf[..n])?; total += n as u64; - if on_chunk(total) == ChunkControl::Cancel { + if on_progress(total) == ChunkControl::Cancel { drop(dst); - let _ = std::fs::remove_file(to_extended_path(to)); + let _ = std::fs::remove_file(&to); return Err(super::cancelled_error()); } } - Ok(total) + let mut unrestored = Vec::new(); + match src_md.modified() { + Ok(modified) => { + if let Err(e) = dst.set_modified(modified) { + unrestored.push(Unrestored::new(UnrestoredKind::ModifiedTime, e)); + } + } + Err(e) => unrestored.push(Unrestored::new(UnrestoredKind::ModifiedTime, e)), + } + if opts.flush { + dst.sync_all()?; + } + let len = dst.metadata()?.len(); + if len != total { + return Err(io::Error::other(format!("destination length {len} does not match the {total} bytes copied"))); + } + drop(dst); + if src_md.permissions().readonly() { + let mut perms = std::fs::metadata(&to)?.permissions(); + perms.set_readonly(true); + if let Err(e) = std::fs::set_permissions(&to, perms) { + unrestored.push(Unrestored::new(UnrestoredKind::ReadOnly, e)); + } + } + Ok(CopyReport { bytes: total, unrestored }) } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { - std::fs::rename(to_extended_path(from), to_extended_path(to)) + std::fs::rename(ext(from), ext(to)) } fn remove_file(&self, path: &Path) -> io::Result<()> { - std::fs::remove_file(to_extended_path(path)) + std::fs::remove_file(ext(path)) } fn remove_dir(&self, path: &Path) -> io::Result<()> { - std::fs::remove_dir(to_extended_path(path)) + std::fs::remove_dir(ext(path)) } fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()> { - let p = to_extended_path(path); + let p = ext(path); let mut perms = std::fs::metadata(&p)?.permissions(); perms.set_readonly(readonly); std::fs::set_permissions(&p, perms) } - fn set_modified(&self, path: &Path, modified: SystemTime) -> io::Result<()> { - let p = to_extended_path(path); - let file = std::fs::OpenOptions::new().write(true).open(&p)?; - file.set_modified(modified) - } - - fn list_streams(&self, _path: &Path) -> io::Result> { - Ok(Vec::new()) - } - - fn copy_stream_chunked(&self, _from: &Path, _stream: &OsStr, _to: &Path, _chunk_bytes: usize) -> io::Result { - Ok(0) + fn supports_named_streams(&self, _path: &Path) -> io::Result { + // ADS is Windows-only; `copy_file` never reports a stream here, + // so this answer never produces a warning off-Windows. + Ok(false) } fn same_volume(&self, a: &Path, b: &Path) -> io::Result { @@ -393,65 +694,67 @@ mod portable_impl { } } +#[cfg(windows)] +mod volume_caps { + //! `GetVolumeInformationW` for the per-volume `FILE_NAMED_STREAMS` + //! capability flag, used by the copy engine to detect destinations with + //! no ADS support at all (FAT32/exFAT) and report one aggregated warning + //! per job instead of one per file (error-diagnostics design D5). The + //! prototype and the flag come from `windows-sys`. + use std::io; + use std::os::windows::ffi::OsStrExt; + use std::path::Path; + use windows_sys::Win32::Storage::FileSystem::GetVolumeInformationW; + use windows_sys::Win32::System::SystemServices::FILE_NAMED_STREAMS; + + /// Whether the volume holding `path` supports named streams. + /// `GetVolumeInformationW` wants the volume root (`C:\` or a UNC share, + /// trailing backslash included), which is the path's longest — i.e. + /// last — ancestor. + pub fn supports_named_streams(path: &Path) -> io::Result { + let root = path.ancestors().last().unwrap_or(path); + let wide: Vec = root.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + let mut flags = 0u32; + // SAFETY: `wide` is null-terminated and outlives the call; the only + // non-null out pointer is `flags`, a live local. + let ok = unsafe { + GetVolumeInformationW( + wide.as_ptr(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut flags, + std::ptr::null_mut(), + 0, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(flags & FILE_NAMED_STREAMS != 0) + } +} + #[cfg(windows)] mod identity { - //! Volume-serial + file-index identity via raw `GetFileInformationByHandle` - //! FFI. `std::os::windows::fs::MetadataExt::file_index` / - //! `volume_serial_number` remain gated behind the unstable + //! Volume-serial + file-index identity via `GetFileInformationByHandle` + //! (through `windows-sys`). `std::os::windows::fs::MetadataExt::file_index` + //! / `volume_serial_number` remain gated behind the unstable //! `windows_by_handle` feature, so this crate queries the same //! `BY_HANDLE_FILE_INFORMATION` data directly instead of depending on it. use super::FileId; use std::os::windows::ffi::OsStrExt; use std::path::Path; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; - #[allow(non_camel_case_types)] - type HANDLE = *mut core::ffi::c_void; - #[allow(non_camel_case_types)] - type c_void = core::ffi::c_void; - - #[repr(C)] - struct FileTime { - low: u32, - high: u32, - } - - #[repr(C)] - struct ByHandleFileInformation { - file_attributes: u32, - creation_time: FileTime, - last_access_time: FileTime, - last_write_time: FileTime, - volume_serial_number: u32, - file_size_high: u32, - file_size_low: u32, - number_of_links: u32, - file_index_high: u32, - file_index_low: u32, - } - - const GENERIC_READ_NONE: u32 = 0; - const FILE_SHARE_READ: u32 = 0x1; - const FILE_SHARE_WRITE: u32 = 0x2; - const FILE_SHARE_DELETE: u32 = 0x4; - const OPEN_EXISTING: u32 = 3; - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - const INVALID_HANDLE_VALUE: isize = -1; - - #[link(name = "kernel32")] - extern "system" { - fn CreateFileW( - file_name: *const u16, - desired_access: u32, - share_mode: u32, - security_attrs: *mut c_void, - creation_disposition: u32, - flags_and_attrs: u32, - template_file: HANDLE, - ) -> HANDLE; - fn GetFileInformationByHandle(file: HANDLE, info: *mut ByHandleFileInformation) -> i32; - fn CloseHandle(object: HANDLE) -> i32; - } + /// No data access at all: metadata only, so a file opened for exclusive + /// writing elsewhere can still be identified. + const NO_ACCESS: u32 = 0; /// Resolve `path`'s volume-serial + file-index identity. `follow` /// controls whether a reparse point at `path` is traversed @@ -461,28 +764,34 @@ mod identity { pub fn file_id_via_handle(path: &Path, follow: bool) -> Option { let wide: Vec = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); let flags = FILE_FLAG_BACKUP_SEMANTICS | if follow { 0 } else { FILE_FLAG_OPEN_REPARSE_POINT }; + // SAFETY: `wide` is NUL-terminated and outlives the calls; a null + // security-attributes pointer and a null template handle are + // permitted by `CreateFileW`; `info` is a live, zeroed out-struct + // (`BY_HANDLE_FILE_INFORMATION` is plain data, valid when zeroed); + // the handle is checked against `INVALID_HANDLE_VALUE` before use and + // closed on every path after it, so it is never used after close. unsafe { let handle = CreateFileW( wide.as_ptr(), - GENERIC_READ_NONE, + NO_ACCESS, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null_mut(), + std::ptr::null(), OPEN_EXISTING, flags, std::ptr::null_mut(), ); - if handle as isize == INVALID_HANDLE_VALUE { + if handle == INVALID_HANDLE_VALUE { return None; } - let mut info: ByHandleFileInformation = std::mem::zeroed(); + let mut info: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed(); let ok = GetFileInformationByHandle(handle, &mut info); CloseHandle(handle); if ok == 0 { return None; } Some(FileId { - volume_serial: info.volume_serial_number as u64, - file_index: ((info.file_index_high as u64) << 32) | info.file_index_low as u64, + volume_serial: info.dwVolumeSerialNumber as u64, + file_index: ((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64, }) } } @@ -490,37 +799,15 @@ mod identity { #[cfg(windows)] mod stream_enum { - //! Minimal raw FFI over `FindFirstStreamW`/`FindNextStreamW` (available - //! since Vista) to enumerate Alternate Data Streams. No external crate - //! is pulled in for this — just the handful of kernel32 entry points - //! needed. - use std::ffi::{OsStr, OsString}; + //! `FindFirstStreamW`/`FindNextStreamW` (available since Vista), through + //! `windows-sys`, to enumerate Alternate Data Streams. + use core::ffi::c_void; + use std::ffi::OsString; use std::io; use std::os::windows::ffi::{OsStrExt, OsStringExt}; use std::path::Path; - - #[repr(C)] - struct Win32FindStreamData { - stream_size: i64, - stream_name: [u16; 296], // MAX_PATH(260) + 36, per WIN32_FIND_STREAM_DATA - } - - #[allow(non_camel_case_types)] - type HANDLE = *mut core::ffi::c_void; - - const INVALID_HANDLE_VALUE: isize = -1; - const FIND_STREAM_INFO_STANDARD: u32 = 0; - const ERROR_HANDLE_EOF: i32 = 38; - const ERROR_FILE_NOT_FOUND: i32 = 2; - const ERROR_NOT_SUPPORTED: i32 = 50; - - #[link(name = "kernel32")] - extern "system" { - fn FindFirstStreamW(lpFileName: *const u16, InfoLevel: u32, lpFindStreamData: *mut Win32FindStreamData, dwFlags: u32) -> HANDLE; - fn FindNextStreamW(hFindStream: HANDLE, lpFindStreamData: *mut Win32FindStreamData) -> i32; - fn FindClose(hFindFile: HANDLE) -> i32; - fn GetLastError() -> u32; - } + use windows_sys::Win32::Foundation::{GetLastError, ERROR_FILE_NOT_FOUND, ERROR_HANDLE_EOF, ERROR_NOT_SUPPORTED, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{FindClose, FindFirstStreamW, FindNextStreamW, FindStreamInfoStandard, WIN32_FIND_STREAM_DATA}; fn wide(path: &Path) -> Vec { path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() @@ -531,30 +818,38 @@ mod stream_enum { /// only care about *named* ADS. pub fn list_streams(path: &Path) -> io::Result> { let wpath = wide(path); - let mut data = Win32FindStreamData { stream_size: 0, stream_name: [0u16; 296] }; + let mut data = WIN32_FIND_STREAM_DATA { StreamSize: 0, cStreamName: [0u16; 296] }; let mut out = Vec::new(); + // SAFETY: `wpath` is NUL-terminated and outlives the calls; `data` is + // a live `WIN32_FIND_STREAM_DATA`, exactly the layout the + // `FindStreamInfoStandard` level fills, passed as the untyped + // out-pointer the prototype takes; the handle is checked against + // `INVALID_HANDLE_VALUE` before use and closed on every path after + // it; `GetLastError` is read right after the failing call. unsafe { - let handle = FindFirstStreamW(wpath.as_ptr(), FIND_STREAM_INFO_STANDARD, &mut data, 0); - if handle as isize == INVALID_HANDLE_VALUE { - let err = GetLastError() as i32; + let handle = FindFirstStreamW(wpath.as_ptr(), FindStreamInfoStandard, &mut data as *mut WIN32_FIND_STREAM_DATA as *mut c_void, 0); + if handle == INVALID_HANDLE_VALUE { + let err = GetLastError(); return if err == ERROR_HANDLE_EOF || err == ERROR_FILE_NOT_FOUND || err == ERROR_NOT_SUPPORTED { Ok(Vec::new()) } else { - Err(io::Error::from_raw_os_error(err)) + Err(io::Error::from_raw_os_error(err as i32)) }; } loop { - let name = decode_name(&data.stream_name); - if name != ":$DATA" { - if let Some(stripped) = name.strip_suffix(":$DATA").and_then(|s| s.strip_prefix(':')) { + let name = decode_name(&data.cStreamName); + // The unnamed default stream is reported as `::$DATA`; a + // named one as `::$DATA`. Only the latter are ADS. + if let Some(stripped) = name.strip_suffix(":$DATA").and_then(|s| s.strip_prefix(':')) { + if !stripped.is_empty() { out.push(OsString::from(stripped)); } } - if FindNextStreamW(handle, &mut data) == 0 { - let err = GetLastError() as i32; + if FindNextStreamW(handle, &mut data as *mut WIN32_FIND_STREAM_DATA as *mut c_void) == 0 { + let err = GetLastError(); if err != ERROR_HANDLE_EOF { FindClose(handle); - return Err(io::Error::from_raw_os_error(err)); + return Err(io::Error::from_raw_os_error(err as i32)); } break; } @@ -568,20 +863,23 @@ mod stream_enum { let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); OsString::from_wide(&buf[..len]).to_string_lossy().into_owned() } - - /// Build the `path:stream` form used to address a named ADS. - pub fn stream_path(path: &Path, stream: &OsStr) -> std::path::PathBuf { - let mut s = path.as_os_str().to_os_string(); - s.push(":"); - s.push(stream); - std::path::PathBuf::from(s) - } } // --------------------------------------------------------------------- -// FakeFs — deterministic in-memory double for unit tests +// FakeFs — deterministic in-memory double for unit tests. Compiled for this +// crate's tests only: the release binary carries no fault-injection double +// (durable-file-copy design D9; filesystem-error-handling "The release +// binary carries no fault-injection double"). // --------------------------------------------------------------------- +#[cfg(test)] +use std::collections::BTreeMap; +#[cfg(test)] +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +#[cfg(test)] +use std::sync::Mutex; + +#[cfg(test)] #[derive(Debug, Clone)] enum FakeNode { File { size: u64, modified: Option, readonly: bool, streams: BTreeMap }, @@ -590,20 +888,38 @@ enum FakeNode { } /// Which fs-trait operation an [`Injection`] applies to. +/// +/// Copy-related injections (`CopyAfterChunks`, `Flush`, `SetModified`, +/// `SetReadonly`, `CopyStream`) are keyed on the copy's *final target* path +/// as well as on the destination actually passed to `copy_file`, so a test +/// can inject on `/dst/a.txt` without knowing that the worker copies into +/// `/dst/a.txt.~fc-partial` first (durable-file-copy design D1, D10). +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InjectableOp { Metadata, ReadDir, CreateDir, - /// Fails a chunked copy after the given number of chunks have already - /// succeeded (simulates disk-full mid-copy). + /// Fails a copy after the given number of chunks have already succeeded + /// (simulates disk-full mid-copy). The partial destination node is left + /// in place, as on a real filesystem. CopyAfterChunks(u64), + /// Fails a `flush: true` copy after its last chunk, with the destination + /// node fully written (durable-file-copy design D10). + Flush, Rename, RemoveFile, RemoveDir, SetReadonly, + /// Metadata-restore fault injection (error-diagnostics task 5.4): each + /// turns the corresponding piece of metadata into an `Unrestored` entry + /// of the copy's report. `ListStreams` keys on the *source* path. + SetModified, + ListStreams, + CopyStream, } +#[cfg(test)] #[derive(Debug, Clone)] struct Injection { op: InjectableOp, @@ -615,20 +931,27 @@ struct Injection { /// `PathBuf`s (via [`super::path::lexically_normalize`]); volumes are /// modeled by an explicit per-path prefix so cross-volume-move tests don't /// need real multi-volume hardware — see [`FakeFs::set_volume`]. +#[cfg(test)] pub struct FakeFs { nodes: Mutex>, volumes: Mutex>, ids: Mutex>, next_id: AtomicU64, injections: Mutex>, + /// Whether every volume answers `supports_named_streams` with `true` + /// (the default). Flipped to model a FAT32/exFAT destination + /// (error-diagnostics design D5). + named_streams: AtomicBool, } +#[cfg(test)] impl Default for FakeFs { fn default() -> Self { Self::new() } } +#[cfg(test)] impl FakeFs { pub fn new() -> Self { FakeFs { @@ -637,6 +960,7 @@ impl FakeFs { ids: Mutex::new(BTreeMap::new()), next_id: AtomicU64::new(1), injections: Mutex::new(Vec::new()), + named_streams: AtomicBool::new(true), } } @@ -649,19 +973,13 @@ impl FakeFs { } pub fn add_file(&self, path: &Path, size: u64) { - self.nodes - .lock() - .unwrap() - .insert(Self::norm(path), FakeNode::File { size, modified: None, readonly: false, streams: BTreeMap::new() }); + self.nodes.lock().unwrap().insert(Self::norm(path), FakeNode::File { size, modified: None, readonly: false, streams: BTreeMap::new() }); } pub fn add_file_with_stream(&self, path: &Path, size: u64, stream_name: &str, stream_size: u64) { let mut streams = BTreeMap::new(); streams.insert(OsString::from(stream_name), stream_size); - self.nodes - .lock() - .unwrap() - .insert(Self::norm(path), FakeNode::File { size, modified: None, readonly: false, streams }); + self.nodes.lock().unwrap().insert(Self::norm(path), FakeNode::File { size, modified: None, readonly: false, streams }); } pub fn add_reparse_point(&self, path: &Path, is_dir: bool, target: &Path, kind: ReparseKind) { @@ -674,15 +992,20 @@ impl FakeFs { self.volumes.lock().unwrap().insert(Self::norm(prefix), volume); } + /// Test toggle for destinations without ADS support (error-diagnostics + /// design D5): with `false`, the copy engine skips per-file stream + /// copies and records one job-level warning. + pub fn set_named_streams_supported(&self, supported: bool) { + self.named_streams.store(supported, Ordering::SeqCst); + } + fn volume_of(&self, path: &Path) -> u64 { let p = Self::norm(path); let volumes = self.volumes.lock().unwrap(); let mut best: Option<(&PathBuf, &u64)> = None; for (prefix, vol) in volumes.iter() { - if p.starts_with(prefix) { - if best.map(|(b, _)| prefix.components().count() > b.components().count()).unwrap_or(true) { - best = Some((prefix, vol)); - } + if p.starts_with(prefix) && best.map(|(b, _)| prefix.components().count() > b.components().count()).unwrap_or(true) { + best = Some((prefix, vol)); } } best.map(|(_, v)| *v).unwrap_or(0) @@ -708,10 +1031,71 @@ impl FakeFs { None } + /// The paths a copy-related injection may be keyed on for a copy into + /// `to`: `to` itself, and — when `to` is a worker temporary + /// (`.~fc-partial[-N]`) — the final target it stands for. + fn copy_injection_keys(to: &Path) -> Vec { + let to = Self::norm(to); + let mut keys = vec![to.clone()]; + if let (Some(parent), Some(name)) = (to.parent(), to.file_name()) { + let name = name.to_string_lossy(); + if let Some(cut) = name.find(super::worker::PARTIAL_SUFFIX) { + keys.push(parent.join(&name[..cut])); + } + } + keys + } + + /// Like [`Self::take_injection`], for an `op` that applies to a copy + /// into `to` (see [`InjectableOp`]). + fn take_copy_injection(&self, op: InjectableOp, to: &Path) -> Option { + let keys = Self::copy_injection_keys(to); + let mut injections = self.injections.lock().unwrap(); + if let Some(idx) = injections.iter().position(|i| i.op == op && keys.contains(&i.path)) { + return Some(injections.remove(idx).kind); + } + None + } + + /// Test helper: pin a file's last-write time (the field `copy_file` + /// carries over). Not on the trait any more (durable-file-copy design + /// D2). + pub fn set_modified(&self, path: &Path, modified: SystemTime) -> io::Result<()> { + let p = self.resolve_ancestors(path); + let mut nodes = self.nodes.lock().unwrap(); + match nodes.get_mut(&p) { + Some(FakeNode::File { modified: m, .. }) => { + *m = Some(modified); + Ok(()) + } + _ => Err(io::Error::from(io::ErrorKind::NotFound)), + } + } + + /// Test helper: the named streams modeled on a file, for asserting what + /// `copy_file` carried over. Not on the trait any more (durable-file-copy + /// design D2). + pub fn list_streams(&self, path: &Path) -> io::Result> { + let p = self.resolve_ancestors(path); + let nodes = self.nodes.lock().unwrap(); + match nodes.get(&p) { + Some(FakeNode::File { streams, .. }) => Ok(streams.keys().cloned().collect()), + Some(_) => Ok(Vec::new()), + None => Err(io::Error::from(io::ErrorKind::NotFound)), + } + } + /// Resolve `path` the way real NTFS resolves a path through junctions: /// every *ancestor* component is transparently redirected if it's a /// reparse point, but the final component itself is left as-is (so /// callers can still see that the final component is a reparse point). + /// + /// Every path-taking operation — not only `metadata` — resolves through + /// here, so a mutation addressed *through* a link lands on the target + /// side exactly as `DeleteFileW`/`RemoveDirectoryW` would, while a + /// mutation addressed *at* a link acts on the link node itself + /// (move-reparse-point-safety design D5). Without that, a test could + /// not reproduce a move deleting through a junction. fn resolve_ancestors(&self, path: &Path) -> PathBuf { let p = Self::norm(path); match (p.parent(), p.file_name()) { @@ -756,6 +1140,7 @@ impl FakeFs { } } +#[cfg(test)] impl FileSystem for FakeFs { fn metadata(&self, path: &Path) -> io::Result { if let Some(kind) = self.take_injection(InjectableOp::Metadata, path) { @@ -798,7 +1183,7 @@ impl FileSystem for FakeFs { if let Some(kind) = self.take_injection(InjectableOp::CreateDir, path) { return Err(io::Error::from(kind)); } - let p = Self::norm(path); + let p = self.resolve_ancestors(path); let mut nodes = self.nodes.lock().unwrap(); if nodes.contains_key(&p) { return Err(io::Error::from(io::ErrorKind::AlreadyExists)); @@ -807,26 +1192,39 @@ impl FileSystem for FakeFs { Ok(()) } - fn copy_file_chunked( - &self, - from: &Path, - to: &Path, - chunk_bytes: usize, - on_chunk: &mut dyn FnMut(u64) -> ChunkControl, - ) -> io::Result { - let size = match self.metadata(from)? { - FsMetadata { size, is_dir: false, .. } => size, - _ => return Err(io::Error::from(io::ErrorKind::InvalidInput)), + /// Durable-file-copy design D10: the destination node exists from the + /// first chunk and grows per chunk, so an injected failure or a cancel + /// leaves a partial node exactly as a real filesystem would; creation is + /// exclusive; and the source's streams, read-only state and last-write + /// time are carried over on success, each one divertible into + /// `unrestored` by the matching injection. + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + // Opening a file for reading follows a file symlink, so the source + // is resolved fully; the destination resolves its ancestors like + // every other mutation (move-reparse-point-safety design D5). + let src = self.resolve_full(from); + let (size, streams, readonly, modified) = match self.nodes.lock().unwrap().get(&src) { + Some(FakeNode::File { size, streams, readonly, modified }) => (*size, streams.clone(), *readonly, *modified), + Some(_) => return Err(io::Error::from(io::ErrorKind::InvalidInput)), + None => return Err(io::Error::from(io::ErrorKind::NotFound)), }; - let chunk = chunk_bytes.max(1) as u64; + let dest = self.resolve_ancestors(to); + { + let mut nodes = self.nodes.lock().unwrap(); + if nodes.contains_key(&dest) { + return Err(io::Error::from(io::ErrorKind::AlreadyExists)); + } + nodes.insert(dest.clone(), FakeNode::File { size: 0, modified: None, readonly: false, streams: BTreeMap::new() }); + } + let keys = Self::copy_injection_keys(to); + let chunk = opts.chunk_bytes.max(1) as u64; let mut done = 0u64; let mut chunk_index = 0u64; loop { let fail_after = { let mut injections = self.injections.lock().unwrap(); - let idx = injections - .iter() - .position(|i| matches!(i.op, InjectableOp::CopyAfterChunks(n) if n == chunk_index) && i.path == Self::norm(to)); + let idx = + injections.iter().position(|i| matches!(i.op, InjectableOp::CopyAfterChunks(n) if n == chunk_index) && keys.contains(&i.path)); idx.map(|idx| injections.remove(idx).kind) }; if let Some(kind) = fail_after { @@ -835,26 +1233,59 @@ impl FileSystem for FakeFs { let step = chunk.min(size - done); done += step; chunk_index += 1; - if on_chunk(done) == ChunkControl::Cancel { + if let Some(FakeNode::File { size: s, .. }) = self.nodes.lock().unwrap().get_mut(&dest) { + *s = done; + } + if on_progress(done) == ChunkControl::Cancel { return Err(cancelled_error()); } if done >= size { break; } } - self.nodes - .lock() - .unwrap() - .insert(Self::norm(to), FakeNode::File { size, modified: None, readonly: false, streams: BTreeMap::new() }); - Ok(size) + if opts.flush { + if let Some(kind) = self.take_copy_injection(InjectableOp::Flush, to) { + return Err(io::Error::from(kind)); + } + } + + let mut unrestored = Vec::new(); + if let Some(kind) = self.take_injection(InjectableOp::ListStreams, from) { + unrestored.push(Unrestored::new(UnrestoredKind::StreamListing, io::Error::from(kind))); + } else { + for (name, stream_size) in streams { + if !self.named_streams.load(Ordering::SeqCst) { + unrestored.push(Unrestored::new(UnrestoredKind::Stream(name), "destination volume has no named-stream support")); + } else if let Some(kind) = self.take_copy_injection(InjectableOp::CopyStream, to) { + unrestored.push(Unrestored::new(UnrestoredKind::Stream(name), io::Error::from(kind))); + } else if let Some(FakeNode::File { streams, .. }) = self.nodes.lock().unwrap().get_mut(&dest) { + streams.insert(name, stream_size); + } + } + } + if let Some(modified) = modified { + if let Some(kind) = self.take_copy_injection(InjectableOp::SetModified, to) { + unrestored.push(Unrestored::new(UnrestoredKind::ModifiedTime, io::Error::from(kind))); + } else if let Some(FakeNode::File { modified: m, .. }) = self.nodes.lock().unwrap().get_mut(&dest) { + *m = Some(modified); + } + } + if readonly { + if let Some(kind) = self.take_copy_injection(InjectableOp::SetReadonly, to) { + unrestored.push(Unrestored::new(UnrestoredKind::ReadOnly, io::Error::from(kind))); + } else if let Some(FakeNode::File { readonly: r, .. }) = self.nodes.lock().unwrap().get_mut(&dest) { + *r = true; + } + } + Ok(CopyReport { bytes: size, unrestored }) } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { if let Some(kind) = self.take_injection(InjectableOp::Rename, from) { return Err(io::Error::from(kind)); } - let pf = Self::norm(from); - let pt = Self::norm(to); + let pf = self.resolve_ancestors(from); + let pt = self.resolve_ancestors(to); let mut nodes = self.nodes.lock().unwrap(); let node = nodes.remove(&pf).ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; nodes.insert(pt.clone(), node); @@ -872,10 +1303,14 @@ impl FileSystem for FakeFs { if let Some(kind) = self.take_injection(InjectableOp::RemoveFile, path) { return Err(io::Error::from(kind)); } - let p = Self::norm(path); + let p = self.resolve_ancestors(path); let mut nodes = self.nodes.lock().unwrap(); match nodes.get(&p) { Some(FakeNode::File { readonly: true, .. }) => Err(io::Error::from(io::ErrorKind::PermissionDenied)), + // `DeleteFileW` refuses a directory object — a real directory + // or a directory junction/symlink — with "Access is denied" + // (move-reparse-point-safety design D5). + Some(FakeNode::Dir { .. }) | Some(FakeNode::Reparse { is_dir: true, .. }) => Err(io::Error::from(io::ErrorKind::PermissionDenied)), Some(_) => { nodes.remove(&p); Ok(()) @@ -888,10 +1323,14 @@ impl FileSystem for FakeFs { if let Some(kind) = self.take_injection(InjectableOp::RemoveDir, path) { return Err(io::Error::from(kind)); } - let p = Self::norm(path); + // Ancestors resolve, the final component does not: `remove_dir` on + // a junction path removes the link node itself, and the emptiness + // check below looks at the link's own (always empty) children, never + // at the target's. + let p = self.resolve_ancestors(path); let mut nodes = self.nodes.lock().unwrap(); if nodes.keys().any(|k| k.parent() == Some(p.as_path())) { - return Err(io::Error::new(io::ErrorKind::Other, "directory not empty")); + return Err(io::Error::other("directory not empty")); } nodes.remove(&p).ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; Ok(()) @@ -901,7 +1340,7 @@ impl FileSystem for FakeFs { if let Some(kind) = self.take_injection(InjectableOp::SetReadonly, path) { return Err(io::Error::from(kind)); } - let p = Self::norm(path); + let p = self.resolve_ancestors(path); let mut nodes = self.nodes.lock().unwrap(); match nodes.get_mut(&p) { Some(FakeNode::File { readonly: r, .. }) => *r = readonly, @@ -911,47 +1350,13 @@ impl FileSystem for FakeFs { Ok(()) } - fn set_modified(&self, path: &Path, modified: SystemTime) -> io::Result<()> { - let p = Self::norm(path); - let mut nodes = self.nodes.lock().unwrap(); - match nodes.get_mut(&p) { - Some(FakeNode::File { modified: m, .. }) => { - *m = Some(modified); - Ok(()) - } - _ => Err(io::Error::from(io::ErrorKind::NotFound)), - } - } - - fn list_streams(&self, path: &Path) -> io::Result> { - let p = Self::norm(path); - let nodes = self.nodes.lock().unwrap(); - match nodes.get(&p) { - Some(FakeNode::File { streams, .. }) => Ok(streams.keys().cloned().collect()), - _ => Ok(Vec::new()), - } - } - - fn copy_stream_chunked(&self, from: &Path, stream: &OsStr, to: &Path, _chunk_bytes: usize) -> io::Result { - let pf = Self::norm(from); - let size = { - let nodes = self.nodes.lock().unwrap(); - match nodes.get(&pf) { - Some(FakeNode::File { streams, .. }) => *streams.get(stream).unwrap_or(&0), - _ => 0, - } - }; - let pt = Self::norm(to); - let mut nodes = self.nodes.lock().unwrap(); - if let Some(FakeNode::File { streams, .. }) = nodes.get_mut(&pt) { - streams.insert(stream.to_os_string(), size); - } - Ok(size) - } - fn same_volume(&self, a: &Path, b: &Path) -> io::Result { Ok(self.volume_of(a) == self.volume_of(b)) } + + fn supports_named_streams(&self, _path: &Path) -> io::Result { + Ok(self.named_streams.load(Ordering::SeqCst)) + } } #[cfg(test)] @@ -969,20 +1374,27 @@ mod tests { assert!(fake.metadata(Path::new("/a.txt")).is_ok()); } + fn opts(chunk_bytes: usize, flush: bool) -> CopyOptions { + CopyOptions { chunk_bytes, flush } + } + #[test] - fn fake_fs_copy_disk_full_after_n_chunks() { + fn fake_fs_copy_disk_full_after_n_chunks_leaves_a_partial_node() { let fake = FakeFs::new(); fake.add_file(Path::new("/src.bin"), 30); fake.inject(InjectableOp::CopyAfterChunks(2), Path::new("/dst.bin"), io::ErrorKind::StorageFull); let mut chunks = 0u64; let err = fake - .copy_file_chunked(Path::new("/src.bin"), Path::new("/dst.bin"), 10, &mut |_| { + .copy_file(Path::new("/src.bin"), Path::new("/dst.bin"), &opts(10, false), &mut |_| { chunks += 1; ChunkControl::Continue }) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::StorageFull); assert_eq!(chunks, 2); + // durable-file-copy design D10: the partial destination stays, as + // on a real filesystem, so the worker's cleanup is testable. + assert_eq!(fake.metadata(Path::new("/dst.bin")).unwrap().size, 20, "two chunks of ten bytes landed"); } #[test] @@ -990,7 +1402,7 @@ mod tests { let fake = FakeFs::new(); fake.add_file(Path::new("/src.bin"), 30); let err = fake - .copy_file_chunked(Path::new("/src.bin"), Path::new("/dst.bin"), 10, &mut |done| { + .copy_file(Path::new("/src.bin"), Path::new("/dst.bin"), &opts(10, false), &mut |done| { if done >= 10 { ChunkControl::Cancel } else { @@ -999,6 +1411,40 @@ mod tests { }) .unwrap_err(); assert!(is_cancelled(&err)); + assert_eq!(fake.metadata(Path::new("/dst.bin")).unwrap().size, 10, "a cancel leaves the partial node too"); + } + + #[test] + fn fake_fs_copy_creates_the_destination_exclusively() { + let fake = FakeFs::new(); + fake.add_file(Path::new("/src.bin"), 3); + fake.add_file(Path::new("/dst.bin"), 99); + let err = fake.copy_file(Path::new("/src.bin"), Path::new("/dst.bin"), &opts(10, false), &mut |_| ChunkControl::Continue).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fake.metadata(Path::new("/dst.bin")).unwrap().size, 99, "the existing file is untouched"); + } + + #[test] + fn fake_fs_flush_injection_fails_a_flushed_copy_after_the_last_chunk() { + let fake = FakeFs::new(); + fake.add_file(Path::new("/src.bin"), 30); + fake.inject(InjectableOp::Flush, Path::new("/dst.bin"), io::ErrorKind::Other); + let err = fake.copy_file(Path::new("/src.bin"), Path::new("/dst.bin"), &opts(10, true), &mut |_| ChunkControl::Continue).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::Other); + assert_eq!(fake.metadata(Path::new("/dst.bin")).unwrap().size, 30, "fully written, then the flush failed"); + // The same injection is ignored by an unflushed copy. + fake.inject(InjectableOp::Flush, Path::new("/dst2.bin"), io::ErrorKind::Other); + assert!(fake.copy_file(Path::new("/src.bin"), Path::new("/dst2.bin"), &opts(10, false), &mut |_| ChunkControl::Continue).is_ok()); + } + + #[test] + fn fake_fs_copy_injections_key_on_the_final_target_of_a_partial_file() { + let fake = FakeFs::new(); + fake.add_file(Path::new("/src.bin"), 30); + fake.inject(InjectableOp::CopyAfterChunks(0), Path::new("/dst/a.bin"), io::ErrorKind::StorageFull); + let temp = PathBuf::from(format!("/dst/a.bin{}-2", super::super::worker::PARTIAL_SUFFIX)); + let err = fake.copy_file(Path::new("/src.bin"), &temp, &opts(10, false), &mut |_| ChunkControl::Continue).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::StorageFull); } #[test] @@ -1047,14 +1493,94 @@ mod tests { assert!(followed.is_dir); } + // move-reparse-point-safety design D5: mutations resolve ancestor links + // like NTFS, and `remove_file` refuses directory objects. + + fn junction_fixture() -> FakeFs { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/shared")); + fake.add_file(Path::new("/shared/f.txt"), 9); + fake.add_dir(Path::new("/work")); + fake.add_reparse_point(Path::new("/work/link"), true, Path::new("/shared"), ReparseKind::Junction); + fake + } + + #[test] + fn fake_fs_remove_file_through_ancestor_junction_removes_the_target_side_node() { + let fake = junction_fixture(); + fake.remove_file(Path::new("/work/link/f.txt")).unwrap(); + assert!(fake.metadata(Path::new("/shared/f.txt")).is_err(), "the real file behind the link is gone, as on NTFS"); + assert!(fake.metadata(Path::new("/work/link")).is_ok(), "the link itself is untouched"); + } + + #[test] + fn fake_fs_remove_dir_on_a_junction_path_removes_only_the_link_node() { + let fake = junction_fixture(); + fake.remove_dir(Path::new("/work/link")).unwrap(); + assert!(fake.metadata(Path::new("/work/link")).is_err(), "the link node is gone"); + assert_eq!(fake.metadata(Path::new("/shared/f.txt")).unwrap().size, 9, "the target and its content survive"); + } + #[test] - fn fake_fs_streams_roundtrip() { + fn fake_fs_remove_file_on_a_directory_junction_is_refused() { + let fake = junction_fixture(); + let err = fake.remove_file(Path::new("/work/link")).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::PermissionDenied, "DeleteFileW on a directory object is denied"); + assert!(fake.metadata(Path::new("/work/link")).is_ok()); + fake.add_dir(Path::new("/plain")); + assert_eq!(fake.remove_file(Path::new("/plain")).unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn fake_fs_mutations_through_a_junction_land_on_the_target_side() { + let fake = junction_fixture(); + fake.set_readonly(Path::new("/work/link/f.txt"), true).unwrap(); + assert!(fake.metadata(Path::new("/shared/f.txt")).unwrap().readonly, "set_readonly followed the ancestor link"); + fake.create_dir(Path::new("/work/link/sub")).unwrap(); + assert!(fake.metadata(Path::new("/shared/sub")).unwrap().is_dir, "create_dir followed the ancestor link"); + fake.rename(Path::new("/work/link/sub"), Path::new("/work/link/sub2")).unwrap(); + assert!(fake.metadata(Path::new("/shared/sub2")).is_ok(), "rename resolved both ends"); + fake.remove_dir(Path::new("/work/link/sub2")).unwrap(); + assert!(fake.metadata(Path::new("/shared/sub2")).is_err(), "remove_dir through the link removed the target-side dir"); + } + + #[test] + fn fake_fs_copy_carries_streams_readonly_and_modified_over() { let fake = FakeFs::new(); fake.add_file_with_stream(Path::new("/a.txt"), 5, "Zone.Identifier", 3); - let streams = fake.list_streams(Path::new("/a.txt")).unwrap(); - assert_eq!(streams, vec![OsString::from("Zone.Identifier")]); - fake.add_file(Path::new("/b.txt"), 5); - fake.copy_stream_chunked(Path::new("/a.txt"), OsStr::new("Zone.Identifier"), Path::new("/b.txt"), 64).unwrap(); + fake.set_readonly(Path::new("/a.txt"), true).unwrap(); + let modified = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + fake.set_modified(Path::new("/a.txt"), modified).unwrap(); + assert_eq!(fake.list_streams(Path::new("/a.txt")).unwrap(), vec![OsString::from("Zone.Identifier")]); + + let report = fake.copy_file(Path::new("/a.txt"), Path::new("/b.txt"), &opts(64, false), &mut |_| ChunkControl::Continue).unwrap(); + assert_eq!(report.bytes, 5); + assert!(report.unrestored.is_empty(), "{:?}", report.unrestored); assert_eq!(fake.list_streams(Path::new("/b.txt")).unwrap(), vec![OsString::from("Zone.Identifier")]); + let md = fake.metadata(Path::new("/b.txt")).unwrap(); + assert!(md.readonly); + assert_eq!(md.modified, Some(modified)); + } + + #[test] + fn fake_fs_metadata_injections_divert_into_the_copy_report() { + let fake = FakeFs::new(); + fake.add_file_with_stream(Path::new("/a.txt"), 5, "Zone.Identifier", 3); + fake.set_readonly(Path::new("/a.txt"), true).unwrap(); + fake.set_modified(Path::new("/a.txt"), SystemTime::UNIX_EPOCH).unwrap(); + fake.inject(InjectableOp::CopyStream, Path::new("/b.txt"), io::ErrorKind::PermissionDenied); + fake.inject(InjectableOp::SetModified, Path::new("/b.txt"), io::ErrorKind::PermissionDenied); + fake.inject(InjectableOp::SetReadonly, Path::new("/b.txt"), io::ErrorKind::PermissionDenied); + + let report = fake.copy_file(Path::new("/a.txt"), Path::new("/b.txt"), &opts(64, false), &mut |_| ChunkControl::Continue).unwrap(); + let kinds: Vec<&UnrestoredKind> = report.unrestored.iter().map(|u| &u.what).collect(); + assert_eq!(kinds, vec![&UnrestoredKind::Stream(OsString::from("Zone.Identifier")), &UnrestoredKind::ModifiedTime, &UnrestoredKind::ReadOnly]); + assert_eq!(report.unrestored[0].reason(), format!("stream Zone.Identifier not copied: {}", io::Error::from(io::ErrorKind::PermissionDenied))); + assert!(report.unrestored[1].reason().starts_with("modification time not restored: ")); + assert!(report.unrestored[2].reason().starts_with("read-only attribute not restored: ")); + let md = fake.metadata(Path::new("/b.txt")).unwrap(); + assert_eq!(md.size, 5, "the content still arrived"); + assert!(!md.readonly); + assert!(fake.list_streams(Path::new("/b.txt")).unwrap().is_empty()); } } diff --git a/crates/filecommand-core/src/fs_ops/job.rs b/crates/filecommand-core/src/fs_ops/job.rs index edb9c5f..b468a7b 100644 --- a/crates/filecommand-core/src/fs_ops/job.rs +++ b/crates/filecommand-core/src/fs_ops/job.rs @@ -8,6 +8,7 @@ use std::ffi::OsString; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use super::conflict::{ConflictChoice, ConflictInfo}; use super::error::{ErrorChoice, ErrorInfo, SkippedItem}; @@ -38,19 +39,22 @@ pub struct SourceItem { } /// A single Copy/Move/Delete/Mkdir/Rename request. `dest_dir` is the -/// destination directory for Copy/Move and the parent directory for Mkdir; -/// `Delete` leaves it equal to `source_dir` (unused, but keeps the type -/// uniform for panel-re-read matching in `core::update`). `Rename` likewise -/// sets `dest_dir` equal to `source_dir` — the renamed entry stays put — and -/// carries its single source's new name in `new_dir_name`. +/// destination directory for Copy/Move; `Delete` leaves it equal to +/// `source_dir` (unused, but keeps the type uniform for panel-re-read +/// matching in `core::update`). Mkdir and `Rename` carry the resolved parent +/// of the new name in `dest_dir` — `source_dir` itself for a plain name — +/// and the bare new name in `new_dir_name` (relative-destination-resolution +/// design D3). `dest_dir` is absolute and normalized for every kind, so the +/// completion re-read in `core::update` matches panels against it exactly. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Job { pub kind: JobKind, pub sources: Vec, pub source_dir: PathBuf, pub dest_dir: PathBuf, - /// The new directory's name for Mkdir, or the target entry's new name - /// for Rename; unused by Copy/Move/Delete. + /// The bare final component of the resolved target: the new directory's + /// name for Mkdir, or the target entry's new name for Rename; unused by + /// Copy/Move/Delete. pub new_dir_name: Option, } @@ -69,8 +73,63 @@ impl ProgressInfo { } } +/// The minimum spacing between progress updates a sink should forward to the +/// UI — about 20 per second (idle-aware-event-loop "Progress updates are +/// rate-limited"; design D7). +pub const PROGRESS_INTERVAL: Duration = Duration::from_millis(50); + +/// A pure rate-limiter for [`JobSink::progress`] (idle-aware-event-loop +/// design D7): `now` is injected, so engine tests stay deterministic and the +/// engine itself (`super::worker::run_job` with its recording sinks) is never +/// throttled. The first offer is always delivered; an offer inside +/// [`PROGRESS_INTERVAL`] of the last delivered one is held as `pending`, the +/// newest replacing it; [`flush`](Self::flush) hands out whatever is held. +/// The sink flushes before a conflict, an error, and completion, so a +/// decision point or the final frame never shows stale figures. +#[derive(Debug, Default)] +pub struct ProgressThrottle { + last_delivered: Option, + pending: Option, +} + +impl ProgressThrottle { + pub fn new() -> ProgressThrottle { + ProgressThrottle::default() + } + + /// Offer one progress update: returned (deliver it) when at least + /// [`PROGRESS_INTERVAL`] has passed since the last delivered update — or + /// none was delivered yet; otherwise held, replacing any previously held + /// update, with `None` returned. + pub fn offer(&mut self, info: ProgressInfo, now: Instant) -> Option { + let due = match self.last_delivered { + None => true, + Some(last) => now.duration_since(last) >= PROGRESS_INTERVAL, + }; + if due { + self.last_delivered = Some(now); + self.pending = None; + Some(info) + } else { + self.pending = Some(info); + None + } + } + + /// The newest held update, if any. `last_delivered` is left alone: a + /// flushed update goes out beside the message that triggered the flush, + /// so it does not reset the cadence. + pub fn flush(&mut self) -> Option { + self.pending.take() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum JobOutcome { + /// `skipped` carries both kinds of `SkippedItem`: items the job did + /// not process (`ItemKind::Skipped`) and metadata-restore warnings + /// (`ItemKind::Warning`) for files whose content *was* copied + /// (error-diagnostics design D5). Completed { skipped: Vec }, /// Covers both an explicit user Cancel and an Abort chosen from the /// error-recovery dialog — both mean "stop early." @@ -108,3 +167,60 @@ impl CancelFlag { self.0.load(Ordering::SeqCst) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn info(files_done: usize, bytes_done: u64) -> ProgressInfo { + ProgressInfo { files_done, files_total: 10, bytes_done, bytes_total: 1_000, current_file: OsString::new() } + } + + // idle-aware-event-loop (file-operations "Progress updates are + // rate-limited"; design D7; task 1.4) + + #[test] + fn the_first_offer_is_always_delivered() { + let mut throttle = ProgressThrottle::new(); + assert_eq!(throttle.offer(info(0, 0), Instant::now()), Some(info(0, 0))); + } + + #[test] + fn offers_within_the_interval_are_held_and_only_the_latest_is_flushed() { + let t0 = Instant::now(); + let mut throttle = ProgressThrottle::new(); + assert!(throttle.offer(info(1, 100), t0).is_some(), "the first offer goes out"); + assert_eq!(throttle.offer(info(2, 200), t0 + Duration::from_millis(10)), None, "inside the interval: held"); + assert_eq!(throttle.offer(info(3, 300), t0 + Duration::from_millis(20)), None, "held, replacing the previous one"); + assert_eq!(throttle.flush(), Some(info(3, 300)), "only the newest held update is flushed"); + assert_eq!(throttle.flush(), None, "nothing is held after the flush"); + } + + #[test] + fn an_offer_after_the_interval_is_delivered() { + let t0 = Instant::now(); + let mut throttle = ProgressThrottle::new(); + assert!(throttle.offer(info(1, 100), t0).is_some()); + assert_eq!(throttle.offer(info(2, 200), t0 + PROGRESS_INTERVAL - Duration::from_millis(1)), None, "one ms short is held"); + assert_eq!(throttle.offer(info(3, 300), t0 + PROGRESS_INTERVAL), Some(info(3, 300)), "exactly at the interval is due"); + // A delivered offer drops anything that was held before it. + assert_eq!(throttle.flush(), None); + } + + #[test] + fn a_thousand_offers_over_one_second_deliver_at_most_twenty_one() { + // file-operations "A fast copy does not flood the UI": the first + // offer plus one per 50 ms interval is 20 in a 1 s span. + let t0 = Instant::now(); + let mut throttle = ProgressThrottle::new(); + let mut delivered = 0; + for i in 0..1_000u64 { + if throttle.offer(info(i as usize, i), t0 + Duration::from_millis(i)).is_some() { + delivered += 1; + } + } + assert!(delivered <= 21, "{delivered} updates passed the throttle in one second"); + assert_eq!(delivered, 20, "t = 0, 50, ..., 950 ms"); + assert_eq!(throttle.flush(), Some(info(999, 999)), "the final figures are never lost"); + } +} diff --git a/crates/filecommand-core/src/fs_ops/mod.rs b/crates/filecommand-core/src/fs_ops/mod.rs index 18f0724..b5e7407 100644 --- a/crates/filecommand-core/src/fs_ops/mod.rs +++ b/crates/filecommand-core/src/fs_ops/mod.rs @@ -12,7 +12,10 @@ pub mod worker; pub use conflict::{ConflictChoice, ConflictInfo, ConflictPolicy, ConflictResolution}; pub use dialog::{DropButtons, FileOpSetup, RunningDialog}; -pub use error::{ErrorChoice, ErrorInfo, ErrorPolicy, ErrorResolution, SkippedItem}; -pub use fs::{ChunkControl, FakeFs, FileId, FileSystem, FsMetadata, RawEntry, RealFs, ReparseKind}; -pub use job::{CancelFlag, Job, JobKind, JobOutcome, JobSink, ProgressInfo, SourceItem}; +pub use error::{ErrorChoice, ErrorInfo, ErrorPolicy, ErrorResolution, ItemKind, SkippedItem}; +#[cfg(test)] +pub use fs::FakeFs; +pub use fs::{ChunkControl, CopyOptions, CopyReport, FileId, FileSystem, FsMetadata, RawEntry, RealFs, ReparseKind, Unrestored, UnrestoredKind}; +pub use job::{CancelFlag, Job, JobKind, JobOutcome, JobSink, ProgressInfo, ProgressThrottle, SourceItem}; pub use worker::run_job; +pub use worker::PARTIAL_SUFFIX; diff --git a/crates/filecommand-core/src/fs_ops/worker.rs b/crates/filecommand-core/src/fs_ops/worker.rs index 2e75bd0..e94cb38 100644 --- a/crates/filecommand-core/src/fs_ops/worker.rs +++ b/crates/filecommand-core/src/fs_ops/worker.rs @@ -16,11 +16,26 @@ use crate::listing::DateTime; use super::conflict::{ConflictChoice, ConflictInfo, ConflictPolicy, ConflictResolution}; use super::error::{ErrorChoice, ErrorInfo, ErrorPolicy, ErrorResolution, SkippedItem}; -use super::fs::{is_cancelled, ChunkControl, FileId, FileSystem}; +use super::fs::{is_cancelled, ChunkControl, CopyOptions, CopyReport, FileId, FileSystem, Unrestored, UnrestoredKind}; use super::job::{Job, JobKind, JobOutcome, JobSink, ProgressInfo}; +use super::path::lexically_normalize; const CHUNK_BYTES: usize = 256 * 1024; +/// Every copied file lands through `.~fc-partial` in the +/// target's own directory and is renamed over the target only once the copy +/// succeeded (durable-file-copy design D1; file-operations "Copies land via +/// a temporary partial file"). The suffix keeps a leftover next to its +/// target in a name-sorted panel, claims no shell association, and is +/// self-describing and safe to delete. +pub const PARTIAL_SUFFIX: &str = ".~fc-partial"; +/// How many numbered variants (`-2`, `-3`, …) are tried when the plain +/// temporary name is taken, before the exclusive create is left to refuse. +const PARTIAL_NAME_ATTEMPTS: u32 = 100; +/// NTFS's (and most filesystems') limit on one path component, in UTF-16 +/// code units. +const MAX_COMPONENT_UTF16: usize = 255; + pub fn run_job(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { match job.kind { JobKind::Mkdir => run_mkdir(job, fs, sink), @@ -78,7 +93,7 @@ fn run_mkdir(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { let mut error_policy = ErrorPolicy::new(); let outcome = match attempt_with_recovery(&target, &mut error_policy, sink, || fs.create_dir(&target)) { RetryOutcome::Done => JobOutcome::Completed { skipped: vec![] }, - RetryOutcome::Skip(reason) => JobOutcome::Cancelled { skipped: vec![SkippedItem { path: target.clone(), reason }] }, + RetryOutcome::Skip(reason) => JobOutcome::Cancelled { skipped: vec![SkippedItem::skipped(target.clone(), reason)] }, RetryOutcome::Abort => JobOutcome::Cancelled { skipped: vec![] }, }; sink.done(outcome); @@ -89,18 +104,27 @@ fn run_mkdir(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { // --------------------------------------------------------------------- enum DeleteItem { - File { path: PathBuf, size: u64, readonly: bool }, + File { + path: PathBuf, + size: u64, + readonly: bool, + }, /// A reparse point (symlink/junction): removing it deletes the link, /// never the target's contents. - ReparseLeaf { path: PathBuf, is_dir: bool }, - Dir { path: PathBuf }, + ReparseLeaf { + path: PathBuf, + is_dir: bool, + }, + Dir { + path: PathBuf, + }, } fn walk_delete(fs: &dyn FileSystem, abs: &Path, out: &mut Vec, skipped: &mut Vec) { let md = match fs.metadata(abs) { Ok(m) => m, Err(e) => { - skipped.push(SkippedItem { path: abs.to_path_buf(), reason: e.to_string() }); + skipped.push(SkippedItem::skipped(abs.to_path_buf(), e.to_string())); return; } }; @@ -116,13 +140,27 @@ fn walk_delete(fs: &dyn FileSystem, abs: &Path, out: &mut Vec, skipp } out.push(DeleteItem::Dir { path: abs.to_path_buf() }); } - Err(e) => skipped.push(SkippedItem { path: abs.to_path_buf(), reason: e.to_string() }), + Err(e) => skipped.push(SkippedItem::skipped(abs.to_path_buf(), e.to_string())), } } else { out.push(DeleteItem::File { path: abs.to_path_buf(), size: md.size, readonly: md.readonly }); } } +/// Remove a reparse point (symlink/junction) itself, never anything reached +/// through it: `remove_dir` for a directory-object link, `remove_file` +/// otherwise. Read-only is deliberately *not* cleared first — `set_readonly` +/// follows the link and would modify the target. Shared by Delete and the +/// source-removal half of a cross-volume move so both halves apply one rule +/// (move-reparse-point-safety design D3). +fn remove_reparse_leaf(fs: &dyn FileSystem, path: &Path, is_dir: bool) -> io::Result<()> { + if is_dir { + fs.remove_dir(path) + } else { + fs.remove_file(path) + } +} + fn run_delete(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { let mut items = Vec::new(); let mut skipped = Vec::new(); @@ -150,14 +188,17 @@ fn run_delete(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { }; progress.current_file = path.file_name().unwrap_or_default().to_os_string(); - let outcome = attempt_with_recovery(&path, &mut error_policy, sink, || { - if readonly { - let _ = fs.set_readonly(&path, false); - } - if is_dir { - fs.remove_dir(&path) - } else { - fs.remove_file(&path) + let outcome = attempt_with_recovery(&path, &mut error_policy, sink, || match &item { + DeleteItem::ReparseLeaf { .. } => remove_reparse_leaf(fs, &path, is_dir), + DeleteItem::File { .. } | DeleteItem::Dir { .. } => { + if readonly { + let _ = fs.set_readonly(&path, false); + } + if is_dir { + fs.remove_dir(&path) + } else { + fs.remove_file(&path) + } } }); match outcome { @@ -168,7 +209,7 @@ fn run_delete(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { } sink.progress(progress.clone()); } - RetryOutcome::Skip(reason) => skipped.push(SkippedItem { path, reason }), + RetryOutcome::Skip(reason) => skipped.push(SkippedItem::skipped(path, reason)), RetryOutcome::Abort => { cancelled = true; break 'items; @@ -183,14 +224,64 @@ fn run_delete(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { // Copy / Move // --------------------------------------------------------------------- +/// How the source-removal half of a cross-volume move disposes of a planned +/// file once its copy succeeded (move-reparse-point-safety design D1). The +/// copy half is the same for every variant: the destination receives the +/// content read through the path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SourceRemoval { + /// A regular file not beneath a traversed link: clear read-only, then + /// `remove_file`. + RemoveFile, + /// A file-type reparse point (file symlink) not beneath a link: its + /// content was copied by following it; only the link is removed, with + /// [`remove_reparse_leaf`]. + RemoveLink { is_dir: bool }, + /// Anything reached *through* a traversed directory link — the target's + /// own files, nested links, nested directories. Never touched: it was + /// never selected, only reached. + Keep, +} + struct PlannedFile { src_abs: PathBuf, rel_path: PathBuf, size: u64, + removal: SourceRemoval, + /// Index into `CopyPlan::dirs` of the nearest enclosing + /// `DirRemoval::RemoveLink` entry, if the file was planned through a + /// link. Any outcome other than `Done` marks that link incomplete so it + /// is kept (design D2). + enclosing_link: Option, +} + +/// How the move's post-loop cleanup disposes of a planned directory +/// (move-reparse-point-safety design D1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DirRemoval { + /// A real directory not beneath a link: best-effort `remove_dir`, so a + /// directory left non-empty by a skipped file simply survives. + RemoveIfEmpty, + /// A directory-type reparse point: removed with [`remove_reparse_leaf`], + /// and only when every file copied through it succeeded (design D2). + RemoveLink { is_dir: bool }, + /// A directory beneath a traversed link. Still created at the + /// destination (its `rel` is unchanged), never removed at the source. + Keep, +} + +struct PlannedDir { + rel: PathBuf, + /// Recorded during the walk rather than rebuilt as + /// `source_dir.join(rel)`: the link's own path is needed for its + /// removal, and this drops the assumption that a source's + /// `original_name` sits directly in `source_dir`. + src_abs: PathBuf, + removal: DirRemoval, } struct CopyPlan { - dirs: Vec, + dirs: Vec, files: Vec, skipped: Vec, } @@ -200,36 +291,40 @@ fn plan_copy(fs: &dyn FileSystem, sources: &[super::job::SourceItem]) -> CopyPla let mut visited: HashSet = HashSet::new(); for src in sources { let rel_root = PathBuf::from(&src.original_name); - walk_copy(fs, &src.path, &rel_root, &mut visited, &mut plan); + walk_copy(fs, &src.path, &rel_root, None, &mut visited, &mut plan); } plan } -fn walk_copy(fs: &dyn FileSystem, abs: &Path, rel: &Path, visited: &mut HashSet, plan: &mut CopyPlan) { +/// `enclosing_link` is `Some(index into plan.dirs)` while the walk is beneath +/// a traversed directory-type reparse point — the outermost one, since that +/// is the only one the move may remove. Everything planned beneath it is +/// `Keep`. Copy totals and the cycle guard are unchanged by this bookkeeping +/// (move-reparse-point-safety design D1). +fn walk_copy(fs: &dyn FileSystem, abs: &Path, rel: &Path, enclosing_link: Option, visited: &mut HashSet, plan: &mut CopyPlan) { let md = match fs.metadata(abs) { Ok(m) => m, Err(e) => { - plan.skipped.push(SkippedItem { path: abs.to_path_buf(), reason: e.to_string() }); + plan.skipped.push(SkippedItem::skipped(abs.to_path_buf(), e.to_string())); return; } }; + let under_link = enclosing_link.is_some(); + let is_link = md.reparse.is_reparse_point(); // Reparse points duplicate their target's content by default: resolve // whether the target is a directory or a file via the *following* // metadata query. - let is_dir_like = if md.reparse.is_reparse_point() { - fs.metadata_follow(abs).map(|m| m.is_dir).unwrap_or(false) - } else { - md.is_dir - }; + let is_dir_like = if is_link { fs.metadata_follow(abs).map(|m| m.is_dir).unwrap_or(false) } else { md.is_dir }; if !is_dir_like { - let size = if md.reparse.is_reparse_point() { - fs.metadata_follow(abs).map(|m| m.size).unwrap_or(md.size) - } else { - md.size + let size = if is_link { fs.metadata_follow(abs).map(|m| m.size).unwrap_or(md.size) } else { md.size }; + let removal = match (under_link, is_link) { + (true, _) => SourceRemoval::Keep, + (false, true) => SourceRemoval::RemoveLink { is_dir: md.is_dir }, + (false, false) => SourceRemoval::RemoveFile, }; - plan.files.push(PlannedFile { src_abs: abs.to_path_buf(), rel_path: rel.to_path_buf(), size }); + plan.files.push(PlannedFile { src_abs: abs.to_path_buf(), rel_path: rel.to_path_buf(), size, removal, enclosing_link }); return; } @@ -238,22 +333,33 @@ fn walk_copy(fs: &dyn FileSystem, abs: &Path, rel: &Path, visited: &mut HashSet< // the tree already being copied — via the *real* (followed) identity. if let Some(id) = fs.metadata_follow(abs).ok().and_then(|m| m.file_id) { if !visited.insert(id) { - plan.skipped.push(SkippedItem { - path: abs.to_path_buf(), - reason: "skipped: reparse point forms a cycle back into the source tree".to_string(), - }); + plan.skipped.push(SkippedItem::skipped(abs.to_path_buf(), "skipped: reparse point forms a cycle back into the source tree".to_string())); return; } } - plan.dirs.push(rel.to_path_buf()); + // `is_dir` here is the link's *own* directory-ness (a junction or + // `mklink /D` symlink on Windows; never for a Unix symlink), which is + // exactly what decides `remove_dir` versus `remove_file` for the leaf + // (design D4). + let removal = match (under_link, is_link) { + (true, _) => DirRemoval::Keep, + (false, true) => DirRemoval::RemoveLink { is_dir: md.is_dir }, + (false, false) => DirRemoval::RemoveIfEmpty, + }; + let index = plan.dirs.len(); + plan.dirs.push(PlannedDir { rel: rel.to_path_buf(), src_abs: abs.to_path_buf(), removal }); + let child_link = match removal { + DirRemoval::RemoveLink { .. } => Some(index), + _ => enclosing_link, + }; match fs.read_dir(abs) { Ok(entries) => { for entry in entries { - walk_copy(fs, &abs.join(&entry.name), &rel.join(&entry.name), visited, plan); + walk_copy(fs, &abs.join(&entry.name), &rel.join(&entry.name), child_link, visited, plan); } } - Err(e) => plan.skipped.push(SkippedItem { path: abs.to_path_buf(), reason: e.to_string() }), + Err(e) => plan.skipped.push(SkippedItem::skipped(abs.to_path_buf(), e.to_string())), } } @@ -269,13 +375,21 @@ fn copy_one_file( fs: &dyn FileSystem, src: &Path, dst: &Path, + opts: &CopyOptions, error_policy: &mut ErrorPolicy, sink: &mut dyn JobSink, progress: &mut ProgressInfo, bytes_done_baseline: u64, + streams_supported: bool, + streams_not_copied: &mut usize, + warnings: &mut Vec, ) -> CopyOneResult { + // durable-file-copy design D1: choose a temporary name in the target's + // directory, copy into it, rename it over the target, and on *every* + // other path remove it. Retry starts over from a fresh temporary. loop { - let result = fs.copy_file_chunked(src, dst, CHUNK_BYTES, &mut |done| { + let temp = partial_path(fs, dst); + let copied = fs.copy_file(src, &temp, opts, &mut |done| { progress.bytes_done = bytes_done_baseline + done; sink.progress(progress.clone()); if sink.is_cancelled() { @@ -284,23 +398,19 @@ fn copy_one_file( ChunkControl::Continue } }); - match result { - Ok(bytes) => { - for name in fs.list_streams(src).unwrap_or_default() { - let _ = fs.copy_stream_chunked(src, &name, dst, CHUNK_BYTES); - } - if let Ok(md) = fs.metadata_follow(src) { - if let Some(modified) = md.modified { - let _ = fs.set_modified(dst, modified); - } - if md.readonly { - let _ = fs.set_readonly(dst, true); - } - } - return CopyOneResult::Done(bytes); + // A rename failure after a complete copy is a failure of this file's + // copy like any other: the target is untouched, the dialog is shown. + let landed: io::Result = copied.and_then(|report| fs.rename(&temp, dst).map(|()| report)); + match landed { + Ok(report) => { + record_unrestored(dst, report.unrestored, streams_supported, streams_not_copied, warnings); + return CopyOneResult::Done(report.bytes); } - Err(e) if is_cancelled(&e) => return CopyOneResult::Cancelled, Err(e) => { + remove_partial(fs, &temp); + if is_cancelled(&e) { + return CopyOneResult::Cancelled; + } let msg = e.to_string(); let choice = match error_policy.resolve(ErrorInfo { path: src.to_path_buf(), message: msg.clone() }) { ErrorResolution::Auto(c) => c, @@ -317,10 +427,122 @@ fn copy_one_file( } } +/// The temporary file a copy of `final_target` lands through: the first free +/// name among `.~fc-partial`, `.~fc-partial-2`, `-3`, … in the +/// target's own directory (so the final step is a same-volume rename), with +/// `` shortened if the component would exceed the filename limit. "Free" +/// is decided by a metadata probe; the copy then creates the file +/// *exclusively*, so a name taken in between is refused rather than +/// overwritten, and an existing file with the temporary name is never +/// modified or removed to make room (durable-file-copy design D1). +fn partial_path(fs: &dyn FileSystem, final_target: &Path) -> PathBuf { + let dir = final_target.parent().unwrap_or(Path::new("")); + let base = final_target.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let candidate = |attempt: u32| { + let suffix = if attempt == 1 { PARTIAL_SUFFIX.to_string() } else { format!("{PARTIAL_SUFFIX}-{attempt}") }; + dir.join(fit_component(&base, &suffix)) + }; + for attempt in 1..=PARTIAL_NAME_ATTEMPTS { + let path = candidate(attempt); + if fs.metadata(&path).is_err() { + return path; + } + } + // Every variant is taken: hand back the last one and let the exclusive + // create refuse it, which surfaces through the error dialog. + candidate(PARTIAL_NAME_ATTEMPTS) +} + +/// ``, with `base` shortened (whole characters) until the +/// component fits [`MAX_COMPONENT_UTF16`]. The temporary name never needs to +/// round-trip, so a lossy shortening is fine. +fn fit_component(base: &str, suffix: &str) -> String { + let suffix_units = suffix.encode_utf16().count(); + let mut base = base.to_string(); + while !base.is_empty() && base.encode_utf16().count() + suffix_units > MAX_COMPONENT_UTF16 { + base.pop(); + } + format!("{base}{suffix}") +} + +/// Best-effort removal of a temporary file on a non-success path. The copy +/// may already have given it the source's read-only attribute, and +/// `CopyFileExW` may already have deleted it on cancel (`NotFound` is fine). +fn remove_partial(fs: &dyn FileSystem, temp: &Path) { + let _ = fs.set_readonly(temp, false); + let _ = fs.remove_file(temp); +} + +/// Turn the metadata a copy could not carry over into [`SkippedItem`] +/// *warnings* against the destination path — the file itself still counts +/// as copied (error-diagnostics design D5; operation-dialogs +/// "Metadata-restore failures are reported in the end-of-job summary"). +/// +/// When the destination volume has no named-stream support at all, a file's +/// missing streams are not reported one by one: the file is counted in +/// `streams_not_copied` instead, so the job reports one aggregated warning +/// rather than one per file. +fn record_unrestored( + dst: &Path, + unrestored: Vec, + streams_supported: bool, + streams_not_copied: &mut usize, + warnings: &mut Vec, +) { + let mut streams_missing = false; + for item in unrestored { + match item.what { + UnrestoredKind::Stream(_) if !streams_supported => streams_missing = true, + _ => warnings.push(SkippedItem::warning(dst.to_path_buf(), item.reason())), + } + } + if streams_missing { + *streams_not_copied += 1; + } +} + fn identity_matches(fs: &dyn FileSystem, a: &Path, b_id: Option) -> bool { matches!((fs.metadata_follow(a).ok().and_then(|m| m.file_id), b_id), (Some(x), Some(y)) if x == y) } +/// Whether `target` resolves to the same file as `src` (file-operations "A +/// copy never overwrites its own source"). Unlike [`identity_matches`], the +/// target's identity is *followed*, so a file symlink pointing at the source +/// also counts. When either identity is unknown, the decision falls back to +/// comparing the lexically-normalized paths. A target that does not exist is +/// never the same file. +fn same_file(fs: &dyn FileSystem, src: &Path, target: &Path) -> bool { + let src_id = fs.metadata_follow(src).ok().and_then(|m| m.file_id); + let target_id = match fs.metadata_follow(target) { + Ok(md) => md.file_id, + Err(_) => return false, + }; + match (src_id, target_id) { + (Some(a), Some(b)) => a == b, + _ => normalized_paths_equal(src, target), + } +} + +/// [`same_file`]'s fallback when a file identity cannot be read: the two +/// paths are equal after lexical normalization, compared case-insensitively +/// on Windows (where a case-insensitive volume is the norm) and exactly +/// elsewhere. Meaningful only for absolute paths; a relative path simply +/// does not match, which is no worse than no fallback at all. +fn normalized_paths_equal(a: &Path, b: &Path) -> bool { + let a = lexically_normalize(a); + let b = lexically_normalize(b); + #[cfg(windows)] + { + let a = a.as_os_str().to_string_lossy().to_lowercase(); + let b = b.as_os_str().to_string_lossy().to_lowercase(); + a == b + } + #[cfg(not(windows))] + { + a == b + } +} + fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_move: bool) { if is_move && !job.sources.is_empty() && job.sources.iter().all(|s| fs.same_volume(&s.path, &job.dest_dir).unwrap_or(false)) { run_move_same_volume(job, fs, sink); @@ -337,13 +559,31 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m let mut error_policy = ErrorPolicy::new(); let mut skipped = plan.skipped; let mut cancelled = false; - - 'dirs: for rel_dir in &plan.dirs { + // Asked once per job (error-diagnostics design D5): a destination + // volume without named-stream support (FAT32/exFAT) would otherwise + // produce one stream warning per file. An error answers "supported", so + // genuine per-file failures still surface individually. The aggregate + // count is reported as a single job-level warning below. + let streams_supported = fs.supports_named_streams(&job.dest_dir).unwrap_or(true); + let mut streams_not_copied = 0usize; + // A move is about to delete the only other copy, so each file is flushed + // to stable storage and length-checked before its source is touched; a + // plain copy skips the flush but keeps the length check + // (durable-file-copy design D4). + let copy_opts = CopyOptions { chunk_bytes: CHUNK_BYTES, flush: is_move }; + // Per planned directory: whether any file copied through that link + // ended in anything other than `Done`. A marked link is kept, the same + // way a real directory survives a skipped file (move-reparse-point- + // safety design D2). Only `DirRemoval::RemoveLink` entries are ever + // read. + let mut link_incomplete = vec![false; plan.dirs.len()]; + + 'dirs: for planned_dir in &plan.dirs { if sink.is_cancelled() { cancelled = true; break; } - let target = job.dest_dir.join(rel_dir); + let target = job.dest_dir.join(&planned_dir.rel); let outcome = attempt_with_recovery(&target, &mut error_policy, sink, || match fs.create_dir(&target) { Ok(()) => Ok(()), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(()), @@ -351,7 +591,7 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m }); match outcome { RetryOutcome::Done => {} - RetryOutcome::Skip(reason) => skipped.push(SkippedItem { path: target, reason }), + RetryOutcome::Skip(reason) => skipped.push(SkippedItem::skipped(target, reason)), RetryOutcome::Abort => { cancelled = true; break 'dirs; @@ -368,6 +608,21 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m let target = job.dest_dir.join(&planned.rel_path); progress.current_file = planned.rel_path.file_name().unwrap_or_default().to_os_string(); + // A copy never writes to its own source: creating the target + // would truncate the source before the first byte is read, and + // the copy half of a move would then delete it. Unlike + // `run_move_same_volume`/`run_rename`, where identity-equality + // means "no conflict" so case-only renames pass, here the same + // file is skipped outright — nothing is written, no conflict is + // raised, and a move's source removal is never reached. + if same_file(fs, &planned.src_abs, &target) { + skipped.push(SkippedItem::skipped(planned.src_abs.clone(), "source and destination are the same file".to_string())); + if let Some(link) = planned.enclosing_link { + link_incomplete[link] = true; + } + continue 'files; + } + let conflict = match fs.metadata(&target) { Ok(target_md) if !identity_matches(fs, &planned.src_abs, target_md.file_id) => Some(ConflictInfo { source_name: planned.rel_path.file_name().unwrap_or_default().to_os_string(), @@ -390,28 +645,97 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m conflict_policy.apply(&choice); match choice { ConflictChoice::Overwrite | ConflictChoice::OverwriteAll => { + // A rename cannot replace a read-only target, so the + // bit is cleared up front; the target is otherwise + // untouched until the finished copy is renamed over + // it, so a failed overwrite leaves it intact + // (durable-file-copy design D7). let _ = fs.set_readonly(&target, false); } ConflictChoice::Skip | ConflictChoice::SkipAll => { - skipped.push(SkippedItem { path: target, reason: "target already exists".to_string() }); + skipped.push(SkippedItem::skipped(target, "target already exists".to_string())); do_copy = false; + if let Some(link) = planned.enclosing_link { + link_incomplete[link] = true; + } } ConflictChoice::Rename(new_name) => { final_target = target.parent().unwrap_or(&job.dest_dir).join(new_name); } + // Abort from the prompt: the conflicting target is left + // as it is and not recorded as skipped, nothing after it + // is processed (operation-dialogs "Overwrite-conflict + // dialog"; process-lifecycle-hardening design D7). + ConflictChoice::Cancel => { + cancelled = true; + break 'files; + } } } if do_copy { let baseline = progress.bytes_done; - match copy_one_file(fs, &planned.src_abs, &final_target, &mut error_policy, sink, &mut progress, baseline) { + match copy_one_file( + fs, + &planned.src_abs, + &final_target, + ©_opts, + &mut error_policy, + sink, + &mut progress, + baseline, + streams_supported, + &mut streams_not_copied, + &mut skipped, + ) { CopyOneResult::Done(bytes) => { progress.files_done += 1; progress.bytes_done = baseline + bytes; sink.progress(progress.clone()); if is_move { - let _ = fs.set_readonly(&planned.src_abs, false); - let _ = fs.remove_file(&planned.src_abs); + match planned.removal { + // The verified copy is in place; a source + // that cannot be removed goes through the + // error-recovery dialog and, on Skip, into + // the summary as copied but not removed — + // never ignored (durable-file-copy design D5; + // file-operations "A source that cannot be + // removed is reported"). + SourceRemoval::RemoveFile => { + let src = &planned.src_abs; + let outcome = attempt_with_recovery(src, &mut error_policy, sink, || { + let _ = fs.set_readonly(src, false); + fs.remove_file(src) + }); + match outcome { + RetryOutcome::Done => {} + RetryOutcome::Skip(reason) => { + skipped.push(SkippedItem::skipped( + src.clone(), + format!("copied, but the source could not be removed: {reason}"), + )); + } + RetryOutcome::Abort => { + cancelled = true; + break 'files; + } + } + } + // A file symlink: the copy followed it; only + // the link goes, and a failure is reported + // rather than ignored (design D3). + SourceRemoval::RemoveLink { is_dir } => { + if let Err(e) = remove_reparse_leaf(fs, &planned.src_abs, is_dir) { + skipped.push(SkippedItem::skipped( + planned.src_abs.clone(), + format!("copied, but the link could not be removed: {e}"), + )); + } + } + // Reached through a link: never selected, never + // removed (R-01). + SourceRemoval::Keep => {} + } } } CopyOneResult::Cancelled | CopyOneResult::Aborted => { @@ -420,7 +744,10 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m } CopyOneResult::Failed(reason) => { progress.bytes_done = baseline; - skipped.push(SkippedItem { path: planned.src_abs.clone(), reason }); + skipped.push(SkippedItem::skipped(planned.src_abs.clone(), reason)); + if let Some(link) = planned.enclosing_link { + link_incomplete[link] = true; + } } } } @@ -428,13 +755,45 @@ fn run_copy_or_move(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink, is_m } if is_move && !cancelled { - // Clean up now-empty source directories, deepest first. Best-effort: - // a directory left non-empty by a skipped file simply survives. - for rel_dir in plan.dirs.iter().rev() { - let _ = fs.remove_dir(&job.source_dir.join(rel_dir)); + // Clean up the source, deepest first, on each entry's recorded path + // (move-reparse-point-safety design D1-D3). + for (index, planned_dir) in plan.dirs.iter().enumerate().rev() { + match planned_dir.removal { + // Best-effort: a directory left non-empty by a skipped file + // simply survives, and that is an expected outcome. + DirRemoval::RemoveIfEmpty => { + let _ = fs.remove_dir(&planned_dir.src_abs); + } + // Beneath a link: the target's own directories stay. + DirRemoval::Keep => {} + // The link itself goes only once everything copied through + // it succeeded, so the user is never left with the link + // gone and a partial copy at the destination. A failed + // removal is not an expected outcome, so it is reported. + DirRemoval::RemoveLink { is_dir } => { + if link_incomplete[index] { + continue; + } + if let Err(e) = remove_reparse_leaf(fs, &planned_dir.src_abs, is_dir) { + skipped.push(SkippedItem::skipped(planned_dir.src_abs.clone(), format!("copied, but the link could not be removed: {e}"))); + } + } + } } } + // One aggregated warning when the destination volume cannot hold named + // streams at all, rather than one warning per file (error-diagnostics + // design D5; operation-dialogs "Metadata-restore failures are reported + // in the end-of-job summary": "A destination without stream support + // yields one warning"). + if streams_not_copied > 0 { + skipped.push(SkippedItem::warning( + job.dest_dir.clone(), + format!("alternate data streams not supported; streams of {streams_not_copied} file(s) not copied"), + )); + } + sink.done(if cancelled { JobOutcome::Cancelled { skipped } } else { JobOutcome::Completed { skipped } }); } @@ -480,10 +839,14 @@ fn run_move_same_volume(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) let _ = if target_md.is_dir { fs.remove_dir(&target) } else { fs.remove_file(&target) }; } ConflictChoice::Skip | ConflictChoice::SkipAll => { - skipped.push(SkippedItem { path: target, reason: "target already exists".to_string() }); + skipped.push(SkippedItem::skipped(target, "target already exists".to_string())); continue 'items; } ConflictChoice::Rename(new_name) => final_target = job.dest_dir.join(new_name), + ConflictChoice::Cancel => { + cancelled = true; + break 'items; + } } } } @@ -494,7 +857,7 @@ fn run_move_same_volume(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) progress.files_done += 1; sink.progress(progress.clone()); } - RetryOutcome::Skip(reason) => skipped.push(SkippedItem { path: src.path.clone(), reason }), + RetryOutcome::Skip(reason) => skipped.push(SkippedItem::skipped(src.path.clone(), reason)), RetryOutcome::Abort => { cancelled = true; break 'items; @@ -509,17 +872,19 @@ fn run_move_same_volume(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) // Rename (file-action-menu "In-place Rename") // --------------------------------------------------------------------- -/// In-place rename of the job's single source within `source_dir` (equal to -/// `dest_dir`, per the `Job` doc). Mirrors [`run_move_same_volume`]'s -/// identity-aware conflict check — a case-only rename onto the same -/// underlying file is never treated as "target already exists" — but always -/// targets `new_dir_name` rather than the source's own original name, and -/// never falls back to a copy-then-delete cross-volume path since a rename -/// never leaves its own directory. Collisions and errors surface through the -/// same `sink.conflict`/`sink.error` calls every other job uses, so the -/// existing `operation-dialogs` conflict/error dialogs render unchanged -/// (file-action-menu "Rename collisions/failures must surface through the -/// existing overwrite-conflict and error-recovery dialogs"). +/// In-place rename of the job's single source to `dest_dir` joined with +/// `new_dir_name` — `dest_dir` is the resolved parent of the new name, equal +/// to `source_dir` for a plain name, per the `Job` doc. Mirrors +/// [`run_move_same_volume`]'s identity-aware conflict check — a case-only +/// rename onto the same underlying file is never treated as "target already +/// exists" — but always targets `new_dir_name` rather than the source's own +/// original name, and never falls back to a copy-then-delete cross-volume +/// path since a rename never leaves its own directory. Collisions and errors +/// surface through the same `sink.conflict`/`sink.error` calls every other +/// job uses, so the existing `operation-dialogs` conflict/error dialogs +/// render unchanged (file-action-menu "Rename collisions/failures must +/// surface through the existing overwrite-conflict and error-recovery +/// dialogs"). fn run_rename(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { let mut progress = ProgressInfo::starting(1, 0); sink.progress(progress.clone()); @@ -553,11 +918,15 @@ fn run_rename(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { let _ = if target_md.is_dir { fs.remove_dir(&target) } else { fs.remove_file(&target) }; } ConflictChoice::Skip | ConflictChoice::SkipAll => { - skipped.push(SkippedItem { path: target, reason: "target already exists".to_string() }); + skipped.push(SkippedItem::skipped(target, "target already exists".to_string())); sink.done(JobOutcome::Completed { skipped }); return; } ConflictChoice::Rename(renamed_to) => final_target = job.dest_dir.join(renamed_to), + ConflictChoice::Cancel => { + sink.done(JobOutcome::Cancelled { skipped }); + return; + } } } } @@ -571,7 +940,7 @@ fn run_rename(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { JobOutcome::Completed { skipped } } RetryOutcome::Skip(reason) => { - skipped.push(SkippedItem { path: src.path.clone(), reason }); + skipped.push(SkippedItem::skipped(src.path.clone(), reason)); JobOutcome::Completed { skipped } } RetryOutcome::Abort => JobOutcome::Cancelled { skipped }, @@ -582,10 +951,12 @@ fn run_rename(job: &Job, fs: &dyn FileSystem, sink: &mut dyn JobSink) { #[cfg(test)] mod tests { use super::*; - use crate::fs_ops::fs::FakeFs; + use crate::fs_ops::error::ItemKind; + use crate::fs_ops::fs::{FakeFs, FsMetadata, InjectableOp, RawEntry}; use crate::fs_ops::job::{CancelFlag, SourceItem}; use std::ffi::OsString; use std::sync::{Arc, Mutex}; + use std::time::SystemTime; /// A [`JobSink`] test double that records every event and auto-resolves /// conflicts/errors from a scripted queue (defaulting to Overwrite/Skip @@ -596,26 +967,46 @@ mod tests { pub error_answers: Vec, pub cancel: CancelFlag, pub outcome: Option, + pub conflicts_seen: usize, + pub errors_seen: Vec, + /// Raise the cancel flag when the n-th progress event (1-based) + /// arrives — the job's opening `starting` report is event 1, so the + /// first in-copy chunk report is event 2. + pub cancel_on_progress_event: Option, } impl RecordingSink { fn new() -> Self { - RecordingSink { events: vec![], conflict_answers: vec![], error_answers: vec![], cancel: CancelFlag::new(), outcome: None } + RecordingSink { + events: vec![], + conflict_answers: vec![], + error_answers: vec![], + cancel: CancelFlag::new(), + outcome: None, + conflicts_seen: 0, + errors_seen: vec![], + cancel_on_progress_event: None, + } } } impl JobSink for RecordingSink { fn progress(&mut self, info: ProgressInfo) { self.events.push(format!("progress:{}/{}", info.files_done, info.files_total)); + if self.cancel_on_progress_event == Some(self.events.len()) { + self.cancel.cancel(); + } } fn conflict(&mut self, _info: ConflictInfo) -> ConflictChoice { + self.conflicts_seen += 1; if self.conflict_answers.is_empty() { ConflictChoice::Overwrite } else { self.conflict_answers.remove(0) } } - fn error(&mut self, _info: ErrorInfo) -> ErrorChoice { + fn error(&mut self, info: ErrorInfo) -> ErrorChoice { + self.errors_seen.push(info); if self.error_answers.is_empty() { ErrorChoice::Skip } else { @@ -848,6 +1239,501 @@ mod tests { assert!(fake.metadata(Path::new("/dst/src/a.txt")).is_ok()); } + // ------------------------------------------------------------- + // move-reparse-point-safety (file-operations "Reparse-point + // (symlink/junction) semantics": the cross-volume move's + // source-removal half treats every link as a leaf) + // ------------------------------------------------------------- + + /// Volumes `/v1` and `/v2`; `/v1/work/proj/file.txt`; `/v1/shared/lib.dll` + /// and `/v1/shared/x86/a.dll`; junction `/v1/work/proj/shared` → `/v1/shared`; + /// empty `/v2/backup`. + fn junction_in_tree_fixture() -> FakeFs { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_dir(Path::new("/v1/work")); + fake.add_dir(Path::new("/v1/work/proj")); + fake.add_file(Path::new("/v1/work/proj/file.txt"), 3); + fake.add_dir(Path::new("/v1/shared")); + fake.add_file(Path::new("/v1/shared/lib.dll"), 10); + fake.add_dir(Path::new("/v1/shared/x86")); + fake.add_file(Path::new("/v1/shared/x86/a.dll"), 20); + fake.add_reparse_point(Path::new("/v1/work/proj/shared"), true, Path::new("/v1/shared"), super::super::fs::ReparseKind::Junction); + fake.add_dir(Path::new("/v2/backup")); + fake + } + + fn move_proj_to_backup() -> Job { + Job { + kind: JobKind::Move, + sources: vec![source("proj", "/v1/work/proj")], + source_dir: PathBuf::from("/v1/work"), + dest_dir: PathBuf::from("/v2/backup"), + new_dir_name: None, + } + } + + fn assert_shared_target_intact(fake: &FakeFs) { + assert_eq!(fake.metadata(Path::new("/v1/shared/lib.dll")).unwrap().size, 10, "R-01: the junction target's file must survive the move"); + assert_eq!(fake.metadata(Path::new("/v1/shared/x86/a.dll")).unwrap().size, 20, "R-01: the target's subdirectory file must survive"); + assert!(fake.metadata(Path::new("/v1/shared/x86")).unwrap().is_dir, "R-01: the target's subdirectory must survive"); + assert!(fake.metadata(Path::new("/v1/shared")).unwrap().is_dir); + } + + /// Audit finding R-01: "Cross-volume move of a tree containing a + /// junction to an outside directory leaves that directory's files + /// intact". + #[test] + fn move_cross_volume_through_junction_leaves_target_files_intact() { + let fake = junction_in_tree_fixture(); + let mut sink = RecordingSink::new(); + run_job(&move_proj_to_backup(), &fake, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_shared_target_intact(&fake); + assert_eq!(fake.metadata(Path::new("/v2/backup/proj/file.txt")).unwrap().size, 3); + assert_eq!( + fake.metadata(Path::new("/v2/backup/proj/shared/lib.dll")).unwrap().size, + 10, + "the destination receives the link target's content" + ); + assert_eq!(fake.metadata(Path::new("/v2/backup/proj/shared/x86/a.dll")).unwrap().size, 20); + assert!(fake.metadata(Path::new("/v1/work/proj/shared")).is_err(), "the junction itself is removed"); + assert!(fake.metadata(Path::new("/v1/work/proj")).is_err(), "the source tree is gone"); + } + + /// "Cross-volume move of a selected junction removes only the link". + #[test] + fn move_cross_volume_of_selected_junction_removes_only_the_link() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_dir(Path::new("/v1/data")); + fake.add_file(Path::new("/v1/data/a.txt"), 5); + fake.add_dir(Path::new("/v1/data/sub")); + fake.add_file(Path::new("/v1/data/sub/b.txt"), 6); + fake.add_dir(Path::new("/v1/work")); + fake.add_reparse_point(Path::new("/v1/work/link"), true, Path::new("/v1/data"), super::super::fs::ReparseKind::Junction); + fake.add_dir(Path::new("/v2/backup")); + + let job = Job { + kind: JobKind::Move, + sources: vec![source("link", "/v1/work/link")], + source_dir: PathBuf::from("/v1/work"), + dest_dir: PathBuf::from("/v2/backup"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_eq!(fake.metadata(Path::new("/v2/backup/link/a.txt")).unwrap().size, 5, "the destination receives the target's content"); + assert_eq!(fake.metadata(Path::new("/v2/backup/link/sub/b.txt")).unwrap().size, 6); + assert!(fake.metadata(Path::new("/v1/work/link")).is_err(), "the junction is removed"); + assert_eq!(fake.metadata(Path::new("/v1/data/a.txt")).unwrap().size, 5, "the target's files are untouched"); + assert_eq!(fake.metadata(Path::new("/v1/data/sub/b.txt")).unwrap().size, 6); + assert!(fake.metadata(Path::new("/v1/data/sub")).unwrap().is_dir); + } + + /// "A link whose content was not fully copied is kept". + #[test] + fn move_cross_volume_keeps_the_junction_when_a_file_beneath_it_was_skipped() { + let fake = junction_in_tree_fixture(); + fake.inject(InjectableOp::CopyAfterChunks(0), Path::new("/v2/backup/proj/shared/lib.dll"), io::ErrorKind::StorageFull); + let mut sink = RecordingSink::new(); + sink.error_answers.push(ErrorChoice::Skip); + run_job(&move_proj_to_backup(), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "exactly the skipped file is listed: {skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from("/v1/work/proj/shared/lib.dll")); + } + other => panic!("expected Completed with one skipped item, got {other:?}"), + } + assert_shared_target_intact(&fake); + assert!(fake.metadata(Path::new("/v1/work/proj/shared")).unwrap().reparse.is_reparse_point(), "the junction survives an incomplete copy"); + assert!(fake.metadata(Path::new("/v1/work/proj")).is_ok(), "the directory holding the kept link survives"); + assert!(fake.metadata(Path::new("/v1/work/proj/file.txt")).is_err(), "the regular file was moved"); + assert_eq!(fake.metadata(Path::new("/v2/backup/proj/shared/x86/a.dll")).unwrap().size, 20, "the rest of the link's content was copied"); + assert!(fake.metadata(Path::new("/v2/backup/proj/shared/lib.dll")).is_err()); + } + + /// Design D3: a file symlink is copied by following it, and only the link + /// is removed — the target file and its read-only attribute are never + /// touched (`set_readonly` would follow the link). + #[test] + fn move_cross_volume_of_a_file_symlink_removes_only_the_link() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_dir(Path::new("/v1/data")); + fake.add_file(Path::new("/v1/data/real.txt"), 42); + fake.set_readonly(Path::new("/v1/data/real.txt"), true).unwrap(); + fake.add_dir(Path::new("/v1/work")); + fake.add_reparse_point(Path::new("/v1/work/link.txt"), false, Path::new("/v1/data/real.txt"), super::super::fs::ReparseKind::Symlink); + fake.add_dir(Path::new("/v2/backup")); + + let job = Job { + kind: JobKind::Move, + sources: vec![source("link.txt", "/v1/work/link.txt")], + source_dir: PathBuf::from("/v1/work"), + dest_dir: PathBuf::from("/v2/backup"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_eq!(fake.metadata(Path::new("/v2/backup/link.txt")).unwrap().size, 42, "the destination receives the target's content"); + assert!(fake.metadata(Path::new("/v1/work/link.txt")).is_err(), "the link is removed"); + let target = fake.metadata(Path::new("/v1/data/real.txt")).unwrap(); + assert_eq!(target.size, 42, "the target file survives"); + assert!(target.readonly, "the target's read-only attribute is untouched"); + } + + /// Design D3: a link that cannot be removed is reported in the summary, + /// not silently ignored like a non-empty directory. + #[test] + fn move_cross_volume_reports_a_junction_that_could_not_be_removed() { + let fake = junction_in_tree_fixture(); + fake.inject(InjectableOp::RemoveDir, Path::new("/v1/work/proj/shared"), io::ErrorKind::PermissionDenied); + let mut sink = RecordingSink::new(); + run_job(&move_proj_to_backup(), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from("/v1/work/proj/shared")); + assert!(skipped[0].reason.starts_with("copied, but the link could not be removed: "), "{}", skipped[0].reason); + } + other => panic!("expected Completed with one skipped item, got {other:?}"), + } + assert_shared_target_intact(&fake); + assert!(fake.metadata(Path::new("/v1/work/proj/shared")).is_ok(), "the link is still there"); + assert_eq!(fake.metadata(Path::new("/v2/backup/proj/shared/lib.dll")).unwrap().size, 10, "its content was copied"); + } + + // ------------------------------------------------------------- + // durable-file-copy (file-operations "Copies land via a temporary + // partial file", "Same-volume move is a rename; cross-volume move is + // copy-then-verified-delete") + // ------------------------------------------------------------- + + /// Delegates every [`FileSystem`] call to the inner filesystem and logs + /// the destination of each `copy_file`, each `rename` and each + /// `remove_file`, so a test can see which temporary name a copy used and + /// in what order a move landed its copy and removed its source. + struct Recording { + inner: F, + ops: Mutex>, + } + + /// Paths are compared as `PathBuf`s, so a `/dst/x` joined on Windows + /// (`/dst\x`) still equals the test's expectation. + #[derive(Debug, Clone, PartialEq, Eq)] + enum Op { + CopyTo(PathBuf), + Rename(PathBuf, PathBuf), + RemoveFile(PathBuf), + } + + impl Recording { + fn new(inner: F) -> Self { + Recording { inner, ops: Mutex::new(vec![]) } + } + fn ops(&self) -> Vec { + self.ops.lock().unwrap().clone() + } + fn log(&self, op: Op) { + self.ops.lock().unwrap().push(op); + } + } + + impl FileSystem for Recording { + fn metadata(&self, path: &Path) -> io::Result { + self.inner.metadata(path) + } + fn metadata_follow(&self, path: &Path) -> io::Result { + self.inner.metadata_follow(path) + } + fn read_dir(&self, path: &Path) -> io::Result> { + self.inner.read_dir(path) + } + fn create_dir(&self, path: &Path) -> io::Result<()> { + self.inner.create_dir(path) + } + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + self.log(Op::CopyTo(to.to_path_buf())); + self.inner.copy_file(from, to, opts, on_progress) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.log(Op::Rename(from.to_path_buf(), to.to_path_buf())); + self.inner.rename(from, to) + } + fn remove_file(&self, path: &Path) -> io::Result<()> { + self.log(Op::RemoveFile(path.to_path_buf())); + self.inner.remove_file(path) + } + fn remove_dir(&self, path: &Path) -> io::Result<()> { + self.inner.remove_dir(path) + } + fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()> { + self.inner.set_readonly(path, readonly) + } + fn supports_named_streams(&self, path: &Path) -> io::Result { + self.inner.supports_named_streams(path) + } + fn same_volume(&self, a: &Path, b: &Path) -> io::Result { + self.inner.same_volume(a, b) + } + } + + fn partial(target: &str) -> PathBuf { + PathBuf::from(format!("{target}{PARTIAL_SUFFIX}")) + } + + fn assert_no_partials(fake: &FakeFs, dir: &str) { + let leftovers: Vec = + fake.read_dir(Path::new(dir)).unwrap().into_iter().map(|e| e.name).filter(|n| n.to_string_lossy().contains(PARTIAL_SUFFIX)).collect(); + assert!(leftovers.is_empty(), "no temporary partial file may remain in {dir}: {leftovers:?}"); + } + + /// "A successful copy appears under the target name with no partial file + /// left" — and it got there through the temporary name. + #[test] + fn copy_lands_through_a_partial_file_and_leaves_only_the_target() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/report.txt"), 30); + fake.add_dir(Path::new("/dst")); + let fs = Recording::new(fake); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("report.txt"), &fs, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_eq!(fs.inner.metadata(Path::new("/dst/report.txt")).unwrap().size, 30); + assert_no_partials(&fs.inner, "/dst"); + assert_eq!( + fs.ops(), + vec![Op::CopyTo(partial("/dst/report.txt")), Op::Rename(partial("/dst/report.txt"), PathBuf::from("/dst/report.txt"))], + "copied into the temporary, then renamed over the target" + ); + } + + /// "A failed overwrite keeps the existing target". + #[test] + fn failed_overwrite_keeps_the_existing_target_and_no_partial() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/report.txt"), 30); + fake.add_dir(Path::new("/dst")); + fake.add_file(Path::new("/dst/report.txt"), 99); + let old_modified = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_600_000_000); + fake.set_modified(Path::new("/dst/report.txt"), old_modified).unwrap(); + fake.inject(InjectableOp::CopyAfterChunks(0), Path::new("/dst/report.txt"), io::ErrorKind::StorageFull); + + let mut sink = RecordingSink::new(); + sink.conflict_answers.push(ConflictChoice::Overwrite); + sink.error_answers.push(ErrorChoice::Skip); + run_job(©_job_of("report.txt"), &fake, &mut sink); + + assert_eq!(sink.conflicts_seen, 1); + assert_eq!(sink.errors_seen.len(), 1, "the disk-full error reached the dialog"); + let target = fake.metadata(Path::new("/dst/report.txt")).unwrap(); + assert_eq!(target.size, 99, "the previous, complete content survives"); + assert_eq!(target.modified, Some(old_modified), "the target was never rewritten"); + assert_no_partials(&fake, "/dst"); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from("/src/report.txt")); + } + other => panic!("expected Completed with one skip, got {other:?}"), + } + } + + /// "Skip after a failed copy leaves nothing behind". + #[test] + fn skip_after_a_failed_copy_leaves_no_target_and_no_partial() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/big.iso"), 600_000); + fake.add_dir(Path::new("/dst")); + fake.inject(InjectableOp::CopyAfterChunks(1), Path::new("/dst/big.iso"), io::ErrorKind::StorageFull); + + let mut sink = RecordingSink::new(); + sink.error_answers.push(ErrorChoice::Skip); + run_job(©_job_of("big.iso"), &fake, &mut sink); + + assert!(fake.metadata(Path::new("/dst/big.iso")).is_err(), "no file under the target name"); + assert_no_partials(&fake, "/dst"); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from("/src/big.iso")); + } + other => panic!("expected Completed with one skip, got {other:?}"), + } + } + + /// "Cancel removes the partial file". + #[test] + fn cancel_mid_copy_removes_the_partial_file() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/big.iso"), 3 * CHUNK_BYTES as u64); + fake.add_dir(Path::new("/dst")); + + let mut sink = RecordingSink::new(); + // Event 1 is the job's `starting` report; event 2 is the first chunk. + sink.cancel_on_progress_event = Some(2); + run_job(©_job_of("big.iso"), &fake, &mut sink); + + assert!(matches!(sink.outcome, Some(JobOutcome::Cancelled { .. })), "{:?}", sink.outcome); + assert!(fake.metadata(Path::new("/dst/big.iso")).is_err()); + assert_no_partials(&fake, "/dst"); + } + + /// "An existing file with the temporary name is left alone". + #[test] + fn an_existing_file_with_the_temporary_name_is_left_alone() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/report.txt"), 30); + fake.add_dir(Path::new("/dst")); + let leftover = partial("/dst/report.txt"); + fake.add_file(&leftover, 7); + let leftover_modified = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_500_000_000); + fake.set_modified(&leftover, leftover_modified).unwrap(); + let fs = Recording::new(fake); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("report.txt"), &fs, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_eq!(fs.inner.metadata(Path::new("/dst/report.txt")).unwrap().size, 30, "the copy landed under the target name"); + let kept = fs.inner.metadata(&leftover).unwrap(); + assert_eq!((kept.size, kept.modified), (7, Some(leftover_modified)), "the existing file is neither modified nor removed"); + let dash_two = PathBuf::from(format!("/dst/report.txt{PARTIAL_SUFFIX}-2")); + assert_eq!(fs.ops()[0], Op::CopyTo(dash_two.clone()), "the copy used the -2 variant: {:?}", fs.ops()); + assert!(fs.inner.metadata(&dash_two).is_err(), "the -2 temporary was renamed away"); + } + + #[test] + fn partial_name_is_shortened_to_fit_the_component_limit() { + let base = "x".repeat(300); + let name = fit_component(&base, PARTIAL_SUFFIX); + assert_eq!(name.encode_utf16().count(), MAX_COMPONENT_UTF16); + assert!(name.ends_with(PARTIAL_SUFFIX)); + assert_eq!(fit_component("short.txt", PARTIAL_SUFFIX), format!("short.txt{PARTIAL_SUFFIX}")); + } + + fn cross_volume_move_of(name: &str) -> Job { + Job { + kind: JobKind::Move, + sources: vec![source(name, &format!("/v1/{name}"))], + source_dir: PathBuf::from("/v1"), + dest_dir: PathBuf::from("/v2"), + new_dir_name: None, + } + } + + /// "A flush failure keeps the source". + #[test] + fn move_cross_volume_flush_failure_keeps_the_source_and_leaves_no_partial() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_file(Path::new("/v1/a.txt"), 100); + fake.add_dir(Path::new("/v2")); + fake.inject(InjectableOp::Flush, Path::new("/v2/a.txt"), io::ErrorKind::Other); + + let mut sink = RecordingSink::new(); + sink.error_answers.push(ErrorChoice::Skip); + run_job(&cross_volume_move_of("a.txt"), &fake, &mut sink); + + assert_eq!(sink.errors_seen.len(), 1, "the flush failure reached the error dialog"); + assert_eq!(sink.errors_seen[0].path, PathBuf::from("/v1/a.txt")); + assert_eq!(fake.metadata(Path::new("/v1/a.txt")).unwrap().size, 100, "the source survives"); + assert!(fake.metadata(Path::new("/v2/a.txt")).is_err(), "nothing under the target name"); + assert_no_partials(&fake, "/v2"); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => assert_eq!(skipped.len(), 1, "{skipped:?}"), + other => panic!("expected Completed with one skip, got {other:?}"), + } + } + + /// A plain copy does not flush, so the same injection is inert there. + #[test] + fn plain_copy_does_not_flush() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/a.txt"), 10); + fake.add_dir(Path::new("/dst")); + fake.inject(InjectableOp::Flush, Path::new("/dst/a.txt"), io::ErrorKind::Other); + let mut sink = RecordingSink::new(); + run_job(©_job_of("a.txt"), &fake, &mut sink); + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert!(sink.errors_seen.is_empty()); + } + + /// "A source that cannot be removed is reported". + #[test] + fn move_cross_volume_reports_a_source_that_could_not_be_removed() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_file(Path::new("/v1/a.txt"), 100); + fake.add_dir(Path::new("/v2")); + fake.inject(InjectableOp::RemoveFile, Path::new("/v1/a.txt"), io::ErrorKind::PermissionDenied); + let fs = Recording::new(fake); + + let mut sink = RecordingSink::new(); + sink.error_answers.push(ErrorChoice::Skip); + run_job(&cross_volume_move_of("a.txt"), &fs, &mut sink); + + assert_eq!(sink.errors_seen.len(), 1, "the removal failure reached the error dialog"); + assert_eq!(sink.errors_seen[0].path, PathBuf::from("/v1/a.txt")); + assert_eq!(fs.inner.metadata(Path::new("/v2/a.txt")).unwrap().size, 100, "the verified copy stays"); + assert_eq!(fs.inner.metadata(Path::new("/v1/a.txt")).unwrap().size, 100, "the source is still there"); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from("/v1/a.txt")); + assert!(skipped[0].reason.starts_with("copied, but the source could not be removed: "), "{}", skipped[0].reason); + assert_eq!(skipped[0].kind, ItemKind::Skipped); + } + other => panic!("expected Completed with one skipped item, got {other:?}"), + } + // The copy landed (rename) before the source removal was attempted. + let ops = fs.ops(); + let landed = ops.iter().position(|o| *o == Op::Rename(partial("/v2/a.txt"), PathBuf::from("/v2/a.txt"))).expect("rename over the target"); + let removed = ops.iter().position(|o| *o == Op::RemoveFile(PathBuf::from("/v1/a.txt"))).expect("source removal attempted"); + assert!(landed < removed, "{ops:?}"); + } + + /// Retry on a failed source removal retries the removal, and the next + /// attempt succeeding leaves a clean summary. + #[test] + fn move_cross_volume_source_removal_retry_succeeds() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_file(Path::new("/v1/a.txt"), 10); + fake.add_dir(Path::new("/v2")); + fake.inject(InjectableOp::RemoveFile, Path::new("/v1/a.txt"), io::ErrorKind::PermissionDenied); + + let mut sink = RecordingSink::new(); + sink.error_answers.push(ErrorChoice::Retry); + run_job(&cross_volume_move_of("a.txt"), &fake, &mut sink); + + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert!(fake.metadata(Path::new("/v1/a.txt")).is_err(), "removed on the retry"); + } + #[test] fn case_only_rename_succeeds_via_identity_check() { let fake = FakeFs::new(); @@ -996,6 +1882,102 @@ mod tests { assert_eq!(fake.metadata(Path::new("/dst/a.txt")).unwrap().size, 99, "left untouched"); } + // ----------------------------------------------------------------- + // process-lifecycle-hardening: Cancel from the conflict prompt + // (operation-dialogs "Overwrite-conflict dialog" — "Esc cancels the + // job from the conflict dialog"; design D7) + // ----------------------------------------------------------------- + + /// Three sources where only the second collides at the destination. + fn three_file_job(kind: JobKind, fake: &FakeFs) -> Job { + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/a.txt"), 1); + fake.add_file(Path::new("/src/b.txt"), 2); + fake.add_file(Path::new("/src/c.txt"), 3); + fake.add_dir(Path::new("/dst")); + fake.add_file(Path::new("/dst/b.txt"), 99); + Job { + kind, + sources: vec![source("a.txt", "/src/a.txt"), source("b.txt", "/src/b.txt"), source("c.txt", "/src/c.txt")], + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst"), + new_dir_name: None, + } + } + + #[test] + fn cancel_at_a_copy_conflict_stops_the_job_without_touching_or_recording_the_target() { + let fake = FakeFs::new(); + // Distinct volumes so a Move would take the copy-then-delete path; + // for Copy it changes nothing. + let job = three_file_job(JobKind::Copy, &fake); + let mut sink = RecordingSink::new(); + sink.conflict_answers.push(ConflictChoice::Cancel); + run_job(&job, &fake, &mut sink); + + assert_eq!(sink.conflicts_seen, 1, "the second file's conflict was asked once"); + assert!( + matches!(sink.outcome, Some(JobOutcome::Cancelled { ref skipped }) if skipped.is_empty()), + "cancelled with no skipped item for the conflicting target: {:?}", + sink.outcome + ); + assert_eq!(fake.metadata(Path::new("/dst/a.txt")).unwrap().size, 1, "the first file was copied before the conflict"); + assert_eq!(fake.metadata(Path::new("/dst/b.txt")).unwrap().size, 99, "the conflicting target is untouched"); + assert!(fake.metadata(Path::new("/dst/c.txt")).is_err(), "nothing after the conflict is processed"); + } + + #[test] + fn cancel_at_a_cross_volume_move_conflict_leaves_the_remaining_sources_in_place() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/src"), 1); + fake.set_volume(Path::new("/dst"), 2); + let job = three_file_job(JobKind::Move, &fake); + let mut sink = RecordingSink::new(); + sink.conflict_answers.push(ConflictChoice::Cancel); + run_job(&job, &fake, &mut sink); + + assert!(matches!(sink.outcome, Some(JobOutcome::Cancelled { ref skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert!(fake.metadata(Path::new("/src/a.txt")).is_err(), "the first file was moved before the conflict"); + assert_eq!(fake.metadata(Path::new("/dst/b.txt")).unwrap().size, 99, "the conflicting target is untouched"); + assert!( + fake.metadata(Path::new("/src/b.txt")).is_ok() && fake.metadata(Path::new("/src/c.txt")).is_ok(), + "the conflicting source and the third file stay put" + ); + assert!(fake.metadata(Path::new("/dst/c.txt")).is_err()); + } + + #[test] + fn cancel_at_a_same_volume_move_conflict_stops_the_job_without_touching_the_target() { + let fake = FakeFs::new(); + let job = three_file_job(JobKind::Move, &fake); + let mut sink = RecordingSink::new(); + sink.conflict_answers.push(ConflictChoice::Cancel); + run_job(&job, &fake, &mut sink); + + assert_eq!(sink.conflicts_seen, 1); + assert!(matches!(sink.outcome, Some(JobOutcome::Cancelled { ref skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert!(fake.metadata(Path::new("/dst/a.txt")).is_ok() && fake.metadata(Path::new("/src/a.txt")).is_err(), "the first file was moved"); + assert_eq!(fake.metadata(Path::new("/dst/b.txt")).unwrap().size, 99, "the conflicting target is untouched"); + assert!(fake.metadata(Path::new("/src/b.txt")).is_ok(), "the conflicting source stays"); + assert!(fake.metadata(Path::new("/src/c.txt")).is_ok() && fake.metadata(Path::new("/dst/c.txt")).is_err(), "the third file is not moved"); + } + + #[test] + fn cancel_at_a_rename_conflict_ends_the_job_cancelled_with_both_files_untouched() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/d")); + fake.add_file(Path::new("/d/a.txt"), 4); + fake.add_file(Path::new("/d/b.txt"), 9); + let job = rename_job("/d", "a.txt", "b.txt"); + let mut sink = RecordingSink::new(); + sink.conflict_answers.push(ConflictChoice::Cancel); + run_job(&job, &fake, &mut sink); + + assert!(matches!(sink.outcome, Some(JobOutcome::Cancelled { ref skipped }) if skipped.is_empty()), "{:?}", sink.outcome); + assert_eq!(fake.metadata(Path::new("/d/a.txt")).unwrap().size, 4); + assert_eq!(fake.metadata(Path::new("/d/b.txt")).unwrap().size, 9, "the conflicting target is untouched"); + } + #[test] fn error_retry_then_succeeds() { let fake = FakeFs::new(); @@ -1105,4 +2087,382 @@ mod tests { handle.join().unwrap(); assert!(matches!(sink.lock().unwrap().outcome, Some(JobOutcome::Completed { .. }))); } + + // ------------------------------------------------------------- + // Copy-onto-itself guard (file-operations "A copy never + // overwrites its own source") + // ------------------------------------------------------------- + + /// A file with a known size, an alternate stream, and a known last-write + /// time. `FakeFs` does not model truncation, but a copy onto the same + /// path replaces the node — dropping the stream and the timestamp — so + /// those two are what detect a self-copy here. + fn self_copy_fixture(fake: &FakeFs, path: &Path) -> SystemTime { + fake.add_file_with_stream(path, 2048, "Zone.Identifier", 512); + let modified = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000); + fake.set_modified(path, modified).unwrap(); + modified + } + + fn assert_untouched(fake: &FakeFs, path: &Path, modified: SystemTime) { + let md = fake.metadata(path).unwrap(); + assert_eq!(md.size, 2048, "content size must survive"); + assert_eq!(md.modified, Some(modified), "last-write time must survive (a self-copy would have replaced the node)"); + assert_eq!(fake.list_streams(path).unwrap(), vec![OsString::from("Zone.Identifier")], "the alternate stream must survive"); + } + + fn assert_skipped_same_file(sink: &RecordingSink, src: &str) { + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "the same-file item is reported as skipped: {skipped:?}"); + assert_eq!(skipped[0].path, PathBuf::from(src)); + assert!(skipped[0].reason.contains("same file"), "the reason should name the self-copy: {}", skipped[0].reason); + } + other => panic!("expected Completed with one skipped item, got {other:?}"), + } + assert_eq!(sink.conflicts_seen, 0, "a same-file copy must not raise the overwrite-conflict dialog"); + } + + #[test] + fn copy_into_own_directory_skips_same_file_without_conflict() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/work")); + let modified = self_copy_fixture(&fake, Path::new("/work/report.txt")); + + let job = Job { + kind: JobKind::Copy, + sources: vec![source("report.txt", "/work/report.txt")], + source_dir: PathBuf::from("/work"), + dest_dir: PathBuf::from("/work"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + assert_skipped_same_file(&sink, "/work/report.txt"); + assert_untouched(&fake, Path::new("/work/report.txt"), modified); + } + + #[test] + fn copy_through_junction_alias_onto_itself_is_skipped() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/work")); + let modified = self_copy_fixture(&fake, Path::new("/work/report.txt")); + fake.add_reparse_point(Path::new("/alias"), true, Path::new("/work"), super::super::fs::ReparseKind::Junction); + + let job = Job { + kind: JobKind::Copy, + sources: vec![source("report.txt", "/work/report.txt")], + source_dir: PathBuf::from("/work"), + dest_dir: PathBuf::from("/alias"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + // `/alias/report.txt` resolves to `/work/report.txt` through the + // junction, so the followed identities match and the item is skipped. + assert_skipped_same_file(&sink, "/work/report.txt"); + assert_untouched(&fake, Path::new("/work/report.txt"), modified); + } + + #[test] + fn move_copy_then_delete_onto_same_file_keeps_the_source() { + let fake = FakeFs::new(); + fake.set_volume(Path::new("/v1"), 1); + fake.set_volume(Path::new("/v2"), 2); + fake.add_dir(Path::new("/v1/work")); + let modified = self_copy_fixture(&fake, Path::new("/v1/work/report.txt")); + fake.add_reparse_point(Path::new("/v2/alias"), true, Path::new("/v1/work"), super::super::fs::ReparseKind::Junction); + + // Different volume tags make `same_volume` answer false, so the move + // takes the copy-then-delete path rather than the rename path. + let job = Job { + kind: JobKind::Move, + sources: vec![source("report.txt", "/v1/work/report.txt")], + source_dir: PathBuf::from("/v1/work"), + dest_dir: PathBuf::from("/v2/alias"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + assert_skipped_same_file(&sink, "/v1/work/report.txt"); + assert_untouched(&fake, Path::new("/v1/work/report.txt"), modified); + } + + /// Delegates every [`FileSystem`] call to the inner fake but reports no + /// file identity, simulating a file whose handle cannot be opened for + /// identity (design D1's unknown-identity path fallback). + struct NoIdentity(F); + + impl FileSystem for NoIdentity { + fn metadata(&self, path: &Path) -> io::Result { + let mut md = self.0.metadata(path)?; + md.file_id = None; + Ok(md) + } + fn metadata_follow(&self, path: &Path) -> io::Result { + let mut md = self.0.metadata_follow(path)?; + md.file_id = None; + Ok(md) + } + fn read_dir(&self, path: &Path) -> io::Result> { + self.0.read_dir(path) + } + fn create_dir(&self, path: &Path) -> io::Result<()> { + self.0.create_dir(path) + } + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + self.0.copy_file(from, to, opts, on_progress) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.0.rename(from, to) + } + fn remove_file(&self, path: &Path) -> io::Result<()> { + self.0.remove_file(path) + } + fn remove_dir(&self, path: &Path) -> io::Result<()> { + self.0.remove_dir(path) + } + fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()> { + self.0.set_readonly(path, readonly) + } + fn supports_named_streams(&self, path: &Path) -> io::Result { + self.0.supports_named_streams(path) + } + fn same_volume(&self, a: &Path, b: &Path) -> io::Result { + self.0.same_volume(a, b) + } + } + + #[test] + fn copy_into_own_directory_without_identity_uses_path_fallback() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/work")); + let modified = self_copy_fixture(&fake, Path::new("/work/report.txt")); + let fs = NoIdentity(fake); + + let job = Job { + kind: JobKind::Copy, + sources: vec![source("report.txt", "/work/report.txt")], + source_dir: PathBuf::from("/work"), + dest_dir: PathBuf::from("/work"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fs, &mut sink); + + // With identity unreadable, `identity_matches` answers false and the + // same file would raise an overwrite conflict against itself; the + // path fallback must instead skip it without asking. + assert_skipped_same_file(&sink, "/work/report.txt"); + assert_untouched(&fs.0, Path::new("/work/report.txt"), modified); + } + + #[cfg(windows)] + #[test] + fn path_fallback_compares_case_insensitively_on_windows() { + let mixed_case = Path::new(r"C:\Work\Report.txt"); + let normalized_dots = Path::new(r"c:\work\.\report.txt"); + let a = Path::new(r"C:\work\a.txt"); + let b = Path::new(r"C:\work\b.txt"); + assert!(normalized_paths_equal(mixed_case, normalized_dots)); + assert!(!normalized_paths_equal(a, b)); + } + + #[cfg(not(windows))] + #[test] + fn path_fallback_compares_exactly_off_windows() { + let with_dot = Path::new("/work/./report.txt"); + let plain = Path::new("/work/report.txt"); + let different_case = Path::new("/work/Report.txt"); + assert!(normalized_paths_equal(with_dot, plain)); + assert!(!normalized_paths_equal(different_case, plain)); + } + + // ------------------------------------------------------------- + // error-diagnostics: metadata-restore warnings (task 5.4) + // ------------------------------------------------------------- + + /// A one-file copy job `/src/` → `/dst`. + fn copy_job_of(name: &str) -> Job { + Job { + kind: JobKind::Copy, + sources: vec![source(name, &format!("/src/{name}"))], + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst"), + new_dir_name: None, + } + } + + /// The pinned source mtime that exercises the modification-time + /// restore (a `FakeFs` file starts with `modified: None`, which skips + /// the restore entirely). + fn pin_modified(fake: &FakeFs, path: &Path) { + fake.set_modified(path, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)).unwrap(); + } + + #[test] + fn set_modified_failure_becomes_a_warning_and_the_file_still_counts() { + // operation-dialogs "Metadata-restore failures are reported in the + // end-of-job summary": "Timestamp restore failure becomes a + // warning". + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/report.docx"), 10); + pin_modified(&fake, Path::new("/src/report.docx")); + fake.add_dir(Path::new("/dst")); + fake.inject(InjectableOp::SetModified, Path::new("/dst/report.docx"), io::ErrorKind::PermissionDenied); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("report.docx"), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "exactly one warning, and it is not a skip: {skipped:?}"); + assert_eq!(skipped[0].kind, ItemKind::Warning); + assert_eq!(skipped[0].path, PathBuf::from("/dst/report.docx"), "the warning names the destination path"); + assert!(skipped[0].reason.starts_with("modification time not restored: "), "{}", skipped[0].reason); + } + other => panic!("expected Completed with one warning, got {other:?}"), + } + assert!(sink.events.iter().any(|e| e == "progress:1/1"), "the file counts toward completed files"); + assert!(fake.metadata(Path::new("/dst/report.docx")).is_ok(), "the destination content was written"); + } + + #[test] + fn stream_copy_failure_becomes_a_warning_naming_the_stream() { + // "Alternate data stream copy failure becomes a warning". + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file_with_stream(Path::new("/src/photo.jpg"), 100, "Zone.Identifier", 24); + fake.add_dir(Path::new("/dst")); + fake.inject(InjectableOp::CopyStream, Path::new("/dst/photo.jpg"), io::ErrorKind::PermissionDenied); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("photo.jpg"), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "one warning for the failed stream: {skipped:?}"); + assert_eq!(skipped[0].kind, ItemKind::Warning); + assert_eq!(skipped[0].path, PathBuf::from("/dst/photo.jpg")); + assert!(skipped[0].reason.starts_with("stream Zone.Identifier not copied: "), "names the stream: {}", skipped[0].reason); + } + other => panic!("expected Completed with one warning, got {other:?}"), + } + assert!(sink.events.iter().any(|e| e == "progress:1/1")); + assert_eq!(fake.metadata(Path::new("/dst/photo.jpg")).unwrap().size, 100, "the main content copied"); + } + + #[test] + fn stream_listing_failure_becomes_a_single_warning() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file_with_stream(Path::new("/src/a.txt"), 10, "Zone.Identifier", 3); + fake.add_dir(Path::new("/dst")); + // The listing is taken on the *source*; the warning is recorded + // against the destination (design D5). + fake.inject(InjectableOp::ListStreams, Path::new("/src/a.txt"), io::ErrorKind::PermissionDenied); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("a.txt"), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "one warning for the failed listing: {skipped:?}"); + assert_eq!(skipped[0].kind, ItemKind::Warning); + assert_eq!(skipped[0].path, PathBuf::from("/dst/a.txt")); + assert!(skipped[0].reason.starts_with("alternate data streams not listed: "), "{}", skipped[0].reason); + } + other => panic!("expected Completed with one warning, got {other:?}"), + } + assert!(sink.events.iter().any(|e| e == "progress:1/1")); + } + + #[test] + fn read_only_restore_failure_becomes_a_warning() { + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file(Path::new("/src/ro.txt"), 4); + fake.set_readonly(Path::new("/src/ro.txt"), true).unwrap(); + fake.add_dir(Path::new("/dst")); + fake.inject(InjectableOp::SetReadonly, Path::new("/dst/ro.txt"), io::ErrorKind::PermissionDenied); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("ro.txt"), &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "one warning for the failed restore: {skipped:?}"); + assert_eq!(skipped[0].kind, ItemKind::Warning); + assert_eq!(skipped[0].path, PathBuf::from("/dst/ro.txt")); + assert!(skipped[0].reason.starts_with("read-only attribute not restored: "), "{}", skipped[0].reason); + } + other => panic!("expected Completed with one warning, got {other:?}"), + } + assert!(sink.events.iter().any(|e| e == "progress:1/1")); + } + + #[test] + fn a_destination_without_stream_support_yields_one_job_level_warning() { + // "A destination without stream support yields one warning": + // forty-files-on-FAT32, shrunk to three. + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + for (name, size) in [("a.txt", 10u64), ("b.txt", 20), ("c.txt", 30)] { + fake.add_file_with_stream(Path::new(&format!("/src/{name}")), size, "Zone.Identifier", 3); + } + fake.add_dir(Path::new("/dst")); + fake.set_named_streams_supported(false); + + let job = Job { + kind: JobKind::Copy, + sources: vec![source("a.txt", "/src/a.txt"), source("b.txt", "/src/b.txt"), source("c.txt", "/src/c.txt")], + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst"), + new_dir_name: None, + }; + let mut sink = RecordingSink::new(); + run_job(&job, &fake, &mut sink); + + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "one aggregated warning, not one per file: {skipped:?}"); + assert_eq!(skipped[0].kind, ItemKind::Warning); + assert_eq!(skipped[0].path, PathBuf::from("/dst"), "the warning names the destination"); + assert_eq!(skipped[0].reason, "alternate data streams not supported; streams of 3 file(s) not copied"); + } + other => panic!("expected Completed with one warning, got {other:?}"), + } + assert!(sink.events.iter().any(|e| e == "progress:3/3"), "all three files still count as copied"); + for name in ["a.txt", "b.txt", "c.txt"] { + let dst = PathBuf::from(format!("/dst/{name}")); + assert!(fake.metadata(&dst).is_ok(), "{name} was copied"); + assert!(fake.list_streams(&dst).unwrap().is_empty(), "no per-file stream copy was attempted for {name}"); + } + } + + #[test] + fn a_clean_copy_records_no_warnings_and_an_empty_skipped() { + // "Clean job shows no summary": a copy whose metadata all restores + // leaves the outcome's skipped vector empty. + let fake = FakeFs::new(); + fake.add_dir(Path::new("/src")); + fake.add_file_with_stream(Path::new("/src/a.txt"), 10, "Zone.Identifier", 3); + pin_modified(&fake, Path::new("/src/a.txt")); + fake.add_dir(Path::new("/dst")); + + let mut sink = RecordingSink::new(); + run_job(©_job_of("a.txt"), &fake, &mut sink); + + assert!( + matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), + "no warnings on a clean copy: {:?}", + sink.outcome + ); + assert_eq!(fake.list_streams(Path::new("/dst/a.txt")).unwrap(), vec![OsString::from("Zone.Identifier")], "the stream was carried over"); + assert!(fake.metadata(Path::new("/dst/a.txt")).unwrap().modified.is_some(), "the mtime was restored"); + } } diff --git a/crates/filecommand-core/src/git_info.rs b/crates/filecommand-core/src/git_info.rs index 340ca51..58ef312 100644 --- a/crates/filecommand-core/src/git_info.rs +++ b/crates/filecommand-core/src/git_info.rs @@ -5,7 +5,12 @@ //! branch resolution, and status — designed to be run on a dedicated worker //! thread that `filecommand-tui` owns (core never spawns threads itself, and //! libgit2 status calls are blocking and not cancellable mid-call; design -//! D3). The reducer (`update.rs`) guards every reply with a directory + +//! D3). It is split into [`discover`] + [`query_in_repo`] so the worker can +//! publish the repository's root (via [`root_in_dir_spelling`]) before the +//! potentially slow status walk begins, which is what lets a timed-out query +//! mark its repository "no info" for the rest of the session +//! (git-info-timeout-memory). The reducer (`update.rs`) guards every reply +//! with a directory + //! generation key (`PanelState::git_request`) via `Command::GitInfoResolved`, //! so a result for a directory/generation the panel has since moved past is //! dropped rather than applied — the same staleness-guard shape as @@ -14,7 +19,7 @@ use std::collections::HashMap; use std::ffi::OsString; -use std::path::Path; +use std::path::{Path, PathBuf}; use git2::{Repository, Status, StatusOptions}; @@ -82,16 +87,89 @@ impl GitInfo { /// Detect the repository enclosing `dir`, resolve its branch name, and /// compute pathspec-scoped status for `dir`'s immediate entries. Blocking — /// callers run this on a dedicated worker thread (git-info "Background -/// repository detection"). +/// repository detection"). The composition of [`discover`] and +/// [`query_in_repo`], kept for callers that need the whole lookup as one +/// call. pub fn query(dir: &Path) -> GitInfo { - let Ok(repo) = Repository::discover(dir) else { + let Some((repo, _depth)) = discover(dir) else { return GitInfo::none(); }; - let branch = resolve_branch(&repo); - let statuses = pathspec_status(&repo, dir); + query_in_repo(&repo, dir) +} + +/// The discovery half of [`query`], split out so a caller that is about to +/// start the (uncancellable, potentially slow) status walk can first learn +/// *which* repository it is walking: the returned depth is the number of +/// path components between the repository's working directory and `dir`, so +/// [`root_in_dir_spelling`] can express the root in `dir`'s own spelling +/// (git-info-timeout-memory design D2). `None` when `dir` is not inside any +/// repository — the same condition [`query`] reports as [`GitInfo::none`]. +/// +/// The depth is computed on canonicalized spellings for the same reason +/// `pathspec_status` compares them (8.3 short-name vs long-name forms); +/// when there is no working directory (a bare repository) or the comparison +/// fails, it falls back to 0, i.e. "the queried directory itself". +pub fn discover(dir: &Path) -> Option<(Repository, usize)> { + let repo = Repository::discover(dir).ok()?; + let Some(workdir) = repo.workdir() else { + return Some((repo, 0)); + }; + let canon_dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let canon_workdir = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.to_path_buf()); + let depth = canon_dir.strip_prefix(&canon_workdir).map(|rel| rel.components().count()).unwrap_or(0); + Some((repo, depth)) +} + +/// The branch-resolution and status half of [`query`] for an already +/// discovered repository (git-info "Background repository detection"). +pub fn query_in_repo(repo: &Repository, dir: &Path) -> GitInfo { + let branch = resolve_branch(repo); + let statuses = pathspec_status(repo, dir); GitInfo { branch, statuses } } +/// The repository root `depth` components above `dir`, spelled with `dir`'s +/// own components rather than libgit2's workdir form — the panel's later +/// `cwd`s share that spelling, so membership tests ([`is_under_root`]) never +/// trip over 8.3-short-name, `\\?\`, or separator differences between what +/// the panel holds and what libgit2 reports (git-info-timeout-memory design +/// D2). `None` when `dir` has fewer ancestors than `depth` (callers fall +/// back to `dir` itself). +pub fn root_in_dir_spelling(dir: &Path, depth: usize) -> Option { + dir.ancestors().nth(depth).map(Path::to_path_buf) +} + +/// Whether `path` is `root` itself or one of its descendants — a +/// component-wise prefix test, so a sibling that merely shares a string +/// prefix (`D:\mono2` vs `D:\mono`) is not "under" the root. Comparison is +/// ASCII-case-insensitive on Windows, where the filesystem is, and exact +/// elsewhere. Shared by the reducer (which remembers timed-out repositories +/// for the session) and the TUI's git worker (which drops queued queries +/// inside them), so both apply the same membership test +/// (git-info-timeout-memory design D1/D2). +pub fn is_under_root(path: &Path, root: &Path) -> bool { + let mut path_components = path.components(); + for root_component in root.components() { + let Some(path_component) = path_components.next() else { + return false; + }; + if !component_matches(path_component.as_os_str(), root_component.as_os_str()) { + return false; + } + } + true +} + +#[cfg(windows)] +fn component_matches(path: &std::ffi::OsStr, root: &std::ffi::OsStr) -> bool { + path.eq_ignore_ascii_case(root) +} + +#[cfg(not(windows))] +fn component_matches(path: &std::ffi::OsStr, root: &std::ffi::OsStr) -> bool { + path == root +} + /// The current branch's short name (e.g. `main`), or `None` for an /// unborn/detached HEAD. fn resolve_branch(repo: &Repository) -> Option { @@ -125,8 +203,13 @@ fn pathspec_status(repo: &Repository, dir: &Path) -> HashMap HashMap String { + let joined = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + let has_pattern_chars = joined.contains(['[', '*', '?']) || (!cfg!(windows) && joined.contains('\\')); + if !has_pattern_chars { + return joined; + } + let mut quoted = String::with_capacity(joined.len() + 2); + for (i, ch) in joined.chars().enumerate() { + match ch { + '[' => quoted.push_str("[[]"), + '*' => quoted.push_str("[*]"), + '?' => quoted.push_str("[?]"), + '\\' => quoted.push('?'), + '!' | '#' if i == 0 => quoted.push('?'), + _ => quoted.push(ch), + } + } + quoted.push_str("/*"); + quoted +} + /// Map a raw libgit2 status bitflag set to the single marker this panel /// shows, or `None` for a clean/ignored/current entry (git-info "Clean /// entry has blank marker"). fn classify(status: Status) -> Option { - let working_tree_dirty = - status.intersects(Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE); + let working_tree_dirty = status.intersects(Status::WT_MODIFIED | Status::WT_DELETED | Status::WT_RENAMED | Status::WT_TYPECHANGE); let staged = status.intersects(Status::INDEX_NEW | Status::INDEX_MODIFIED | Status::INDEX_DELETED | Status::INDEX_RENAMED | Status::INDEX_TYPECHANGE); let untracked = status.contains(Status::WT_NEW); @@ -343,4 +465,260 @@ mod tests { let second = query(dir.path()); assert_eq!(first, second); } + + // ----------------------------------------------------------------- + // git-info-timeout-memory: literal pathspecs, root discovery, + // remembered-root membership (design D2, D4) + // ----------------------------------------------------------------- + + /// Stage `paths` (repo-relative, `/`-separated) and commit them on top + /// of HEAD, so they can then be dirtied in the working tree. + fn commit_paths(repo: &Repository, paths: &[&str], message: &str) { + let mut index = repo.index().unwrap(); + for path in paths { + index.add_path(Path::new(path)).unwrap(); + } + index.write().unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let sig = repo.signature().unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent]).unwrap(); + } + + /// Extend the fixture with `/a.txt` committed and then + /// modified, plus an untracked `/new/inner.txt`, so a panel + /// sitting in `rel_dir` should report exactly `a.txt: M` and `new: ?` — + /// the same markers any plain-named directory would show. + fn commit_then_dirty(repo: &Repository, root: &Path, rel_dir: &str) { + let dir = root.join(rel_dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.txt"), b"before\n").unwrap(); + let rel_file = format!("{rel_dir}/a.txt"); + commit_paths(repo, &[&rel_file], "add a.txt"); + std::fs::write(dir.join("a.txt"), b"after\n").unwrap(); + std::fs::create_dir_all(dir.join("new")).unwrap(); + std::fs::write(dir.join("new").join("inner.txt"), b"x\n").unwrap(); + } + + /// The panel directory reports its own modified `a.txt` and untracked + /// `new`, and nothing else (git-info "Directory names are matched + /// literally"). + fn assert_own_markers(info: &GitInfo) { + assert_eq!(info.statuses.get(OsStr::new("a.txt")), Some(&FileStatus::Modified), "a.txt must show M: {:?}", info.statuses); + assert_eq!(info.statuses.get(OsStr::new("new")), Some(&FileStatus::Untracked), "new must show ?: {:?}", info.statuses); + assert_eq!(info.statuses.len(), 2, "nothing outside the panel directory is reported: {:?}", info.statuses); + } + + #[test] + fn literal_pathspec_passes_paths_without_pattern_characters_through_unchanged() { + // The forms that already work today keep their exact spelling — + // `!old` (would be negation), `#notes` (would be a comment), + // spaces, and `]` included (design D4). + assert_eq!(literal_pathspec(Path::new("src")), "src"); + assert_eq!(literal_pathspec(Path::new("src/main")), "src/main"); + assert_eq!(literal_pathspec(Path::new("!old")), "!old"); + assert_eq!(literal_pathspec(Path::new("#notes")), "#notes"); + assert_eq!(literal_pathspec(Path::new("a b")), "a b"); + assert_eq!(literal_pathspec(Path::new("a]b")), "a]b"); + assert_eq!(literal_pathspec(Path::new("x/!b")), "x/!b", "a `!` that is not the pattern's first character needs no quoting"); + } + + #[test] + fn literal_pathspec_quotes_wildcard_characters_as_bracket_classes() { + assert_eq!(literal_pathspec(Path::new("foo[1]")), "foo[[]1]/*"); + assert_eq!(literal_pathspec(Path::new("[x]")), "[[]x]/*"); + assert_eq!(literal_pathspec(Path::new("x/foo[1]")), "x/foo[[]1]/*"); + assert_eq!(literal_pathspec(Path::new("a*b")), "a[*]b/*"); + assert_eq!(literal_pathspec(Path::new("what?")), "what[?]/*"); + } + + #[test] + fn literal_pathspec_defuses_a_leading_bang_or_hash_when_quoting() { + // Inside a quoted pattern a leading `!` or `#` would still be read + // as negation/comment, so it becomes `?`; a sibling the `?` also + // matches is removed by the caller's `strip_prefix` fold (design D4). + assert_eq!(literal_pathspec(Path::new("!b[2]")), "?b[[]2]/*"); + assert_eq!(literal_pathspec(Path::new("#c[3]")), "?c[[]3]/*"); + assert_eq!(literal_pathspec(Path::new("x/!b[2]")), "x/!b[[]2]/*", "only the pattern's first character is special"); + } + + #[cfg(not(windows))] + #[test] + fn literal_pathspec_replaces_a_backslash_off_windows() { + // libgit2 reads `\` as an escape; a name containing one exists only + // where the filesystem allows it. + assert_eq!(literal_pathspec(Path::new("back\\slash")), "back?slash/*"); + } + + #[test] + fn literal_pathspec_never_emits_a_backslash() { + for rel in ["foo[1]", "[x]", "!b[2]", "#c[3]", "a*b", "what?", "x/foo[1]"] { + assert!(!literal_pathspec(Path::new(rel)).contains('\\'), "{rel}"); + } + #[cfg(not(windows))] + assert!(!literal_pathspec(Path::new("back\\slash")).contains('\\')); + } + + #[test] + fn a_directory_named_foo_bracket_1_reports_its_markers() { + // git-info "Directory names are matched literally". + let dir = TempRepoDir::new("bracket"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "foo[1]"); + let info = query(&dir.path().join("foo[1]")); + assert_own_markers(&info); + } + + #[test] + fn pattern_characters_in_a_nested_directory_name() { + // git-info "Pattern characters in a nested directory name": the + // sibling `src/foo1` is walked too (the iterator prefix ends at the + // first quoted character) but its entries never reach the panel. + let dir = TempRepoDir::new("nested-bracket"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "src/foo[1]"); + commit_then_dirty(&repo, dir.path(), "src/foo1"); + let info = query(&dir.path().join("src").join("foo[1]")); + assert_own_markers(&info); + } + + #[test] + fn a_directory_named_bracket_x_reports_its_markers() { + let dir = TempRepoDir::new("bracket-x"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "[x]"); + let info = query(&dir.path().join("[x]")); + assert_own_markers(&info); + } + + #[test] + fn a_directory_named_bang_b_bracket_2_reports_only_its_own_markers() { + // The quoted pattern `?b[[]2]/*` also matches the sibling `Xb[2]`; + // its entries are dropped by the `strip_prefix` fold (design D4). + let dir = TempRepoDir::new("bang-bracket"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "!b[2]"); + commit_then_dirty(&repo, dir.path(), "Xb[2]"); + let info = query(&dir.path().join("!b[2]")); + assert_own_markers(&info); + } + + #[test] + fn a_directory_named_hash_c_bracket_3_reports_its_markers() { + let dir = TempRepoDir::new("hash-bracket"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "#c[3]"); + let info = query(&dir.path().join("#c[3]")); + assert_own_markers(&info); + } + + #[test] + fn a_leading_exclamation_mark_is_not_a_negation() { + // git-info "A leading exclamation mark is not a negation" — this + // name passes through unchanged and keeps working via libgit2's + // literal-`!` fallback; pinned so the quoting fix cannot regress it. + let dir = TempRepoDir::new("bang"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "!old"); + let info = query(&dir.path().join("!old")); + assert_own_markers(&info); + } + + #[test] + fn a_leading_hash_does_not_widen_the_query() { + // git-info "A leading hash does not widen the query": the + // comment-pattern keeps the walk scoped via the iterator bound, so + // a modified file outside `#notes` is not reported either. + let dir = TempRepoDir::new("hash"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "#notes"); + std::fs::write(dir.path().join("outside.txt"), b"before\n").unwrap(); + commit_paths(&repo, &["outside.txt"], "add outside.txt"); + std::fs::write(dir.path().join("outside.txt"), b"after\n").unwrap(); + let info = query(&dir.path().join("#notes")); + assert_own_markers(&info); + assert!(!info.statuses.contains_key(OsStr::new("outside.txt")), "outside the panel directory: {:?}", info.statuses); + } + + #[test] + fn a_directory_name_with_a_space_reports_its_markers() { + let dir = TempRepoDir::new("space"); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), "my dir"); + let info = query(&dir.path().join("my dir")); + assert_own_markers(&info); + } + + #[cfg(not(windows))] + #[test] + fn directories_named_with_glob_characters_report_their_markers() { + // `*`, `?`, and `\` cannot appear in a Windows file name, so these + // run only where the filesystem allows them. + for (i, name) in ["a*b", "what?", "back\\slash"].iter().enumerate() { + let dir = TempRepoDir::new(&format!("glob-{i}")); + let repo = init_repo_with_commit(dir.path()); + commit_then_dirty(&repo, dir.path(), name); + let info = query(&dir.path().join(name)); + assert_own_markers(&info); + } + } + + #[test] + fn root_in_dir_spelling_walks_up_by_components() { + assert_eq!(root_in_dir_spelling(Path::new("/repo/sub/deep"), 2), Some(PathBuf::from("/repo"))); + assert_eq!(root_in_dir_spelling(Path::new("/repo/sub"), 1), Some(PathBuf::from("/repo"))); + } + + #[test] + fn root_in_dir_spelling_of_the_root_itself_is_the_directory() { + assert_eq!(root_in_dir_spelling(Path::new("/repo"), 0), Some(PathBuf::from("/repo"))); + } + + #[cfg(windows)] + #[test] + fn root_in_dir_spelling_preserves_the_queried_paths_own_spelling() { + // The panel may hold the 8.3 short-name form while libgit2 reports + // the long one; the root must come back in the panel's spelling so + // later `cwd`s compare equal under `is_under_root` (design D2). + assert_eq!(root_in_dir_spelling(Path::new(r"C:\VERYLO~1\sub"), 1), Some(PathBuf::from(r"C:\VERYLO~1"))); + } + + #[test] + fn discover_reports_the_depth_so_the_root_spells_like_the_query() { + let dir = TempRepoDir::new("discover-depth"); + init_repo_with_commit(dir.path()); + let sub = dir.path().join("a").join("b"); + std::fs::create_dir_all(&sub).unwrap(); + let (_repo, depth) = discover(&sub).expect("a subdirectory of a repo's working tree is discovered"); + assert_eq!(depth, 2); + assert_eq!(root_in_dir_spelling(&sub, depth).as_deref(), Some(dir.path())); + } + + #[test] + fn discover_outside_any_repository_is_none() { + let dir = TempRepoDir::new("discover-outside"); + assert!(discover(dir.path()).is_none()); + } + + #[test] + fn is_under_root_covers_the_root_itself_and_its_descendants() { + assert!(is_under_root(Path::new("/mono"), Path::new("/mono"))); + assert!(is_under_root(Path::new("/mono/src/deep"), Path::new("/mono"))); + assert!(!is_under_root(Path::new("/other"), Path::new("/mono"))); + assert!(!is_under_root(Path::new("/mono"), Path::new("/mono/src")), "a shorter path is not under a longer root"); + } + + #[test] + fn is_under_root_is_component_wise_not_a_string_prefix() { + assert!(!is_under_root(Path::new("/mono2"), Path::new("/mono"))); + #[cfg(windows)] + assert!(!is_under_root(Path::new(r"D:\mono2"), Path::new(r"D:\mono"))); + } + + #[cfg(windows)] + #[test] + fn is_under_root_folds_ascii_case_on_windows() { + assert!(is_under_root(Path::new(r"d:\MONO\Src"), Path::new(r"D:\mono"))); + } } diff --git a/crates/filecommand-core/src/identity.rs b/crates/filecommand-core/src/identity.rs index 66e2e92..e35137b 100644 --- a/crates/filecommand-core/src/identity.rs +++ b/crates/filecommand-core/src/identity.rs @@ -31,7 +31,7 @@ pub fn version_line() -> String { format!("Version {}", version()) } -/// "Copyright (C) The FileCommand Authors" +/// "Copyright (C) `` The FileCommand Authors" pub fn copyright_line(year: i32) -> String { format!("Copyright (C) {year} The FileCommand Authors") } @@ -39,12 +39,7 @@ pub fn copyright_line(year: i32) -> String { /// The four identity lines shown on the splash screen, in display order: /// product name, version, copyright, tribute. pub fn identity_lines(year: i32) -> [String; 4] { - [ - PRODUCT_NAME.to_string(), - version_line(), - copyright_line(year), - TRIBUTE_LINE.to_string(), - ] + [PRODUCT_NAME.to_string(), version_line(), copyright_line(year), TRIBUTE_LINE.to_string()] } #[cfg(test)] diff --git a/crates/filecommand-core/src/info.rs b/crates/filecommand-core/src/info.rs index 81efe6b..c5934d0 100644 --- a/crates/filecommand-core/src/info.rs +++ b/crates/filecommand-core/src/info.rs @@ -129,8 +129,7 @@ mod tests { #[test] fn resolved_values_replace_the_placeholder_in_place() { - let mut values = InfoValues::default(); - values.drive_total = Some(1_234_567); + let values = InfoValues { drive_total: Some(1_234_567), ..InfoValues::default() }; let boxes = info_boxes(&values, Some('C')); let drive_box = &boxes[1]; assert_eq!(drive_box.fields[2].value, "1,234,567 bytes"); diff --git a/crates/filecommand-core/src/lib.rs b/crates/filecommand-core/src/lib.rs index d4df453..9a6a46b 100644 --- a/crates/filecommand-core/src/lib.rs +++ b/crates/filecommand-core/src/lib.rs @@ -5,10 +5,12 @@ //! ([`update::update`]) plus the panel, listing, theme, config, clock, and //! identity data types that model drives. +pub mod anim; pub mod clock; pub mod config; pub mod dialogs; pub mod drives; +pub mod durable; pub mod editor; pub mod external_editor; pub mod find_file; @@ -17,14 +19,18 @@ pub mod fuzzy; pub mod git_info; pub mod identity; pub mod info; +pub mod line_edit; pub mod listing; pub mod menu; pub mod panel; pub mod panel_split; pub mod quicksearch; pub mod shell; +mod stamps; pub mod theme; pub mod update; pub mod viewer; -pub use update::{update, ButtonId, ClickMods, Command, DragState, DropTarget, Effect, PanelSide, State, UiPhase}; +pub use update::{ + input_layer, update, ButtonId, ClickMods, Command, DragState, DropTarget, Effect, InputLayer, Overlay, PanelSide, ProbeKind, State, UiPhase, +}; diff --git a/crates/filecommand-core/src/line_edit.rs b/crates/filecommand-core/src/line_edit.rs new file mode 100644 index 0000000..0b035f2 --- /dev/null +++ b/crates/filecommand-core/src/line_edit.rs @@ -0,0 +1,524 @@ +//! The shared single-line text-field model behind every editable input line +//! (text-field-editing "Caret model and editing keys"; design D1): 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. One model, one key map, one renderer helper +//! — the days of six private "push a char / pop a char" `String`s are over. +//! +//! The caret is a **char index** into `text` (an insertion point between +//! chars, so `caret == len_chars()` is "at the end"), keeping `unicode-width` +//! out of the core logic; renderers map char indices to display columns with +//! `listing::display_width` (design D1). + +/// One editing operation on a [`LineEdit`], as delivered by +/// `Command::TextEdit` (design D2). `Paste` carries already-read clipboard +/// text — the model never touches the OS clipboard itself; reading it is the +/// TUI's answer to `Effect::ReadClipboardText` (design D4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TextEditOp { + Insert(char), + Backspace, + Delete, + Left, + Right, + Home, + End, + WordLeft, + WordRight, + DeleteWordBack, + DeleteWordForward, + Paste(String), +} + +impl TextEditOp { + /// Whether the op can change the text (everything but the pure caret + /// moves) — the reducer's cue to end history recall / re-narrow a + /// filtered list, while a bare caret move leaves both alone. + pub fn is_text_mutation(&self) -> bool { + !matches!(self, TextEditOp::Left | TextEditOp::Right | TextEditOp::Home | TextEditOp::End | TextEditOp::WordLeft | TextEditOp::WordRight) + } +} + +/// Replace every line break with a single space — `\r\n`, a lone `\r`, or a +/// lone `\n` each become one space (text-field-editing "Paste into a text +/// field"). The one definition shared by [`LineEdit::insert_str`], the TUI's +/// clipboard read (`Effect::ReadClipboardText`), and terminal bracketed-paste +/// routing (idle-aware-event-loop design D6), so every paste path flattens +/// identically. +pub fn flatten_line_breaks(text: &str) -> String { + text.replace("\r\n", " ").replace(['\r', '\n'], " ") +} + +/// A single line of text plus a caret (char index, `0..=len_chars()`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LineEdit { + text: String, + /// Caret as a char index into `text`: `0` is before the first char, + /// `len_chars()` is after the last. + caret: usize, +} + +impl LineEdit { + /// The field's current text. + pub fn text(&self) -> &str { + &self.text + } + + /// The caret's char index (`0..=len_chars()`). + pub fn caret(&self) -> usize { + self.caret + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + /// The text's length in chars — the caret's upper bound. + pub fn len_chars(&self) -> usize { + self.text.chars().count() + } + + /// Replace the text wholesale, leaving the caret at its end — how every + /// pre-filled field opens (text-field-editing "A field that opens + /// pre-filled SHALL open with the caret at the end"), and how history + /// recall and Ctrl+Enter/Ctrl+] leave the command line. + pub fn set_text_caret_end(&mut self, text: String) { + self.text = text; + self.caret = self.len_chars(); + } + + /// Insert `c` at the caret, moving the caret after it. + pub fn insert(&mut self, c: char) { + let byte = self.byte_of_char(self.caret); + self.text.insert(byte, c); + self.caret += 1; + } + + /// Insert `s` at the caret with any line breaks flattened to a single + /// space (text-field-editing "Paste into a text field": "with any line + /// breaks replaced by a single space"), moving the caret past what was + /// inserted. The TUI's clipboard read normalizes before dispatching + /// `TextEditOp::Paste` already; doing it here as well keeps the model's + /// own contract independent of that path. + pub fn insert_str(&mut self, s: &str) { + let flattened = flatten_line_breaks(s); + let byte = self.byte_of_char(self.caret); + self.text.insert_str(byte, &flattened); + self.caret += flattened.chars().count(); + } + + /// Backspace: delete the char before the caret. A no-op at the start. + pub fn backspace(&mut self) { + if self.caret == 0 { + return; + } + let start = self.byte_of_char(self.caret - 1); + let end = self.byte_of_char(self.caret); + self.text.replace_range(start..end, ""); + self.caret -= 1; + } + + /// Delete: remove the char after the caret. A no-op at the end. + pub fn delete(&mut self) { + if self.caret >= self.len_chars() { + return; + } + let start = self.byte_of_char(self.caret); + let end = self.byte_of_char(self.caret + 1); + self.text.replace_range(start..end, ""); + } + + pub fn left(&mut self) { + self.caret = self.caret.saturating_sub(1); + } + + pub fn right(&mut self) { + self.caret = (self.caret + 1).min(self.len_chars()); + } + + pub fn home(&mut self) { + self.caret = 0; + } + + pub fn end(&mut self) { + self.caret = self.len_chars(); + } + + /// Ctrl+Left: move the caret to the start of the current or previous + /// word. Words are runs of alphanumerics; everything else is a separator + /// run of its own, so `C:\Users\kevin` jumps at every `\` (design D1). + pub fn word_left(&mut self) { + self.caret = self.word_left_index(); + } + + /// Ctrl+Right: move the caret to the end of the current or next word. + pub fn word_right(&mut self) { + self.caret = self.word_right_index(); + } + + /// Ctrl+Backspace: delete from the caret back to where [`Self::word_left`] + /// would land — e.g. `C:\Users\kevin\docs` at end becomes + /// `C:\Users\kevin\` (text-field-editing "Word delete"). + pub fn delete_word_back(&mut self) { + let target = self.word_left_index(); + if target == self.caret { + return; + } + let start = self.byte_of_char(target); + let end = self.byte_of_char(self.caret); + self.text.replace_range(start..end, ""); + self.caret = target; + } + + /// Ctrl+Delete: delete from the caret forward to where + /// [`Self::word_right`] would land. + pub fn delete_word_forward(&mut self) { + let target = self.word_right_index(); + if target == self.caret { + return; + } + let start = self.byte_of_char(self.caret); + let end = self.byte_of_char(target); + self.text.replace_range(start..end, ""); + } + + /// Empty the field and re-home the caret. + pub fn clear(&mut self) { + self.text.clear(); + self.caret = 0; + } + + /// Apply one [`TextEditOp`] — the single entry point `Command::TextEdit` + /// routes through for every field (design D2). + pub fn apply(&mut self, op: &TextEditOp) { + match op { + TextEditOp::Insert(c) => self.insert(*c), + TextEditOp::Backspace => self.backspace(), + TextEditOp::Delete => self.delete(), + TextEditOp::Left => self.left(), + TextEditOp::Right => self.right(), + TextEditOp::Home => self.home(), + TextEditOp::End => self.end(), + TextEditOp::WordLeft => self.word_left(), + TextEditOp::WordRight => self.word_right(), + TextEditOp::DeleteWordBack => self.delete_word_back(), + TextEditOp::DeleteWordForward => self.delete_word_forward(), + TextEditOp::Paste(s) => self.insert_str(s), + } + } + + /// The visible window of the text for a field `width` chars wide, + /// scrolled so the caret is always visible (text-field-editing "Field + /// rendering keeps the caret visible"; design D5). Returns + /// `(start_char, caret_col)`: the char index the window starts at, and + /// the caret's column within the window (`caret - start`). + /// + /// `caret_col` is in `[0, width]`: it equals `width` only when the caret + /// sits at the end of a full window (an overlong line edited at its + /// end), in which case the renderer clamps the terminal cursor onto the + /// window's last cell. A mid-text caret always lands on a visible char: + /// `caret_col <= width - 1` with the caret's own char inside the window. + pub fn window(&self, width: usize) -> (usize, usize) { + if width == 0 { + return (0, 0); + } + if self.caret < width { + // The caret sits inside the first window-full, so no scrolling: + // the text's start stays visible (a Home press on a long line + // shows the first `width` chars with the caret at the left edge). + (0, self.caret) + } else if self.caret == self.len_chars() { + // At the end of an overlong line: show the last `width` chars, + // the caret one column past the window's last char (clamped back + // onto that cell by the renderer). + (self.caret - width, width) + } else { + // Mid-text: scroll just far enough that the caret's own char is + // the window's rightmost cell, keeping what follows in view. + (self.caret - width + 1, width - 1) + } + } + + /// The byte offset of char index `idx` (`text.len()` when at the end). + fn byte_of_char(&self, idx: usize) -> usize { + self.text.char_indices().nth(idx).map(|(b, _)| b).unwrap_or(self.text.len()) + } + + /// Where [`Self::word_left`] would land: skip any separator run left of + /// the caret, then the alphanumeric run before it. + fn word_left_index(&self) -> usize { + let mut i = self.caret; + while i > 0 && !is_word_char(self.nth_char(i - 1)) { + i -= 1; + } + while i > 0 && is_word_char(self.nth_char(i - 1)) { + i -= 1; + } + i + } + + /// Where [`Self::word_right`] would land: skip any separator run right + /// of the caret, then the alphanumeric run after it. + fn word_right_index(&self) -> usize { + let len = self.len_chars(); + let mut i = self.caret; + while i < len && !is_word_char(self.nth_char(i)) { + i += 1; + } + while i < len && is_word_char(self.nth_char(i)) { + i += 1; + } + i + } + + /// The char at char index `idx`. Callers stay within `0..len_chars()`. + fn nth_char(&self, idx: usize) -> char { + self.text.chars().nth(idx).expect("char index within the text") + } +} + +/// Word characters for the word-motion ops: alphanumerics, so `\`, `/`, +/// `.`, and spaces are all separators (design D1). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() +} + +/// A pre-filled field: text set, caret at its end (text-field-editing "A +/// field that opens pre-filled SHALL open with the caret at the end"). +impl From<&str> for LineEdit { + fn from(text: &str) -> LineEdit { + LineEdit::from(text.to_string()) + } +} + +/// Same as the `&str` conversion: text set, caret at its end. +impl From for LineEdit { + fn from(text: String) -> LineEdit { + let mut edit = LineEdit::default(); + edit.set_text_caret_end(text); + edit + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(text: &str, caret: usize) -> LineEdit { + let mut e = LineEdit::from(text); + e.caret = caret; + e + } + + #[test] + fn from_str_prefills_with_the_caret_at_the_end() { + let e = LineEdit::from(r"D:\backup"); + assert_eq!(e.text(), r"D:\backup"); + assert_eq!(e.caret(), e.len_chars(), "a pre-filled field opens with the caret at the end"); + } + + #[test] + fn insert_lands_at_the_caret_mid_line() { + // text-field-editing "Insert in the middle of a pre-filled name": + // `draft.txt` -> `drift.txt` by Backspace + `i` near the caret. The + // scenario's "Left five times" is off by one in the spec: from the + // end of a 9-char name, reaching the `a` takes six Lefts (five would + // delete the `f` instead); the stated result `drift.txt` is what + // pins the model down here. + let mut e = LineEdit::from("draft.txt"); + for _ in 0..6 { + e.left(); + } + e.backspace(); + e.insert('i'); + assert_eq!(e.text(), "drift.txt"); + assert_eq!(e.caret(), 3, "the caret sits after the inserted i"); + } + + #[test] + fn backspace_at_the_start_and_delete_at_the_end_are_noops() { + let mut e = edit("ab", 0); + e.backspace(); + assert_eq!((e.text(), e.caret()), ("ab", 0)); + e.end(); + e.delete(); + assert_eq!((e.text(), e.caret()), ("ab", 2)); + } + + #[test] + fn delete_removes_the_char_after_the_caret() { + // text-field-editing "Home then edit a destination prefix". + let mut e = LineEdit::from(r"D:\backup\2026"); + e.home(); + e.delete(); + e.delete(); + assert_eq!(e.text(), r"\backup\2026"); + e.insert_str(r"\\server\share"); + assert_eq!(e.text(), r"\\server\share\backup\2026"); + } + + #[test] + fn home_end_and_arrows_clamp_at_the_line_ends() { + let mut e = edit("abc", 1); + e.home(); + assert_eq!(e.caret(), 0); + e.left(); + assert_eq!(e.caret(), 0, "left at the start holds"); + e.end(); + assert_eq!(e.caret(), 3); + e.right(); + assert_eq!(e.caret(), 3, "right at the end holds"); + } + + #[test] + fn word_left_jumps_at_backslashes_and_spaces() { + let mut e = LineEdit::from(r"C:\Users\kevin"); + assert_eq!(e.caret(), 14); + e.word_left(); + assert_eq!(e.caret(), 9, "start of `kevin`, just past the backslash"); + e.word_left(); + assert_eq!(e.caret(), 3, "start of `Users`"); + e.word_left(); + assert_eq!(e.caret(), 0, "start of `C`"); + e.word_left(); + assert_eq!(e.caret(), 0, "already at the start"); + + let mut e = LineEdit::from("cd Program Files"); + e.word_left(); + assert_eq!(e.caret(), 11, "start of `Files`"); + e.word_left(); + assert_eq!(e.caret(), 3, "start of `Program`"); + } + + #[test] + fn word_right_jumps_to_word_ends() { + let mut e = edit(r"C:\Users\kevin", 0); + e.word_right(); + assert_eq!(e.caret(), 1, "end of `C`"); + e.word_right(); + assert_eq!(e.caret(), 8, "end of `Users`"); + e.word_right(); + assert_eq!(e.caret(), 14, "end of the line"); + e.word_right(); + assert_eq!(e.caret(), 14, "already at the end"); + } + + #[test] + fn delete_word_back_removes_to_the_previous_boundary() { + // text-field-editing "Word delete": `C:\Users\kevin\docs` at end, + // Ctrl+Backspace -> `C:\Users\kevin\`. + let mut e = LineEdit::from(r"C:\Users\kevin\docs"); + e.delete_word_back(); + assert_eq!(e.text(), r"C:\Users\kevin\"); + assert_eq!(e.caret(), e.len_chars()); + e.delete_word_back(); + assert_eq!(e.text(), r"C:\Users\", "the separator run goes with the next word back"); + } + + #[test] + fn delete_word_forward_removes_to_the_next_boundary() { + let mut e = edit(r"C:\Users\kevin", 0); + e.delete_word_forward(); + assert_eq!(e.text(), r":\Users\kevin", "only the word goes; its trailing separator stays"); + e.delete_word_forward(); + assert_eq!(e.text(), r"\kevin", "the next delete takes the separator and the word together"); + let mut e = edit(r"C:\Users\kevin", 9); + e.delete_word_forward(); + assert_eq!(e.text(), r"C:\Users\"); + } + + #[test] + fn clear_empties_the_field_and_rehomes_the_caret() { + let mut e = LineEdit::from("dir *.txt"); + e.clear(); + assert!(e.is_empty()); + assert_eq!(e.caret(), 0); + } + + #[test] + fn paste_flattens_line_breaks_to_single_spaces() { + let mut e = LineEdit::default(); + e.insert_str("one\r\ntwo\nthree\rfour"); + assert_eq!(e.text(), "one two three four"); + assert_eq!(e.caret(), e.len_chars(), "the caret lands past the pasted text"); + } + + #[test] + fn paste_inserts_at_a_mid_line_caret() { + let mut e = edit(r"D:\", 3); + e.insert_str(r"E:\archive"); + assert_eq!(e.text(), r"D:\E:\archive"); + assert_eq!(e.caret(), e.len_chars()); + } + + #[test] + fn apply_dispatches_every_op() { + let mut e = edit("ab d", 4); + e.apply(&TextEditOp::WordLeft); + assert_eq!(e.caret(), 3); + e.apply(&TextEditOp::Left); + assert_eq!(e.caret(), 2); + e.apply(&TextEditOp::Insert('c')); + assert_eq!(e.text(), "abc d"); + e.apply(&TextEditOp::Home); + e.apply(&TextEditOp::Delete); + assert_eq!(e.text(), "bc d"); + e.apply(&TextEditOp::End); + e.apply(&TextEditOp::Backspace); + assert_eq!(e.text(), "bc "); + e.apply(&TextEditOp::DeleteWordForward); + assert_eq!(e.text(), "bc ", "nothing after the caret to delete"); + e.apply(&TextEditOp::WordRight); + e.apply(&TextEditOp::DeleteWordBack); + assert_eq!(e.text(), ""); + e.apply(&TextEditOp::Paste("x".to_string())); + assert_eq!(e.text(), "x"); + } + + #[test] + fn window_keeps_the_caret_visible_for_long_text() { + // text-field-editing "Long destination scrolls to the caret": a + // 60-char path in a 40-cell field with the caret at the end shows + // the last 40 chars. + let text: String = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567".chars().collect(); + assert_eq!(text.chars().count(), 60); + let mut e = LineEdit::from(text); + assert_eq!(e.window(40), (20, 40), "at the end: the last 40 chars, caret one past the window"); + + e.home(); + assert_eq!(e.window(40), (0, 0), "Home scrolls back to the first 40 chars, caret at the left edge"); + + e.end(); + for _ in 0..21 { + e.left(); + } + assert_eq!(e.caret(), 39); + assert_eq!(e.window(40), (0, 39), "a caret inside the first window needs no scrolling"); + + e.right(); + assert_eq!(e.window(40), (1, 39), "a mid-text caret past the first window scrolls it into view at the right edge"); + } + + #[test] + fn window_of_a_short_line_never_scrolls() { + let e = LineEdit::from("short"); + assert_eq!(e.window(40), (0, 5), "the whole text fits; the caret sits just past it"); + let e = LineEdit::default(); + assert_eq!(e.window(40), (0, 0)); + assert_eq!(e.window(0), (0, 0), "a zero-width field has no window"); + } + + #[test] + fn multibyte_chars_edit_and_move_by_char_not_byte() { + let mut e = LineEdit::from("aé中"); + assert_eq!(e.len_chars(), 3); + e.left(); + e.backspace(); + assert_eq!(e.text(), "a中"); + assert_eq!(e.caret(), 1); + e.insert('é'); + assert_eq!(e.text(), "aé中"); + e.word_left(); + assert_eq!(e.caret(), 0, "é and 中 are alphanumeric word chars"); + } +} diff --git a/crates/filecommand-core/src/listing.rs b/crates/filecommand-core/src/listing.rs index 06acdae..6fc4111 100644 --- a/crates/filecommand-core/src/listing.rs +++ b/crates/filecommand-core/src/listing.rs @@ -5,11 +5,14 @@ use std::cmp::Ordering; use std::ffi::OsString; use std::io; +use std::ops::ControlFlow; use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::time::{Duration, Instant, SystemTime}; use unicode_width::UnicodeWidthStr; +use crate::fs_ops::CancelFlag; + /// What kind of thing an [`Entry`] refers to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { @@ -41,7 +44,7 @@ impl DateTime { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as i64; // [0, 146096] + let doe = z - era * 146_097; // [0, 146096] let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] @@ -62,23 +65,63 @@ impl DateTime { } } -/// One directory entry as displayed in a panel. +/// One directory entry as displayed in a panel. Built only through +/// [`Entry::new`], [`Entry::parent_dir`] and `From`, so the +/// private lowercase [`Entry::sort_key`] is always consistent with `name` +/// (streaming-listing-performance design D4). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { + /// The on-disk name. Readable everywhere; nothing mutates it after + /// construction, since `sort_key` is derived from it once. pub name: OsString, pub kind: EntryKind, pub size: u64, pub modified: Option, + /// The entry carries the Windows hidden/system attribute, or — on other + /// platforms — a dot-prefixed name (hidden-entry-styling; design D1). + /// The renderer dims such entries via the `panel.hidden` role. The + /// synthetic `..` entry is never hidden. + pub hidden: bool, + /// The entry is a link — a junction or symbolic link on Windows, a + /// symbolic link elsewhere — classified by following it once: `kind` + /// and `size` describe the target, while `name`, `modified` and + /// `hidden` are the link's own (panel-navigation "Directory links list + /// as directories"; link-aware-listing design D1/D3). Internal only — + /// nothing renders it. Find-file never descends into a directory link, + /// and F8 on one asks a single confirmation because Delete removes only + /// the link (design D4/D5). The synthetic `..` entry is never a link. + pub link: bool, + /// `name` lowercased once at construction (`to_string_lossy()` then + /// `str::to_lowercase`): the key every name-based comparator and the + /// quick filter read instead of allocating per comparison + /// (streaming-listing-performance design D4; panel-navigation "Large + /// directories stream progressively and stay responsive"). Private so + /// no struct literal can build an `Entry` whose key disagrees with its + /// name. + sort_key: Box, } impl Entry { + /// The one way to build an entry from its parts; computes the lowercase + /// sort key once (design D4). + pub fn new(name: impl Into, kind: EntryKind, size: u64, modified: Option, hidden: bool, link: bool) -> Entry { + let name = name.into(); + let sort_key = name.to_string_lossy().to_lowercase().into_boxed_str(); + Entry { name, kind, size, modified, hidden, link, sort_key } + } + pub fn parent_dir() -> Entry { - Entry { name: OsString::from(".."), kind: EntryKind::ParentDir, size: 0, modified: None } + Entry::new("..", EntryKind::ParentDir, 0, None, false, false) } pub fn is_dir_like(&self) -> bool { matches!(self.kind, EntryKind::Directory | EntryKind::ParentDir) } + + /// The lowercase name key computed when the entry was built (design D4). + pub fn sort_key(&self) -> &str { + &self.sort_key + } } /// Lossy UTF-8 rendering of a possibly-non-Unicode file name. @@ -139,10 +182,6 @@ pub fn sort_arrow(descending: bool) -> &'static str { } } -fn lower_name(entry: &Entry) -> String { - entry.name.to_string_lossy().to_lowercase() -} - /// The DOS-style extension of a name: everything after the last `.`, except /// that a leading dot is part of the name (`.gitignore` has no extension). pub fn extension_of(name: &str) -> &str { @@ -153,28 +192,30 @@ pub fn extension_of(name: &str) -> &str { } /// Case-insensitive name order. Directories and files interleave, matching -/// classic Norton Commander behavior. +/// classic Norton Commander behavior. Compares the precomputed +/// [`Entry::sort_key`]s — no allocation per comparison (streaming-listing- +/// performance design D4). pub fn cmp_by_name(a: &Entry, b: &Entry) -> Ordering { - lower_name(a).cmp(&lower_name(b)) + a.sort_key().cmp(b.sort_key()) } /// Extension order, falling back to name so entries sharing an extension /// stay in a predictable (total) order. pub fn cmp_by_extension(a: &Entry, b: &Entry) -> Ordering { - let (an, bn) = (lower_name(a), lower_name(b)); - extension_of(&an).cmp(extension_of(&bn)).then_with(|| an.cmp(&bn)) + let (an, bn) = (a.sort_key(), b.sort_key()); + extension_of(an).cmp(extension_of(bn)).then_with(|| an.cmp(bn)) } /// Modification-time order, oldest first; entries with no timestamp sort /// before any timestamped entry. Ties break on name. pub fn cmp_by_time(a: &Entry, b: &Entry) -> Ordering { - a.modified.cmp(&b.modified).then_with(|| lower_name(a).cmp(&lower_name(b))) + a.modified.cmp(&b.modified).then_with(|| a.sort_key().cmp(b.sort_key())) } /// Size order, smallest first, ties breaking on name. Directories carry /// size 0 and therefore group at the front. pub fn cmp_by_size(a: &Entry, b: &Entry) -> Ordering { - a.size.cmp(&b.size).then_with(|| lower_name(a).cmp(&lower_name(b))) + a.size.cmp(&b.size).then_with(|| a.sort_key().cmp(b.sort_key())) } /// The comparator for `mode`, before `..`-first and direction handling. @@ -195,13 +236,106 @@ mod sort_tests { use super::*; fn f(name: &str, size: u64, modified: Option) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size, modified } + Entry::new(name, EntryKind::File, size, modified, false, false) } fn dt(day: u8) -> DateTime { DateTime { year: 2026, month: 1, day, hour: 0, minute: 0 } } + #[test] + fn the_sort_key_is_the_lowercased_lossy_name_for_every_constructor() { + assert_eq!(f("ReadMe.TXT", 0, None).sort_key(), "readme.txt"); + assert_eq!(Entry::parent_dir().sort_key(), ".."); + let raw = RawDirEntry { name: "ÄRger.Log".into(), is_dir: false, size: 1, modified: None, hidden: false, link: false }; + assert_eq!(Entry::from(raw).sort_key(), "ärger.log"); + } + + /// streaming-listing-performance design D4 / panel-navigation "Orders + /// are unchanged by precomputed keys": the keyed comparators agree with + /// the allocating ones they replaced, for every mode and direction. + mod key_proptests { + use super::*; + use crate::panel::{cmp_entries, SortDirection}; + use proptest::prelude::*; + + // The previous implementation, kept verbatim as the oracle. + fn lower_name(entry: &Entry) -> String { + entry.name.to_string_lossy().to_lowercase() + } + fn old_cmp_by_name(a: &Entry, b: &Entry) -> Ordering { + lower_name(a).cmp(&lower_name(b)) + } + fn old_cmp_by_extension(a: &Entry, b: &Entry) -> Ordering { + let (an, bn) = (lower_name(a), lower_name(b)); + extension_of(&an).cmp(extension_of(&bn)).then_with(|| an.cmp(&bn)) + } + fn old_cmp_by_time(a: &Entry, b: &Entry) -> Ordering { + a.modified.cmp(&b.modified).then_with(|| lower_name(a).cmp(&lower_name(b))) + } + fn old_cmp_by_size(a: &Entry, b: &Entry) -> Ordering { + a.size.cmp(&b.size).then_with(|| lower_name(a).cmp(&lower_name(b))) + } + fn old_cmp_by_mode(a: &Entry, b: &Entry, mode: SortMode) -> Ordering { + match mode { + SortMode::Name => old_cmp_by_name(a, b), + SortMode::Extension => old_cmp_by_extension(a, b), + SortMode::Time => old_cmp_by_time(a, b), + SortMode::Size => old_cmp_by_size(a, b), + SortMode::Unsorted => Ordering::Equal, + } + } + fn old_cmp_entries(a: &Entry, b: &Entry, mode: SortMode, dir: SortDirection) -> Ordering { + match (a.kind == EntryKind::ParentDir, b.kind == EntryKind::ParentDir) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} + } + let ord = old_cmp_by_mode(a, b, mode); + match dir { + SortDirection::Asc => ord, + SortDirection::Desc => ord.reverse(), + } + } + + fn arb_entry() -> impl Strategy { + // Mixed case, dots, a Greek final sigma (whose lowercase differs + // from its simple case fold), and a short size/time range so + // ties are common. + ( + "[A-Za-z0-9._ ΣσςÄä-]{0,8}", + prop_oneof![Just(EntryKind::File), Just(EntryKind::Directory), Just(EntryKind::ParentDir)], + 0u64..4, + proptest::option::of(1u8..4), + ) + .prop_map(|(name, kind, size, day)| Entry::new(name, kind, size, day.map(dt), false, false)) + } + + fn arb_mode() -> impl Strategy { + prop_oneof![Just(SortMode::Name), Just(SortMode::Extension), Just(SortMode::Time), Just(SortMode::Size), Just(SortMode::Unsorted)] + } + + proptest! { + #[test] + fn keyed_comparators_agree_with_the_allocating_ones(a in arb_entry(), b in arb_entry(), mode in arb_mode(), desc in any::()) { + let dir = if desc { SortDirection::Desc } else { SortDirection::Asc }; + prop_assert_eq!(cmp_by_mode(&a, &b, mode), old_cmp_by_mode(&a, &b, mode)); + prop_assert_eq!(cmp_entries(&a, &b, mode, dir), old_cmp_entries(&a, &b, mode, dir)); + } + + #[test] + fn keyed_sort_orders_match_the_allocating_sort_orders(entries in prop::collection::vec(arb_entry(), 0..12), mode in arb_mode(), desc in any::()) { + let dir = if desc { SortDirection::Desc } else { SortDirection::Asc }; + let mut new = entries.clone(); + new.sort_by(|a, b| cmp_entries(a, b, mode, dir)); + let mut old = entries; + old.sort_by(|a, b| old_cmp_entries(a, b, mode, dir)); + prop_assert_eq!(new, old); + } + } + } + #[test] fn extension_of_ignores_a_leading_dot() { assert_eq!(extension_of("readme.txt"), "txt"); @@ -421,65 +555,281 @@ pub struct RawDirEntry { pub is_dir: bool, pub size: u64, pub modified: Option, + /// The hidden/system flag, computed once by the reader (hidden-entry- + /// styling; design D1): on Windows from the `FILE_ATTRIBUTE_HIDDEN` + /// (0x2) / `FILE_ATTRIBUTE_SYSTEM` (0x4) bits in the enumeration's own + /// metadata, elsewhere from a leading `.` in the name. + pub hidden: bool, + /// The entry is a link (see [`Entry::link`]). When set, `is_dir` and + /// `size` describe the link's target — or `false`/`0` for a link that + /// could not be followed — while `name`, `modified` and `hidden` are the + /// link's own (link-aware-listing design D1/D2). + pub link: bool, +} + +/// One directory read: the entries that could be classified, plus how many +/// were skipped because their own metadata could not be read (panel- +/// navigation "One unreadable entry does not fail a listing"; link-aware- +/// listing design D6). Skipping is per entry; a failure of the enumeration +/// itself is still the `Err` of [`FsReader::read_dir`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RawDirListing { + pub entries: Vec, + pub unreadable: usize, } /// A narrow seam over directory reads. Exists so later milestones can inject /// deterministic errors (permission denied, I/O failure, long-path edge /// cases) without touching the real filesystem. pub trait FsReader { - fn read_dir(&self, path: &Path) -> io::Result>; + fn read_dir(&self, path: &Path) -> io::Result; + + /// Hand `path`'s entries to `f` one at a time, as the enumeration + /// yields them, until it returns `ControlFlow::Break` or the directory + /// is exhausted. Returns how many entries were skipped as unreadable + /// (the [`RawDirListing::unreadable`] count; link-aware-listing design + /// D6). The default collects through [`FsReader::read_dir`], so fakes + /// keep implementing only that; [`StdFsReader`] overrides it with the + /// real per-entry loop so the listing worker can deliver entries while + /// the OS is still enumerating, and stop mid-enumeration when its cancel + /// flag is set (streaming-listing-performance design D1). + fn for_each_entry(&self, path: &Path, f: &mut dyn FnMut(RawDirEntry) -> ControlFlow<()>) -> io::Result { + let listing = self.read_dir(path)?; + for entry in listing.entries { + if f(entry).is_break() { + break; + } + } + Ok(listing.unreadable) + } +} + +/// What a reader learns about one enumerated entry from a single metadata +/// record — the enumeration's own (`DirEntry::metadata`, which never follows +/// a link) or the target's (`std::fs::metadata`, which does). The pure input +/// of [`classify_entry`], so every classification branch is testable without +/// a filesystem (link-aware-listing design D8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EntryFacts { + pub is_dir: bool, + pub is_symlink: bool, + pub len: u64, + pub modified: Option, + pub hidden: bool, +} + +/// Classify one enumerated entry (link-aware-listing design D1/D2/D8): +/// +/// - `own` is `Err` → `None`: the entry's own metadata could not be read, so +/// it is skipped and the caller counts it as unreadable (design D6). +/// - not a link → exactly the entry's own facts, no extra I/O. +/// - a link → `follow()` is called once. On `Ok`, `is_dir` and (for a file +/// target) `size` come from the target; on `Err` — dangling, or a target +/// that cannot be reached — the entry lists as a non-enterable file of +/// size 0. Either way `modified` and `hidden` stay the link's own, and +/// `link` is set. +pub fn classify_entry(name: OsString, own: io::Result, follow: impl FnOnce() -> io::Result) -> Option { + let own = own.ok()?; + let (is_dir, size) = if !own.is_symlink { + (own.is_dir, if own.is_dir { 0 } else { own.len }) + } else { + match follow() { + Ok(target) => (target.is_dir, if target.is_dir { 0 } else { target.len }), + Err(_) => (false, 0), + } + }; + Some(RawDirEntry { name, is_dir, size, modified: own.modified, hidden: own.hidden, link: own.is_symlink }) } /// The real filesystem, via `std::fs`, routed through [`to_long_path`]. #[derive(Debug, Default, Clone, Copy)] pub struct StdFsReader; +/// The hidden/system flag for one enumerated entry, computed from the +/// enumeration's own metadata — never a per-file stat (hidden-entry-styling; +/// design D1). The synthetic `..` entry never passes through here; it is +/// built by [`Entry::parent_dir`] with `hidden: false`. +#[cfg(windows)] +fn is_hidden(_name: &std::ffi::OsStr, metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4; + metadata.file_attributes() & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM) != 0 +} + +/// The non-Windows rule: a leading `.` in the name (design D1). +#[cfg(not(windows))] +fn is_hidden(name: &std::ffi::OsStr, _metadata: &std::fs::Metadata) -> bool { + name.to_string_lossy().starts_with('.') +} + +/// The [`EntryFacts`] of one `std::fs::Metadata` record for the entry named +/// `name`. `is_symlink` is true for every name-surrogate reparse point on +/// Windows (junctions and symbolic links alike) and for symbolic links +/// elsewhere; `hidden` goes through the existing per-platform rule. +fn facts_of(name: &std::ffi::OsStr, metadata: &std::fs::Metadata) -> EntryFacts { + EntryFacts { + is_dir: metadata.is_dir(), + is_symlink: metadata.file_type().is_symlink(), + len: metadata.len(), + modified: metadata.modified().ok(), + hidden: is_hidden(name, metadata), + } +} + impl FsReader for StdFsReader { - fn read_dir(&self, path: &Path) -> io::Result> { + /// Enumerate `path` into one listing by collecting through + /// [`StdFsReader::for_each_entry`] — the `Vec` API Tree mode, find-file + /// and the Info gatherer keep using (streaming-listing-performance + /// design D1). + fn read_dir(&self, path: &Path) -> io::Result { + let mut entries = Vec::new(); + let unreadable = self.for_each_entry(path, &mut |raw| { + entries.push(raw); + ControlFlow::Continue(()) + })?; + Ok(RawDirListing { entries, unreadable }) + } + + /// The real per-entry loop over `std::fs::read_dir`, handing each entry + /// to `f` as the OS returns it (design D1). An error from the + /// enumeration itself (`entry?`: `FindNextFileW`/`readdir` failing) is + /// still returned as `Err` — it identifies no single entry, and + /// continuing could repeat it forever. An entry whose own metadata + /// cannot be read is skipped and counted instead of failing the whole + /// listing (link-aware-listing design D6). A link is followed with one + /// `std::fs::metadata` call so that a junction or symlink to a directory + /// lists as a directory (design D1). + fn for_each_entry(&self, path: &Path, f: &mut dyn FnMut(RawDirEntry) -> ControlFlow<()>) -> io::Result { let long = to_long_path(path); - let mut out = Vec::new(); + let mut unreadable = 0usize; for entry in std::fs::read_dir(&long)? { let entry = entry?; - let metadata = entry.metadata()?; - out.push(RawDirEntry { - name: entry.file_name(), - is_dir: metadata.is_dir(), - size: if metadata.is_dir() { 0 } else { metadata.len() }, - modified: metadata.modified().ok(), - }); + let name = entry.file_name(); + let own = entry.metadata().map(|m| facts_of(&name, &m)); + let follow = || std::fs::metadata(long.join(&name)).map(|m| facts_of(&name, &m)); + match classify_entry(name.clone(), own, follow) { + Some(raw) => { + if f(raw).is_break() { + break; + } + } + None => unreadable += 1, + } } - Ok(out) + Ok(unreadable) } } impl From for Entry { fn from(raw: RawDirEntry) -> Self { - Entry { - name: raw.name, - kind: if raw.is_dir { EntryKind::Directory } else { EntryKind::File }, - size: raw.size, - modified: raw.modified.map(DateTime::from_system_time), - } + Entry::new( + raw.name, + if raw.is_dir { EntryKind::Directory } else { EntryKind::File }, + raw.size, + raw.modified.map(DateTime::from_system_time), + raw.hidden, + raw.link, + ) } } -/// Read a directory and deliver entries in chunks via `on_chunk`, followed -/// by a final `true` on completion. Designed to be called from a worker -/// thread by `filecommand-tui`; core never spawns threads itself. -pub fn list_dir_chunked)>( +/// What [`list_dir_streaming`] delivered: the entries handed to `on_chunk`, +/// and the entries the reader skipped as unreadable (link-aware-listing +/// design D6). The caller reports the latter on the mini-status. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ListingTotals { + pub entries: usize, + pub unreadable: usize, +} + +/// When [`list_dir_streaming`] hands the batch it is accumulating to its +/// caller (streaming-listing-performance design D2; panel-navigation "Large +/// directories stream progressively and stay responsive"): once the batch +/// holds `clamp(sent / 4, min, max)` entries — the size grows with what has +/// already been sent, so a fast enumeration produces O(log n) batches and +/// the panel's per-batch merge stays near-linear overall, while `max` +/// bounds the largest single merge — or once `max_age` has passed since the +/// last flush when the next entry arrives, so slow media still show +/// progress at ~20 Hz. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BatchPolicy { + /// The smallest batch that is flushed on count alone. + pub min: usize, + /// The largest batch ever accumulated. + pub max: usize, + /// How long a non-empty batch may wait, measured between flushes. + pub max_age: Duration, +} + +impl Default for BatchPolicy { + fn default() -> Self { + BatchPolicy { min: 256, max: 16_384, max_age: Duration::from_millis(50) } + } +} + +impl BatchPolicy { + /// The count bound for the next batch after `sent` entries have gone out. + pub fn target_len(&self, sent: usize) -> usize { + (sent / 4).clamp(self.min, self.max.max(self.min)) + } + + /// Whether a batch of `batch_len` entries, with `sent` entries already + /// delivered and `since_last_flush` elapsed since the previous flush, + /// goes out now. Pure, so it is tested without a clock; an empty batch + /// is never flushed. + pub fn should_flush(&self, batch_len: usize, sent: usize, since_last_flush: Duration) -> bool { + batch_len > 0 && (batch_len >= self.target_len(sent) || since_last_flush >= self.max_age) + } +} + +/// Enumerate a directory through `reader` and deliver its entries to +/// `on_chunk` in batches while the enumeration is still running, returning +/// the number delivered and the number the reader skipped as unreadable. +/// Designed to be called from a worker thread by `filecommand-tui`; core +/// never spawns threads itself (streaming-listing-performance design D1/D2). +/// +/// A batch is flushed when `policy` says so — `now` is the clock it is +/// judged against, injected so the policy's time bound is testable without +/// sleeping — and whatever remains is flushed once the enumeration ends. +/// `cancel` is the listing's stop signal (panel-navigation "Superseded +/// directory listings are discarded and cancelled"; listing-request-guard +/// design D5): it is checked before the read and as every entry arrives, and +/// once set the enumeration is broken off and nothing further is delivered, +/// not even the partial batch — the returned count then covers only what +/// was delivered. +pub fn list_dir_streaming)>( reader: &dyn FsReader, path: &Path, - chunk_size: usize, + cancel: &CancelFlag, + policy: BatchPolicy, + now: &dyn Fn() -> Instant, mut on_chunk: F, -) -> io::Result { - let raw = reader.read_dir(path)?; - let mut total = 0usize; - for chunk in raw.chunks(chunk_size.max(1)) { - let entries: Vec = chunk.iter().cloned().map(Entry::from).collect(); - total += entries.len(); - on_chunk(entries); +) -> io::Result { + if cancel.is_cancelled() { + return Ok(ListingTotals::default()); + } + let mut sent = 0usize; + let mut batch: Vec = Vec::with_capacity(policy.min); + let mut last_flush = now(); + let unreadable = reader.for_each_entry(path, &mut |raw| { + if cancel.is_cancelled() { + return ControlFlow::Break(()); + } + batch.push(Entry::from(raw)); + let at = now(); + if policy.should_flush(batch.len(), sent, at.saturating_duration_since(last_flush)) { + sent += batch.len(); + on_chunk(std::mem::replace(&mut batch, Vec::with_capacity(policy.min))); + last_flush = at; + } + ControlFlow::Continue(()) + })?; + if !cancel.is_cancelled() && !batch.is_empty() { + sent += batch.len(); + on_chunk(batch); } - Ok(total) + Ok(ListingTotals { entries: sent, unreadable }) } // --------------------------------------------------------------------- @@ -494,7 +844,7 @@ pub fn list_dir_chunked)>( /// once per node expansion, never eagerly for the whole tree. pub fn list_child_dirs(reader: &dyn FsReader, path: &Path) -> io::Result> { let raw = reader.read_dir(path)?; - let mut dirs: Vec = raw.into_iter().filter(|e| e.is_dir).map(Entry::from).collect(); + let mut dirs: Vec = raw.entries.into_iter().filter(|e| e.is_dir).map(Entry::from).collect(); dirs.sort_by(cmp_by_name); Ok(dirs) } @@ -532,20 +882,33 @@ pub struct FindMatch { /// one call; `filecommand-tui` runs it on a worker thread and streams /// `on_match` results back to the UI as they arrive so the walk never /// blocks the UI thread (find-file "Non-blocking search with static -/// progress"). -pub fn find_in_subtree(reader: &dyn FsReader, root: &Path, pattern: &str, mut on_match: F) { +/// progress"). `cancel` is the walk's stop signal (find-file "Dismissed or +/// superseded searches stop"; listing-request-guard design D5): it is +/// checked before each directory read and between the entries examined, +/// and once set the walk returns without reading another directory or +/// reporting another match. A directory *link* is matched by name like any +/// entry but never descended into: links were leaves before they listed as +/// directories, and Windows ships junction cycles such as +/// `C:\ProgramData\Application Data` (link-aware-listing design D4). +pub fn find_in_subtree(reader: &dyn FsReader, root: &Path, pattern: &str, cancel: &CancelFlag, mut on_match: F) { let needle = pattern.to_lowercase(); // Relative sub-paths pending a read, root itself represented as "". let mut stack: Vec = vec![PathBuf::new()]; while let Some(rel_dir) = stack.pop() { + if cancel.is_cancelled() { + return; + } let abs_dir = if rel_dir.as_os_str().is_empty() { root.to_path_buf() } else { root.join(&rel_dir) }; - let Ok(raw_entries) = reader.read_dir(&abs_dir) else { continue }; - for raw in raw_entries { + let Ok(listing) = reader.read_dir(&abs_dir) else { continue }; + for raw in listing.entries { + if cancel.is_cancelled() { + return; + } let rel_path = rel_dir.join(&raw.name); if !needle.is_empty() && raw.name.to_string_lossy().to_lowercase().contains(&needle) { on_match(FindMatch { relative_path: rel_path.clone(), entry: Entry::from(raw.clone()) }); } - if raw.is_dir { + if raw.is_dir && !raw.link { stack.push(rel_path); } } @@ -598,7 +961,7 @@ mod tests { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; let raw = OsStr::from_bytes(&[0x66, 0x6f, 0x80, 0x6f]); // "fo\x80o", invalid utf8 - let entry = Entry { name: raw.to_os_string(), kind: EntryKind::File, size: 0, modified: None }; + let entry = Entry::new(raw.to_os_string(), EntryKind::File, 0, None, false, false); let shown = display_name_lossy(&entry); assert!(shown.contains('\u{FFFD}')); assert_eq!(display_width(&shown), shown.chars().count()); @@ -618,7 +981,7 @@ mod tests { #[test] fn entry_status_line_marks_dirs_and_parent() { - let dir = Entry { name: "sub".into(), kind: EntryKind::Directory, size: 0, modified: None }; + let dir = Entry::new("sub", EntryKind::Directory, 0, None, false, false); assert_eq!(entry_status_line(&dir), "sub SUB-DIR"); assert_eq!(entry_status_line(&Entry::parent_dir()), ".. UP--DIR"); } @@ -626,7 +989,7 @@ mod tests { #[test] fn entry_status_parts_splits_file_fields() { let dt = DateTime { year: 2026, month: 1, day: 2, hour: 3, minute: 4 }; - let file = Entry { name: "a.txt".into(), kind: EntryKind::File, size: 100, modified: Some(dt) }; + let file = Entry::new("a.txt", EntryKind::File, 100, Some(dt), false, false); let (name, size, date, time) = entry_status_parts(&file); assert_eq!(name, "a.txt"); assert_eq!(size.as_deref(), Some("100")); @@ -636,7 +999,7 @@ mod tests { #[test] fn entry_status_parts_marks_dirs_and_parent_with_no_date_or_time() { - let dir = Entry { name: "sub".into(), kind: EntryKind::Directory, size: 0, modified: None }; + let dir = Entry::new("sub", EntryKind::Directory, 0, None, false, false); assert_eq!(entry_status_parts(&dir), ("sub".to_string(), Some("SUB-DIR".to_string()), None, None)); assert_eq!(entry_status_parts(&Entry::parent_dir()), ("..".to_string(), Some("UP--DIR".to_string()), None, None)); } @@ -683,21 +1046,229 @@ mod tests { struct FakeReader(Vec); impl FsReader for FakeReader { - fn read_dir(&self, _path: &Path) -> io::Result> { - Ok(self.0.clone()) + fn read_dir(&self, _path: &Path) -> io::Result { + Ok(RawDirListing { entries: self.0.clone(), unreadable: 0 }) } } + /// A clock that never advances, so only the count bound governs. + fn fixed_clock() -> impl Fn() -> Instant { + let t = Instant::now(); + move || t + } + + /// A clock that advances `step` on every reading. + fn stepping_clock(step: Duration) -> impl Fn() -> Instant { + let t = Instant::now(); + let reads = std::cell::Cell::new(0u32); + move || { + reads.set(reads.get() + 1); + t + step * reads.get() + } + } + + /// Batches of exactly `size` entries, no time bound. + fn fixed_batches(size: usize) -> BatchPolicy { + BatchPolicy { min: size, max: size, max_age: Duration::MAX } + } + + /// Everything `list_dir_streaming` delivers under `policy` on a fixed + /// clock: the batches in order, and the totals. + fn stream_all(reader: &dyn FsReader, cancel: &CancelFlag, policy: BatchPolicy) -> (Vec>, ListingTotals) { + let mut batches = Vec::new(); + let totals = list_dir_streaming(reader, Path::new("."), cancel, policy, &fixed_clock(), |b| batches.push(b)).unwrap(); + (batches, totals) + } + + fn raw_files(n: usize) -> Vec { + (0..n).map(|i| RawDirEntry { name: format!("f{i}").into(), is_dir: false, size: 0, modified: None, hidden: false, link: false }).collect() + } + #[test] - fn list_dir_chunked_delivers_chunks_and_total() { - let entries: Vec = (0..10) - .map(|i| RawDirEntry { name: format!("f{i}").into(), is_dir: false, size: 0, modified: None }) - .collect(); - let reader = FakeReader(entries); - let mut chunks = Vec::new(); - let total = list_dir_chunked(&reader, Path::new("."), 3, |c| chunks.push(c.len())).unwrap(); - assert_eq!(total, 10); - assert_eq!(chunks, vec![3, 3, 3, 1]); + fn list_dir_streaming_delivers_batches_and_total() { + let reader = FakeReader(raw_files(10)); + let (batches, total) = stream_all(&reader, &CancelFlag::new(), fixed_batches(3)); + assert_eq!(total, ListingTotals { entries: 10, unreadable: 0 }); + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![3, 3, 3, 1]); + let names: Vec = batches.concat().iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); + assert_eq!(names, (0..10).map(|i| format!("f{i}")).collect::>(), "enumeration order, nothing lost or duplicated"); + } + + // ----------------------------------------------------------------- + // streaming-listing-performance: the batch policy (design D2) and the + // streaming enumeration (design D1; panel-navigation "First entries + // appear before enumeration finishes") + // ----------------------------------------------------------------- + + #[test] + fn batch_policy_count_bound_grows_with_what_was_sent_between_min_and_max() { + let p = BatchPolicy::default(); + assert_eq!(p.target_len(0), 256, "the floor applies until 1,024 entries have gone out"); + assert_eq!(p.target_len(1_023), 256); + assert_eq!(p.target_len(1_024), 256); + assert_eq!(p.target_len(4_000), 1_000, "sent / 4"); + assert_eq!(p.target_len(65_536), 16_384, "the cap"); + assert_eq!(p.target_len(10_000_000), 16_384); + } + + #[test] + fn batch_policy_flushes_on_count_or_age_and_never_an_empty_batch() { + let p = BatchPolicy::default(); + let fresh = Duration::ZERO; + assert!(!p.should_flush(255, 0, fresh), "below the floor, nothing goes out on count"); + assert!(p.should_flush(256, 0, fresh), "the floor is reached"); + assert!(!p.should_flush(256, 4_000, fresh), "with 4,000 sent the bound is 1,000"); + assert!(p.should_flush(1_000, 4_000, fresh)); + assert!(!p.should_flush(1, 0, Duration::from_millis(49)), "younger than the age bound"); + assert!(p.should_flush(1, 0, Duration::from_millis(50)), "the age bound is inclusive"); + assert!(p.should_flush(1, 1_000_000, Duration::from_secs(1))); + assert!(!p.should_flush(0, 0, Duration::from_secs(10)), "an empty batch is never flushed, however old"); + } + + #[test] + fn fast_enumeration_produces_geometrically_growing_batches() { + let n = 100_000; + let reader = FakeReader(raw_files(n)); + let (batches, total) = stream_all(&reader, &CancelFlag::new(), BatchPolicy::default()); + assert_eq!(total.entries, n); + assert_eq!(batches.iter().map(Vec::len).sum::(), n); + let sizes: Vec = batches.iter().map(Vec::len).collect(); + assert_eq!(&sizes[..5], &[256, 256, 256, 256, 256], "the floor until 1,024 have gone out, then 1,280 / 4 = 320"); + assert_eq!(sizes[5], 320); + let mut sent = 0; + for (i, &size) in sizes.iter().enumerate() { + if i + 1 < sizes.len() { + assert_eq!(size, BatchPolicy::default().target_len(sent), "batch {i} is exactly the policy's bound for {sent} sent"); + } else { + assert!(size <= BatchPolicy::default().target_len(sent), "the remainder is at most the bound"); + } + sent += size; + } + assert!(sizes.len() < 50, "O(log n) batches for 100k entries, not ~390 fixed chunks: {}", sizes.len()); + assert!(sizes.iter().all(|&s| s <= 16_384)); + } + + #[test] + fn a_slow_enumeration_flushes_on_age_so_every_entry_shows_promptly() { + // The clock advances 60 ms per reading, so by the time each entry + // arrives the 50 ms bound has passed since the last flush: every + // entry goes out on its own (panel-navigation "First entries appear + // before enumeration finishes"). + let reader = FakeReader(raw_files(5)); + let mut batches = Vec::new(); + let total = list_dir_streaming( + &reader, + Path::new("."), + &CancelFlag::new(), + BatchPolicy::default(), + &stepping_clock(Duration::from_millis(60)), + |b| batches.push(b.len()), + ) + .unwrap(); + assert_eq!(total.entries, 5); + assert_eq!(batches, vec![1, 1, 1, 1, 1]); + } + + /// A reader that hands out entries one by one and counts how many it + /// produced, so a test can show enumeration stopped at the cancel. + struct CountingStreamReader { + entries: Vec, + produced: std::cell::Cell, + } + + impl FsReader for CountingStreamReader { + fn read_dir(&self, _path: &Path) -> io::Result { + unreachable!("the streaming path must go through for_each_entry") + } + fn for_each_entry(&self, _path: &Path, f: &mut dyn FnMut(RawDirEntry) -> ControlFlow<()>) -> io::Result { + for e in &self.entries { + self.produced.set(self.produced.get() + 1); + if f(e.clone()).is_break() { + break; + } + } + Ok(0) + } + } + + #[test] + fn a_flag_set_mid_enumeration_stops_reading_and_sends_nothing_further() { + let reader = CountingStreamReader { entries: raw_files(10), produced: std::cell::Cell::new(0) }; + let cancel = CancelFlag::new(); + let mut batches = Vec::new(); + let total = list_dir_streaming(&reader, Path::new("."), &cancel, fixed_batches(3), &fixed_clock(), |b| { + batches.push(b.len()); + // The reducer's `CancelRequest` lands while the next batch is + // accumulating. + cancel.cancel(); + }) + .unwrap(); + assert_eq!(batches, vec![3], "the batch in flight when the flag was set is the last one; the partial batch is dropped"); + assert_eq!(total.entries, 3, "the count covers only what was delivered"); + assert_eq!(reader.produced.get(), 4, "the entry after the flush saw the flag and broke the enumeration; nothing more was read"); + } + + #[test] + fn the_default_for_each_entry_streams_a_read_dir_result_and_honours_break() { + let reader = FakeReader(raw_files(5)); + let mut seen = 0; + let unreadable = reader + .for_each_entry(Path::new("."), &mut |_| { + seen += 1; + if seen == 2 { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .unwrap(); + assert_eq!(seen, 2); + assert_eq!(unreadable, 0); + } + + // ----------------------------------------------------------------- + // hidden-entry-styling: the hidden flag (tasks.md 1.3) + // ----------------------------------------------------------------- + + fn raw_hidden(name: &str, is_dir: bool) -> RawDirEntry { + RawDirEntry { name: name.into(), is_dir, size: 0, modified: None, hidden: true, link: false } + } + + #[test] + fn from_raw_entry_copies_the_hidden_flag() { + let file = Entry::from(raw_hidden("desktop.ini", false)); + assert!(file.hidden && file.kind == EntryKind::File); + let dir = Entry::from(raw_hidden("System Volume Information", true)); + assert!(dir.hidden && dir.kind == EntryKind::Directory); + assert!(!Entry::from(raw_file("readme.txt")).hidden); + } + + #[test] + fn parent_dir_is_never_hidden() { + // panel-navigation "Entry row rendering": "the `..` entry is never + // hidden" — the synthetic parent carries no attributes at all. + assert!(!Entry::parent_dir().hidden); + } + + #[test] + fn hidden_flags_survive_enumeration_and_sorting() { + // Entries read through the FsReader seam carry the flag into + // `Entry`, and sorting reorders whole entries without dropping it + // (hidden-entry-styling tasks.md 1.3). + let reader = FakeReader(vec![raw_file("zeta.txt"), raw_hidden("desktop.ini", false), raw_hidden(".git", true), raw_dir("alpha")]); + let (batches, total) = stream_all(&reader, &CancelFlag::new(), BatchPolicy::default()); + let mut entries: Vec = batches.concat(); + assert_eq!(total.entries, 4); + let flags: Vec<(&str, bool)> = entries.iter().map(|e| (e.name.to_str().unwrap(), e.hidden)).collect(); + assert_eq!(flags, vec![("zeta.txt", false), ("desktop.ini", true), (".git", true), ("alpha", false)], "enumeration order"); + + entries.sort_by(|a, b| cmp_by_mode(a, b, SortMode::Name)); + let flags: Vec<(&str, bool)> = entries.iter().map(|e| (e.name.to_str().unwrap(), e.hidden)).collect(); + assert_eq!( + flags, + vec![(".git", true), ("alpha", false), ("desktop.ini", true), ("zeta.txt", false)], + "the flag follows its entry through the sort" + ); } // ----------------------------------------------------------------- @@ -710,16 +1281,20 @@ mod tests { struct TreeReader(std::collections::HashMap>); impl FsReader for TreeReader { - fn read_dir(&self, path: &Path) -> io::Result> { - self.0.get(path).cloned().ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such fixture directory")) + fn read_dir(&self, path: &Path) -> io::Result { + self.0 + .get(path) + .cloned() + .map(|entries| RawDirListing { entries, unreadable: 0 }) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such fixture directory")) } } fn raw_dir(name: &str) -> RawDirEntry { - RawDirEntry { name: name.into(), is_dir: true, size: 0, modified: None } + RawDirEntry { name: name.into(), is_dir: true, size: 0, modified: None, hidden: false, link: false } } fn raw_file(name: &str) -> RawDirEntry { - RawDirEntry { name: name.into(), is_dir: false, size: 0, modified: None } + RawDirEntry { name: name.into(), is_dir: false, size: 0, modified: None, hidden: false, link: false } } #[test] @@ -765,7 +1340,7 @@ mod tests { let reader = TreeReader(fixture); let mut matches = Vec::new(); - find_in_subtree(&reader, Path::new("/root"), "report", |m| matches.push(m)); + find_in_subtree(&reader, Path::new("/root"), "report", &CancelFlag::new(), |m| matches.push(m)); matches.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); let paths: Vec = matches.iter().map(|m| m.relative_path.clone()).collect(); @@ -787,7 +1362,7 @@ mod tests { let reader = TreeReader(fixture); let mut matches = Vec::new(); - find_in_subtree(&reader, Path::new("/root"), "readme", |m| matches.push(m)); + find_in_subtree(&reader, Path::new("/root"), "readme", &CancelFlag::new(), |m| matches.push(m)); assert_eq!(matches.len(), 1); assert_eq!(matches[0].entry.name, OsString::from("ReadMe.MD"), "the returned entry must keep the original OsString name"); } @@ -800,7 +1375,7 @@ mod tests { let reader = TreeReader(fixture); let mut matches = Vec::new(); - find_in_subtree(&reader, Path::new("/root"), "nonexistent-pattern", |m| matches.push(m)); + find_in_subtree(&reader, Path::new("/root"), "nonexistent-pattern", &CancelFlag::new(), |m| matches.push(m)); assert!(matches.is_empty()); } @@ -816,11 +1391,295 @@ mod tests { let reader = TreeReader(fixture); let mut matches = Vec::new(); - find_in_subtree(&reader, Path::new("/root"), "target", |m| matches.push(m)); + find_in_subtree(&reader, Path::new("/root"), "target", &CancelFlag::new(), |m| matches.push(m)); assert_eq!(matches.len(), 1); assert_eq!(matches[0].relative_path, PathBuf::from("ok").join("target_match.txt")); } + // ----------------------------------------------------------------- + // listing-request-guard: the stop signal (find-file "Esc stops the + // walk"; panel-navigation "Superseded reader stops"; design D5) + // ----------------------------------------------------------------- + + /// A reader that counts how many directories it has been asked to read, + /// so a test can show the walk stopped asking once cancelled. + struct CountingReader { + inner: TreeReader, + reads: std::cell::Cell, + } + + impl FsReader for CountingReader { + fn read_dir(&self, path: &Path) -> io::Result { + self.reads.set(self.reads.get() + 1); + self.inner.read_dir(path) + } + } + + #[test] + fn find_in_subtree_stops_reading_directories_once_cancelled_from_inside_on_match() { + // Three levels, each with a match and a subdirectory below it. The + // flag is set from inside the first `on_match`, as the TUI would + // when the reducer's `CancelRequest` lands mid-walk: no further + // directory is read and no further match is reported. + let mut fixture = std::collections::HashMap::new(); + fixture.insert(PathBuf::from("/root"), vec![raw_file("report1.txt"), raw_dir("a")]); + fixture.insert(PathBuf::from("/root/a"), vec![raw_file("report2.txt"), raw_dir("b")]); + fixture.insert(PathBuf::from("/root/a/b"), vec![raw_file("report3.txt")]); + let reader = CountingReader { inner: TreeReader(fixture), reads: std::cell::Cell::new(0) }; + + let cancel = CancelFlag::new(); + let mut matches = Vec::new(); + find_in_subtree(&reader, Path::new("/root"), "report", &cancel, |m| { + matches.push(m); + cancel.cancel(); + }); + assert_eq!(matches.len(), 1, "nothing is reported after the flag is set"); + assert_eq!(reader.reads.get(), 1, "no directory is read after the flag is set"); + } + + #[test] + fn find_in_subtree_with_a_pre_set_flag_reads_nothing() { + let mut fixture = std::collections::HashMap::new(); + fixture.insert(PathBuf::from("/root"), vec![raw_file("report.txt")]); + let reader = CountingReader { inner: TreeReader(fixture), reads: std::cell::Cell::new(0) }; + let cancel = CancelFlag::new(); + cancel.cancel(); + let mut matches = Vec::new(); + find_in_subtree(&reader, Path::new("/root"), "report", &cancel, |m| matches.push(m)); + assert!(matches.is_empty()); + assert_eq!(reader.reads.get(), 0, "the check precedes the first directory read"); + } + + #[test] + fn list_dir_streaming_with_a_pre_set_flag_delivers_nothing() { + let reader = CountingStreamReader { entries: raw_files(10), produced: std::cell::Cell::new(0) }; + let cancel = CancelFlag::new(); + cancel.cancel(); + let (batches, total) = stream_all(&reader, &cancel, fixed_batches(3)); + assert!(batches.is_empty(), "a superseded reader delivers no batch"); + assert_eq!(total.entries, 0); + assert_eq!(reader.produced.get(), 0, "the check precedes the enumeration"); + } + + #[test] + fn list_dir_streaming_stops_delivering_once_cancelled_between_batches() { + let reader = FakeReader(raw_files(10)); + let cancel = CancelFlag::new(); + let mut chunks = Vec::new(); + let total = list_dir_streaming(&reader, Path::new("."), &cancel, fixed_batches(3), &fixed_clock(), |c| { + chunks.push(c.len()); + cancel.cancel(); + }) + .unwrap(); + assert_eq!(chunks, vec![3], "the batch in flight when the flag was set is the last one"); + assert_eq!(total.entries, 3, "the count covers only what was delivered"); + } + + // ----------------------------------------------------------------- + // link-aware-listing: link classification and skipped entries + // (panel-navigation "Directory links list as directories", "One + // unreadable entry does not fail a listing"; design D1/D2/D6/D8) + // ----------------------------------------------------------------- + + fn facts(is_dir: bool, is_symlink: bool, len: u64, secs: u64, hidden: bool) -> EntryFacts { + EntryFacts { is_dir, is_symlink, len, modified: Some(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs)), hidden } + } + + fn not_followed() -> io::Result { + panic!("a non-link entry must never be followed") + } + + #[test] + fn classify_entry_skips_an_entry_whose_own_metadata_cannot_be_read() { + let own = Err(io::Error::new(io::ErrorKind::NotFound, "raced deletion")); + assert_eq!(classify_entry("gone".into(), own, not_followed), None); + } + + #[test] + fn classify_entry_keeps_a_plain_file_and_a_plain_directory_as_they_are() { + let file = classify_entry("a.txt".into(), Ok(facts(false, false, 42, 10, false)), not_followed).unwrap(); + assert_eq!( + file, + RawDirEntry { + name: "a.txt".into(), + is_dir: false, + size: 42, + modified: facts(false, false, 42, 10, false).modified, + hidden: false, + link: false + } + ); + + let dir = classify_entry("sub".into(), Ok(facts(true, false, 4096, 20, true)), not_followed).unwrap(); + assert!(dir.is_dir && !dir.link && dir.hidden); + assert_eq!(dir.size, 0, "a directory's own length is never shown as a size"); + } + + #[test] + fn classify_entry_lists_a_link_to_a_directory_as_a_directory_with_the_links_own_date_and_hidden_flag() { + let link_facts = facts(false, true, 0, 10, true); + let target = facts(true, false, 4096, 99, false); + let raw = classify_entry("lodash".into(), Ok(link_facts), || Ok(target)).unwrap(); + assert!(raw.is_dir, "classified by the target"); + assert!(raw.link); + assert_eq!(raw.size, 0); + assert_eq!(raw.modified, link_facts.modified, "the date is the link's own"); + assert!(raw.hidden, "the hidden flag is the link's own"); + } + + #[test] + fn classify_entry_lists_a_link_to_a_file_with_the_targets_size_and_the_links_date() { + let link_facts = facts(false, true, 0, 10, false); + let target = facts(false, false, 4096, 99, false); + let raw = classify_entry("notes.lnk".into(), Ok(link_facts), || Ok(target)).unwrap(); + assert!(!raw.is_dir && raw.link); + assert_eq!(raw.size, 4096, "the Size column shows the target's size"); + assert_eq!(raw.modified, link_facts.modified); + } + + #[test] + fn classify_entry_lists_a_dangling_link_as_a_non_enterable_file_without_failing() { + let link_facts = facts(false, true, 0, 10, false); + let raw = classify_entry("old".into(), Ok(link_facts), || Err(io::Error::new(io::ErrorKind::NotFound, "target gone"))).unwrap(); + assert_eq!(raw, RawDirEntry { name: "old".into(), is_dir: false, size: 0, modified: link_facts.modified, hidden: false, link: true }); + // A target that exists but cannot be reached is treated the same. + let raw = classify_entry("denied".into(), Ok(link_facts), || Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"))).unwrap(); + assert!(!raw.is_dir && raw.link && raw.size == 0); + } + + /// A reader that also reports skipped entries, for the unreadable count. + struct SkippingReader(RawDirListing); + impl FsReader for SkippingReader { + fn read_dir(&self, _path: &Path) -> io::Result { + Ok(self.0.clone()) + } + } + + fn raw_dir_link(name: &str) -> RawDirEntry { + RawDirEntry { name: name.into(), is_dir: true, size: 0, modified: None, hidden: false, link: true } + } + + #[test] + fn list_dir_streaming_reports_the_skipped_count_and_the_link_flag_survives_conversion_and_sorting() { + let reader = SkippingReader(RawDirListing { entries: vec![raw_file("zeta.txt"), raw_dir_link("lnk"), raw_dir("alpha")], unreadable: 2 }); + let (batches, totals) = stream_all(&reader, &CancelFlag::new(), BatchPolicy::default()); + let mut entries: Vec = batches.concat(); + assert_eq!(totals, ListingTotals { entries: 3, unreadable: 2 }); + + let flags: Vec<(&str, EntryKind, bool)> = entries.iter().map(|e| (e.name.to_str().unwrap(), e.kind, e.link)).collect(); + assert_eq!(flags, vec![("zeta.txt", EntryKind::File, false), ("lnk", EntryKind::Directory, true), ("alpha", EntryKind::Directory, false)]); + + entries.sort_by(|a, b| cmp_by_mode(a, b, SortMode::Name)); + let flags: Vec<(&str, bool)> = entries.iter().map(|e| (e.name.to_str().unwrap(), e.link)).collect(); + assert_eq!( + flags, + vec![("alpha", false), ("lnk", true), ("zeta.txt", false)], + "a directory link sorts among the directories and keeps its flag" + ); + } + + #[test] + fn parent_dir_is_never_a_link() { + assert!(!Entry::parent_dir().link); + } + + #[test] + fn find_in_subtree_matches_a_directory_link_by_name_but_never_reads_beneath_it() { + // design D4: links are leaves for the walk — `report_link` is + // reported, `/root/report_link/report_b.txt` is never seen, and the + // directory behind the link is never read. + let mut fixture = std::collections::HashMap::new(); + fixture.insert(PathBuf::from("/root"), vec![raw_dir("real"), raw_dir_link("report_link")]); + fixture.insert(PathBuf::from("/root/real"), vec![raw_file("report_a.txt")]); + fixture.insert(PathBuf::from("/root/report_link"), vec![raw_file("report_b.txt")]); + let reader = CountingReader { inner: TreeReader(fixture), reads: std::cell::Cell::new(0) }; + + let mut paths = Vec::new(); + find_in_subtree(&reader, Path::new("/root"), "report", &CancelFlag::new(), |m| paths.push(m.relative_path)); + paths.sort(); + assert_eq!(paths, vec![PathBuf::from("real").join("report_a.txt"), PathBuf::from("report_link")]); + assert_eq!(reader.reads.get(), 2, "only /root and /root/real are read"); + } + + /// Real junctions on the real filesystem (tasks.md 4.4): `mklink /J` + /// needs no privilege. The junction to a live directory lists as a + /// directory, the dangling one as a file, and neither fails the read + /// (panel-navigation "A junction to a directory lists as a directory", + /// "A dangling link does not fail the listing"). + #[cfg(windows)] + #[test] + fn std_reader_classifies_junctions_by_their_target_and_survives_a_dangling_one() { + let dir = std::env::temp_dir().join(format!("filecommand-listing-junction-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("target")).unwrap(); + std::fs::write(dir.join("target").join("inside.txt"), b"x").unwrap(); + std::fs::write(dir.join("plain.txt"), b"12345").unwrap(); + std::fs::create_dir(dir.join("gone")).unwrap(); + let mklink = |link: &str, target: &str| { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J", link, target]) + .current_dir(&dir) + .stdout(std::process::Stdio::null()) + .status() + .expect("cmd.exe runs"); + assert!(status.success(), "mklink /J {link} {target} failed"); + }; + mklink("lnk", "target"); + mklink("old", "gone"); + std::fs::remove_dir(dir.join("gone")).unwrap(); // `old` now dangles + + let listing = StdFsReader.read_dir(&dir).expect("a dangling junction does not fail the listing"); + assert_eq!(listing.unreadable, 0); + let by_name = + |name: &str| listing.entries.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("{name} is listed: {:?}", listing.entries)).clone(); + let lnk = by_name("lnk"); + assert!(lnk.is_dir && lnk.link, "a junction to a directory lists as a directory: {lnk:?}"); + let old = by_name("old"); + assert!(!old.is_dir && old.link && old.size == 0, "a dangling junction lists as a non-enterable file: {old:?}"); + let target = by_name("target"); + assert!(target.is_dir && !target.link); + let plain = by_name("plain.txt"); + assert!(!plain.is_dir && !plain.link && plain.size == 5); + assert_eq!(listing.entries.len(), 4); + + // The junction's own path lists the target's contents, as Enter would. + let through = StdFsReader.read_dir(&dir.join("lnk")).unwrap(); + assert_eq!(through.entries.iter().map(|e| e.name.clone()).collect::>(), vec![OsString::from("inside.txt")]); + + let _ = std::fs::remove_dir(dir.join("lnk")); + let _ = std::fs::remove_dir(dir.join("old")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Symbolic links on Unix (tasks.md 4.5): to a directory, to a + /// 4,096-byte file, and to a missing path (panel-navigation "A directory + /// symbolic link behaves like a junction", "A link to a file lists as a + /// file with the target's size", "A dangling link does not fail the + /// listing"). + #[cfg(unix)] + #[test] + fn std_reader_classifies_symlinks_by_their_target_and_survives_a_dangling_one() { + let dir = std::env::temp_dir().join(format!("filecommand-listing-symlink-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("target")).unwrap(); + std::fs::write(dir.join("big.bin"), vec![0u8; 4096]).unwrap(); + std::os::unix::fs::symlink(dir.join("target"), dir.join("dir_link")).unwrap(); + std::os::unix::fs::symlink(dir.join("big.bin"), dir.join("file_link")).unwrap(); + std::os::unix::fs::symlink(dir.join("missing"), dir.join("dangling")).unwrap(); + + let listing = StdFsReader.read_dir(&dir).expect("a dangling symlink does not fail the listing"); + assert_eq!(listing.unreadable, 0); + let by_name = |name: &str| listing.entries.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("{name} is listed")).clone(); + let dir_link = by_name("dir_link"); + assert!(dir_link.is_dir && dir_link.link); + let file_link = by_name("file_link"); + assert!(!file_link.is_dir && file_link.link); + assert_eq!(file_link.size, 4096, "the target's size"); + let dangling = by_name("dangling"); + assert!(!dangling.is_dir && dangling.link && dangling.size == 0); + let _ = std::fs::remove_dir_all(&dir); + } + #[cfg(unix)] #[test] fn find_in_subtree_matches_non_unicode_names_via_lossy_substring() { @@ -831,11 +1690,14 @@ mod tests { let mut raw_name = vec![0x66, 0x6f, 0x80]; raw_name.extend_from_slice(b"report.txt"); let name = std::ffi::OsStr::from_bytes(&raw_name).to_os_string(); - fixture.insert(PathBuf::from("/root"), vec![RawDirEntry { name: name.clone(), is_dir: false, size: 0, modified: None }]); + fixture.insert( + PathBuf::from("/root"), + vec![RawDirEntry { name: name.clone(), is_dir: false, size: 0, modified: None, hidden: false, link: false }], + ); let reader = TreeReader(fixture); let mut matches = Vec::new(); - find_in_subtree(&reader, Path::new("/root"), "report", |m| matches.push(m)); + find_in_subtree(&reader, Path::new("/root"), "report", &CancelFlag::new(), |m| matches.push(m)); assert_eq!(matches.len(), 1); assert_eq!(matches[0].entry.name, name, "matching must not corrupt the original OsString even though it went through lossy conversion"); } diff --git a/crates/filecommand-core/src/menu.rs b/crates/filecommand-core/src/menu.rs index 5d00969..3634179 100644 --- a/crates/filecommand-core/src/menu.rs +++ b/crates/filecommand-core/src/menu.rs @@ -4,7 +4,7 @@ //! sits alongside the phase in `State` so a file-op dialog and the menu can //! never both claim the phase slot. Everything here is pure data plus index //! arithmetic — dispatching an item's action is the caller's job -//! ([`crate::update`] turns a [`MenuAction`] back into a `Command`). +//! ([`crate::update`](fn@crate::update) turns a [`MenuAction`] back into a `Command`). use crate::listing::SortMode; use crate::panel::DisplayMode; @@ -69,8 +69,9 @@ impl MenuId { } } -/// What activating an item does. Items for features that land in later -/// milestones carry [`MenuAction::Unimplemented`] and render disabled. +/// What activating an item does. Every shipped item is wired +/// (pulldown-menu-completion); [`MenuAction::Unimplemented`] is retained for +/// future proposals, whose items render disabled until they land. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuAction { ToggleInfoMode, @@ -80,8 +81,19 @@ pub enum MenuAction { SetDisplayMode(DisplayMode), EnterTree, SortBy(SortMode), + /// Left/Right → Filter: start the quick filter on the menu's own panel + /// (which becomes the active panel so typing reaches it — design D1). + QuickFilter, Reread, DriveSelect, + /// Left/Right → New tab (Ctrl+T) on the menu's own panel. + NewTab, + /// Left/Right → Close tab (Ctrl+W) on the menu's own panel. + CloseTab, + /// Files → View (F3). + View, + /// Files → Edit (F4). + Edit, Copy, Move, Mkdir, @@ -98,13 +110,23 @@ pub enum MenuAction { InvertSelection, PanelsOnOff, FindFile, + /// Commands → History (Alt+F8): open the command-history dialog + /// (command-line "Command history dialog (Alt+F8)"). + History, + /// Commands → Swap panels (Ctrl+U): exchange the two panels' state + /// (panel-navigation "Swap panels (Ctrl+U)"). + SwapPanels, FuzzyJump, + /// Commands → Menu file edit: open `usermenu.toml` in the F4 editor path + /// (user-menu "Edit the user menu file from the Commands menu"). + EditMenuFile, Quit, /// Options → Themes: opens the theme-selection picker dialog /// (theme-selection "Options menu opens the theme picker"). OpenThemes, /// Rendered as a real entry so the menu matches its final shape, but - /// greyed out and unselectable until its milestone lands. + /// greyed out and unselectable until its milestone lands. No shipped item + /// uses it (pulldown-menu-completion design D5). Unimplemented, } @@ -161,17 +183,17 @@ const PANEL_MENU: &[MenuEntry] = &[ item("Size", 0, "Ctrl-F6", MenuAction::SortBy(SortMode::Size)), item("Unsorted", 0, "Ctrl-F7", MenuAction::SortBy(SortMode::Unsorted)), MenuEntry::Separator, - item("Filter", 0, "", MenuAction::Unimplemented), + item("Filter", 0, "Ctrl-P", MenuAction::QuickFilter), item("Re-read", 0, "Ctrl-R", MenuAction::Reread), item("Drive select", 0, "Alt-F1/F2", MenuAction::DriveSelect), MenuEntry::Separator, - item("New tab", 0, "Ctrl-T", MenuAction::Unimplemented), - item("Close tab", 0, "Ctrl-W", MenuAction::Unimplemented), + item("New tab", 0, "Ctrl-T", MenuAction::NewTab), + item("Close tab", 0, "Ctrl-W", MenuAction::CloseTab), ]; const FILES_MENU: &[MenuEntry] = &[ - item("View", 0, "F3", MenuAction::Unimplemented), - item("Edit", 0, "F4", MenuAction::Unimplemented), + item("View", 0, "F3", MenuAction::View), + item("Edit", 0, "F4", MenuAction::Edit), item("Copy", 0, "F5", MenuAction::Copy), item("Rename/Move", 0, "F6", MenuAction::Move), item("Make directory", 0, "F7", MenuAction::Mkdir), @@ -181,8 +203,6 @@ const FILES_MENU: &[MenuEntry] = &[ item("Copy path(s)", 0, "Ctrl-Sh-Ins", MenuAction::ClipboardPaths), item("Copy name(s)", 0, "", MenuAction::ClipboardNames), MenuEntry::Separator, - item("Attributes", 0, "", MenuAction::Unimplemented), - MenuEntry::Separator, item("Select group", 0, "+", MenuAction::SelectGroup), item("Deselect group", 0, "-", MenuAction::DeselectGroup), item("Invert selection", 0, "*", MenuAction::InvertSelection), @@ -192,21 +212,14 @@ const FILES_MENU: &[MenuEntry] = &[ const COMMANDS_MENU: &[MenuEntry] = &[ item("Find file", 0, "Alt-F7", MenuAction::FindFile), - item("History", 0, "Alt-F8", MenuAction::Unimplemented), - item("Swap panels", 0, "Ctrl-U", MenuAction::Unimplemented), + item("History", 0, "Alt-F8", MenuAction::History), + item("Swap panels", 0, "Ctrl-U", MenuAction::SwapPanels), item("Panels on/off", 0, "Ctrl-O", MenuAction::PanelsOnOff), - item("Compare directories", 0, "", MenuAction::Unimplemented), item("Fuzzy jump", 0, "Ctrl-J", MenuAction::FuzzyJump), - item("Menu file edit", 0, "", MenuAction::Unimplemented), + item("Menu file edit", 0, "", MenuAction::EditMenuFile), ]; -const OPTIONS_MENU: &[MenuEntry] = &[ - item("Configuration", 0, "", MenuAction::Unimplemented), - item("Themes", 0, "", MenuAction::OpenThemes), - item("Editor selection", 0, "", MenuAction::Unimplemented), - MenuEntry::Separator, - item("Save setup", 0, "", MenuAction::Unimplemented), -]; +const OPTIONS_MENU: &[MenuEntry] = &[item("Themes", 0, "", MenuAction::OpenThemes)]; /// The pull-down contents for a menu. pub fn entries(id: MenuId) -> &'static [MenuEntry] { @@ -227,6 +240,19 @@ pub struct MenuState { /// Esc-from-a-pull-down leaves behind. pub pulldown_open: bool, pub selected: usize, + /// `State::clock_ms` when the bar opened. Stamped by the reducer (the + /// constructors below leave it `0`), read only by the renderer to drive + /// the bar's entrance sweep (ui-animations "Render-only motion model + /// driven by the reducer clock"). + pub opened_at_ms: u64, + /// `State::clock_ms` when the current pull-down began unfolding — + /// re-stamped on Left/Right traversal and hotkey jumps, since the new + /// pull-down unfolds while already open and interactive (design D4). + pub pulldown_opened_at_ms: u64, + /// `State::clock_ms` when the highlight last moved within the current + /// pull-down, or `None` if it has not moved since the pull-down opened + /// (opening is the unfold's job, not a highlight flash). + pub selected_at_ms: Option, } impl MenuState { @@ -237,7 +263,14 @@ impl MenuState { /// Open `id`'s pull-down with the first selectable item highlighted. pub fn for_menu(id: MenuId) -> MenuState { - MenuState { active: id, pulldown_open: true, selected: first_selectable(id).unwrap_or(0) } + MenuState { + active: id, + pulldown_open: true, + selected: first_selectable(id).unwrap_or(0), + opened_at_ms: 0, + pulldown_opened_at_ms: 0, + selected_at_ms: None, + } } pub fn items(&self) -> &'static [MenuEntry] { @@ -259,22 +292,7 @@ impl MenuState { /// Move the selection by `delta` rows, landing only on enabled items and /// wrapping at the ends. pub fn move_selection(&mut self, delta: isize) { - let entries = self.items(); - let len = entries.len(); - if len == 0 { - return; - } - let step = if delta >= 0 { 1isize } else { -1isize }; - let mut index = self.selected as isize; - // At most one full lap: if nothing is selectable we leave the - // selection where it was rather than spinning. - for _ in 0..len { - index = (index + step).rem_euclid(len as isize); - if is_selectable(&entries[index as usize]) { - self.selected = index as usize; - return; - } - } + self.selected = move_selection_in(self.items(), self.selected, delta); } /// Switch to an adjacent menu, keeping the pull-down open so horizontal @@ -290,6 +308,28 @@ fn is_selectable(entry: &MenuEntry) -> bool { matches!(entry, MenuEntry::Item(item) if item.is_enabled()) } +/// The selection-walk core of [`MenuState::move_selection`], factored out so +/// the skip-disabled/separator rule can be tested against a local fixture +/// rather than relying on shipped items being disabled (pulldown-menu- +/// completion design D5: no shipped item is disabled). +fn move_selection_in(entries: &[MenuEntry], selected: usize, delta: isize) -> usize { + let len = entries.len(); + if len == 0 { + return selected; + } + let step = if delta >= 0 { 1isize } else { -1isize }; + let mut index = selected as isize; + // At most one full lap: if nothing is selectable we leave the + // selection where it was rather than spinning. + for _ in 0..len { + index = (index + step).rem_euclid(len as isize); + if is_selectable(&entries[index as usize]) { + return index as usize; + } + } + selected +} + /// The index of the first enabled item in `id`'s pull-down. pub fn first_selectable(id: MenuId) -> Option { entries(id).iter().position(is_selectable) @@ -336,37 +376,45 @@ mod tests { } #[test] - fn vertical_selection_skips_separators_and_disabled_items() { + fn vertical_selection_skips_separators() { let mut menu = MenuState::for_menu(MenuId::Files); - // "Attributes" is disabled and bounded by separators on both sides, - // so stepping past "Copy name(s)" must land on "Select group". - let copy_names = entries(MenuId::Files) - .iter() - .position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Copy name(s)")) - .unwrap(); + // A separator sits between "Copy name(s)" and "Select group", so + // stepping down from the former must land on the latter. + let copy_names = entries(MenuId::Files).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Copy name(s)")).unwrap(); menu.selected = copy_names; menu.move_selection(1); assert_eq!(menu.selected_item().map(|i| i.label), Some("Select group")); } + #[test] + fn vertical_selection_skips_disabled_items_in_a_local_fixture() { + // No shipped item is disabled (design D5), so the skip-disabled rule + // is pinned against a local fixture: a disabled item bounded by + // separators must never be landed on, from either direction. + let fixture: Vec = vec![ + item("Alpha", 0, "", MenuAction::Copy), + MenuEntry::Separator, + item("Beta", 0, "", MenuAction::Unimplemented), + MenuEntry::Separator, + item("Gamma", 0, "", MenuAction::Quit), + ]; + assert!(!MenuAction::Unimplemented.is_enabled(), "sanity: the fixture's disabled item is disabled"); + assert_eq!(move_selection_in(&fixture, 0, 1), 4, "Down from Alpha skips the disabled Beta and both separators"); + assert_eq!(move_selection_in(&fixture, 4, -1), 0, "Up from Gamma skips them going back"); + } + #[test] fn vertical_selection_wraps_at_both_ends() { let mut menu = MenuState::for_menu(MenuId::Files); menu.move_selection(-1); assert_eq!(menu.selected_item().map(|i| i.label), Some("Quit"), "Up from the first item wraps to the last"); menu.move_selection(1); - assert_eq!(menu.selected_item().map(|i| i.label), Some("Copy"), "Down from the last wraps to the first"); + assert_eq!(menu.selected_item().map(|i| i.label), Some("View"), "Down from the last wraps to the first"); } #[test] fn selection_never_rests_on_a_disabled_item() { for id in ALL_MENUS { - // An all-disabled menu (Options today) legitimately has nothing - // to land on; `selected_item` returning `None` is what keeps - // Enter from activating anything there. - if first_selectable(id).is_none() { - continue; - } let mut menu = MenuState::for_menu(id); for delta in [1isize, -1] { for _ in 0..entries(id).len() * 2 { @@ -381,25 +429,28 @@ mod tests { } #[test] - fn options_menu_themes_is_enabled_the_rest_still_render_disabled() { - // Themes (theme-selection) is the first Options entry, and is the - // canonical "renders disabled" example's *former* stand-in — see - // `pulldown-menus` MODIFIED "Menu contents": the rest of the menu - // stays a placeholder (pulldown-menus "Not-yet-available feature - // renders disabled" now anchors on Attributes/Configuration/etc. - // instead). + fn every_shipped_item_is_enabled() { + // pulldown-menu-completion "Every listed item is wired": no shipped + // menu carries a greyed-out placeholder any more (design D5 — the + // disabled-rendering rule stays available for future items). + for id in ALL_MENUS { + for entry in entries(id) { + if let MenuEntry::Item(item) = entry { + assert!(item.is_enabled(), "{:?} `{}` must not ship disabled", id, item.label); + } + } + } + } + + #[test] + fn options_menu_lists_themes_alone() { + // Options collapsed to its one wired entry — no separator (design + // D5): Configuration, Editor selection, and Save setup were removed. + let entries = entries(MenuId::Options); + assert_eq!(entries.len(), 1, "Options lists Themes alone"); + assert!(matches!(entries[0], MenuEntry::Item(i) if i.label == "Themes" && i.action == MenuAction::OpenThemes)); let menu = MenuState::for_menu(MenuId::Options); assert_eq!(menu.selected_item().map(|i| i.label), Some("Themes")); - for label in ["Configuration", "Editor selection", "Save setup"] { - let item = entries(MenuId::Options) - .iter() - .find_map(|e| match e { - MenuEntry::Item(i) if i.label == label => Some(i), - _ => None, - }) - .unwrap_or_else(|| panic!("`{label}` missing from the Options menu")); - assert!(!item.is_enabled(), "`{label}` should still render disabled"); - } } #[test] @@ -431,7 +482,6 @@ mod tests { "Copy to clipboard", "Copy path(s)", "Copy name(s)", - "Attributes", "Select group", "Deselect group", "Invert selection", @@ -473,15 +523,87 @@ mod tests { } #[test] - fn not_yet_built_features_render_as_disabled_entries_not_omissions() { - let history = entries(MenuId::Commands) + fn panel_menus_list_their_specified_items_in_order() { + // pulldown-menus "Menu contents": the full Left/Right pull-down, in + // order (`Left and Right menus mirror each other`). + let labels: Vec<&str> = entries(MenuId::Left) .iter() - .find_map(|e| match e { - MenuEntry::Item(i) if i.label == "History" => Some(i), - _ => None, + .filter_map(|e| match e { + MenuEntry::Item(i) => Some(i.label), + MenuEntry::Separator => None, }) - .expect("History is present even though it is unimplemented"); - assert!(!history.is_enabled()); + .collect(); + assert_eq!( + labels, + vec![ + "Brief", + "Full", + "Tree", + "Quick view", + "Info", + "Name", + "Extension", + "Modif. time", + "Size", + "Unsorted", + "Filter", + "Re-read", + "Drive select", + "New tab", + "Close tab", + ] + ); + } + + #[test] + fn commands_menu_lists_its_specified_items_in_order() { + // pulldown-menus "Menu contents": Commands, in order — Compare + // directories is gone, History/Swap panels/Menu file edit are wired. + let labels: Vec<&str> = entries(MenuId::Commands) + .iter() + .filter_map(|e| match e { + MenuEntry::Item(i) => Some(i.label), + MenuEntry::Separator => None, + }) + .collect(); + assert_eq!(labels, vec!["Find file", "History", "Swap panels", "Panels on/off", "Fuzzy jump", "Menu file edit"]); + } + + #[test] + fn removed_items_are_absent_from_every_menu() { + // pulldown-menus "Removed items are absent" (design D5: remove, + // don't hide). + for id in ALL_MENUS { + for entry in entries(id) { + if let MenuEntry::Item(item) = entry { + for removed in ["Attributes", "Compare directories", "Configuration", "Editor selection", "Save setup"] { + assert_ne!(item.label, removed, "{id:?} still lists removed `{removed}`"); + } + } + } + } + } + + #[test] + fn newly_wired_items_carry_their_keyboard_equivalents_shortcut_hints() { + // The wired items show the same hyphenated hint style as the rest of + // the menu (F3/F4/Ctrl+P/Ctrl+T/Ctrl+W/Alt+F8/Ctrl+U bindings). + let by_label = |id: MenuId, label: &str| -> &'static MenuItem { + entries(id) + .iter() + .find_map(|e| match e { + MenuEntry::Item(i) if i.label == label => Some(i), + _ => None, + }) + .unwrap_or_else(|| panic!("`{label}` missing from the {id:?} menu")) + }; + assert_eq!(by_label(MenuId::Files, "View").shortcut, "F3"); + assert_eq!(by_label(MenuId::Files, "Edit").shortcut, "F4"); + assert_eq!(by_label(MenuId::Left, "Filter").shortcut, "Ctrl-P"); + assert_eq!(by_label(MenuId::Left, "New tab").shortcut, "Ctrl-T"); + assert_eq!(by_label(MenuId::Left, "Close tab").shortcut, "Ctrl-W"); + assert_eq!(by_label(MenuId::Commands, "History").shortcut, "Alt-F8"); + assert_eq!(by_label(MenuId::Commands, "Swap panels").shortcut, "Ctrl-U"); } #[test] @@ -518,7 +640,7 @@ mod tests { menu.pulldown_open = false; menu.go_to(MenuId::Files); assert!(menu.pulldown_open); - assert_eq!(menu.selected_item().map(|i| i.label), Some("Copy")); + assert_eq!(menu.selected_item().map(|i| i.label), Some("View")); } #[test] diff --git a/crates/filecommand-core/src/panel.rs b/crates/filecommand-core/src/panel.rs index 71cd531..86c6a1e 100644 --- a/crates/filecommand-core/src/panel.rs +++ b/crates/filecommand-core/src/panel.rs @@ -3,7 +3,7 @@ //! values produced by the `listing` module. use std::cmp::Ordering; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::{Path, PathBuf}; @@ -215,6 +215,39 @@ pub struct ClipboardFeedback { pub expires_at_ms: u64, } +/// A position snapshot taken just before a same-directory re-read (a +/// completed/cancelled file-operation job, Ctrl+R, or a stale background +/// tab's activation refresh), so the streamed re-listing can restore the +/// user's place instead of jumping back to the top (panel-navigation +/// "Same-directory re-read preserves cursor and scroll position"; +/// reread-preserves-cursor design D1). The cursor is re-anchored by entry +/// *name*, not by row index, so a re-read that added or removed entries +/// still lands on the same entry or its nearest survivor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RereadAnchor { + /// The previous listing's entry names in display order — the resolution + /// domain for [`PanelState::apply_reread_anchor`]'s survivor fallback. + pub names: Vec, + /// The cursor's index into `names` at capture time. + pub cursor: usize, + /// The scroll offset at capture time; 0 when a quick filter was active, + /// since that offset counted rows of the narrowed list that + /// `begin_new_listing` discards (design D3). + pub scroll_offset: usize, + /// Each captured name's priority in the resolution order — `names[cursor]` + /// is 0, then `names[cursor + 1..]`, then `names[..cursor]` reversed — so + /// a streamed batch can be checked against the anchor in O(batch) rather + /// than O(names × entries) (streaming-listing-performance design D6). + pub rank: HashMap, + /// The best-ranked captured name present in the listing so far, as + /// `(rank, entry index)`, maintained by [`PanelState::merge_streamed`] + /// across batches. `None` until one arrives, and after anything that + /// re-orders the entries without going through the merge (a re-sort), in + /// which case [`PanelState::apply_reread_anchor`] re-finds it with one + /// linear scan (design D6). + pub resolved: Option<(usize, usize)>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PanelState { pub cwd: PathBuf, @@ -240,6 +273,15 @@ pub struct PanelState { /// yanking it back to row 0. pub cursor_user_moved: bool, pub last_error: Option, + /// A non-error note about a request outstanding for this panel: + /// `Checking …` while a command-line target probe runs, and + /// `Opening …` while an F3/F4 open runs (offload-blocking-io + /// design D3). Shown on the mini-status line below any status override + /// and above `last_error`. Set through [`Self::set_pending_note`], which + /// also clears `last_error` (a new command supersedes an old error); + /// cleared when the reply is applied or discarded and by + /// [`Self::begin_new_listing`]. + pub pending_note: Option, /// The most recent clipboard action's outcome, or `None` when nothing /// is being shown (`clipboard-export` "Clipboard feedback"). Set by /// `crate::update`'s `Command::CopyToClipboard`/`Command::ClipboardResult` @@ -253,12 +295,33 @@ pub struct PanelState { /// active. While `Some`, the panel body is narrowed to entries whose /// displayed name contains the pattern as a substring (plus `..`), and /// cursor movement is restricted to the narrowed set (quick-filter all - /// requirements). + /// requirements). Read freely; write it only through + /// [`Self::activate_quick_filter`], [`Self::quick_filter_push`], + /// [`Self::quick_filter_backspace`], [`Self::clear_quick_filter`] or + /// [`Self::set_quick_filter`], which keep the visible-index cache below + /// in step with it. pub quick_filter: Option, + /// The entry indices visible under `quick_filter`, in ascending order, + /// or `None` while no filter is active — the visible list is then the + /// identity and is never materialized (streaming-listing-performance + /// design D5). Rebuilt by every method that changes the entries or the + /// pattern; read through [`Self::visible_len`], [`Self::visible_at`], + /// [`Self::visible_position`] and [`Self::visible_window`], so drawing, + /// hit-testing and cursor movement cost the rows shown rather than the + /// directory's size (panel-navigation "Large directories stream + /// progressively and stay responsive"). + filtered: Option>, + /// The output buffer [`Self::merge_streamed`] merges into, kept between + /// batches (always empty outside the merge, so it never affects equality) + /// so the two buffers a merge needs are allocated and page-faulted + /// O(log n) times over a listing rather than once per batch + /// (streaming-listing-performance design D3). Released by + /// [`Self::begin_new_listing`]; a stashed tab keeps its own. + merge_scratch: Vec, /// Other tabs belonging to this panel, in list order, with the /// currently active tab's slot omitted — its state lives inline in the /// fields above rather than duplicated here. See [`TabData`] and - /// [`PanelState::open_tab`]/[`close_tab`]/[`switch_tab`] (panel-tabs "Per-panel + /// [`PanelState::open_tab`]/[`PanelState::close_tab`]/[`PanelState::switch_tab`] (panel-tabs "Per-panel /// tab list with independent state"). pub tabs: Vec, /// This panel's active tab's zero-based position within the full @@ -277,6 +340,18 @@ pub struct PanelState { /// rather than clobbering a fresher one (git-info "Silent absence on /// timeout and stale-result discarding"). pub git_request: Option, + /// The id of the listing this panel is currently showing or reading — + /// minted by `update::begin_listing_inner` (and `State::initial`) for + /// every `Effect::StartListing`, exactly like [`Self::git_request`]. + /// `Command::ListingChunk`/`ListingComplete`/`ListingFailed` apply only + /// when their `request` matches this, so a listing superseded by a + /// navigation, a re-read, or a tab switch never adds entries to, + /// completes, or fails the listing that replaced it (panel-navigation + /// "Superseded directory listings are discarded and cancelled"; + /// listing-request-guard design D1/D2). Kept after completion — a late + /// reply from an earlier generation still mismatches. `None` only for a + /// panel that has never been asked to list anything. + pub listing_request: Option, /// Tree-mode navigation state, `Some` only while `display_mode == /// DisplayMode::Tree` — `None` the rest of the time, including before /// Tree mode has ever been entered (additional-panel-modes "Tree mode @@ -288,6 +363,13 @@ pub struct PanelState { /// settling the cursor there (find-file "Navigate to a chosen result"). /// `None` for every ordinary navigation. pub pending_cursor_target: Option, + /// The position snapshot a same-directory re-read captured so the + /// streamed re-listing can restore the user's place — applied on every + /// `ListingChunk` and consumed on `ListingComplete` (reread-preserves- + /// cursor design D2/D4). `None` for ordinary navigations (any move to a + /// different directory clears it), for a re-read issued while the + /// previous listing was still streaming, and outside a re-read entirely. + pub reread_anchor: Option, /// The topmost visible position of the panel body's render window, in /// *visible-position* space — an index into `visible_indices()`, not a /// raw `entries` index (panel-navigation "Scroll offset is core panel @@ -313,21 +395,41 @@ impl PanelState { progress: ListingProgress::Streaming { count: 0 }, cursor_user_moved: false, last_error: None, + pending_note: None, clipboard_feedback: None, selected: HashSet::new(), quick_filter: None, + filtered: None, + merge_scratch: Vec::new(), tabs: Vec::new(), active_tab_index: 0, git_info: GitInfo::none(), git_request: None, + listing_request: None, tree: None, pending_cursor_target: None, + reread_anchor: None, scroll_offset: 0, } } + /// The entry under the cursor, or `None` when the panel has no current + /// entry: an empty listing, or — while a quick filter is active — a + /// cursor entry the pattern hides. The latter happens only when no + /// entry is visible at all (a pattern matching nothing where there is + /// no `..`, such as a drive root), since `snap_cursor_to_visible` + /// otherwise keeps the cursor on a visible entry. Every cursor-targeted + /// command treats `None` as "do nothing", so none of them ever acts on + /// an entry the filter hides (quick-filter "Cursor and mini-status + /// behavior under an active filter"; filter-selection-safety design D1). + /// The visibility check is one `visible_position` lookup — O(log n) in + /// the cached visible list — rather than allocating `visible_indices()`. pub fn selected(&self) -> Option<&Entry> { - self.entries.get(self.cursor) + let entry = self.entries.get(self.cursor)?; + if self.quick_filter.is_some() && self.visible_position(self.cursor).is_none() { + return None; + } + Some(entry) } pub fn clamp_cursor(&mut self) { @@ -374,12 +476,11 @@ impl PanelState { /// offset is core panel state"). pub fn ensure_cursor_visible(&mut self, rows: usize) { let rows = rows.max(1); - let visible = self.visible_indices(); - if visible.is_empty() { + if self.visible_len() == 0 { self.scroll_offset = 0; return; } - let pos = visible.iter().position(|&i| i == self.cursor).unwrap_or(0); + let pos = self.visible_position(self.cursor).unwrap_or(0); if pos < self.scroll_offset { self.scroll_offset = pos; } else if pos >= self.scroll_offset + rows { @@ -400,12 +501,11 @@ impl PanelState { pub fn ensure_cursor_visible_brief(&mut self, rows: usize, cols: usize) { let rows = rows.max(1); let cols = cols.max(1); - let visible = self.visible_indices(); - if visible.is_empty() { + if self.visible_len() == 0 { self.scroll_offset = 0; return; } - let pos = visible.iter().position(|&i| i == self.cursor).unwrap_or(0); + let pos = self.visible_position(self.cursor).unwrap_or(0); let pos_col = pos / rows; let mut start_col = self.scroll_offset / rows; if pos_col < start_col { @@ -420,36 +520,149 @@ impl PanelState { /// leaves visible — a no-op when nothing is visible (quick-filter /// "Navigation is restricted to matching entries"). fn move_cursor_filtered(&mut self, m: CursorMove) { - let visible = self.visible_indices(); - let Some(last) = visible.len().checked_sub(1) else { return }; - let pos = visible.iter().position(|&i| i == self.cursor).unwrap_or(0); + let Some(last) = self.visible_len().checked_sub(1) else { return }; + let pos = self.visible_position(self.cursor).unwrap_or(0); let new_pos = match m { CursorMove::Up(n) => pos.saturating_sub(n), CursorMove::Down(n) => (pos + n).min(last), CursorMove::Home => 0, CursorMove::End => last, }; - self.cursor = visible[new_pos]; + self.cursor = self.visible_at(new_pos).unwrap_or(self.cursor); self.cursor_user_moved = true; } - /// Insert a freshly streamed entry in sorted position, then re-pin the - /// cursor to row 0 if the user hasn't moved it yet. - pub fn insert_streamed(&mut self, entry: Entry) { - insert_sorted(&mut self.entries, entry, self.sort_mode, self.sort_direction); - if !self.cursor_user_moved { + /// Merge one streamed batch into the sorted listing in a single pass + /// (streaming-listing-performance design D3; panel-navigation "Large + /// directories stream progressively and stay responsive"): the batch is + /// stable-sorted under the current mode, then merged with the existing + /// entries in O(n + k) — never by inserting entries one at a time into + /// the middle of the list. Ties keep existing entries first, matching + /// what per-entry `insert_sorted` produced, so the result equals a bulk + /// stable sort of everything received so far; in `Unsorted` mode that + /// is enumeration order with `..` floated to the top. + /// + /// The cursor stays pinned to row 0 until the user moves it; once moved, + /// it is advanced by the number of batch entries that landed before it, + /// so it stays on its entry while entries stream in around it + /// (panel-navigation "Cursor stays on its entry while entries stream + /// in"). A same-directory re-read anchor, when one is pending, has its + /// best-ranked present name tracked from the batch alone (design D6); + /// `core::update` applies it right after this call, as before. The + /// quick-filter cache is rebuilt when a filter is active. + pub fn merge_streamed(&mut self, mut batch: Vec) { + if batch.is_empty() { + return; + } + let mode = self.sort_mode; + let dir = self.sort_direction; + batch.sort_by(|a, b| cmp_entries(a, b, mode, dir)); + + let n = self.entries.len(); + let k = batch.len(); + let old_cursor = self.cursor; + let mut new_cursor = old_cursor; + let anchor = self.reread_anchor.as_mut(); + let mut tracked = anchor.as_ref().and_then(|a| a.resolved); + // Merge into the retained scratch buffer, then swap it in: the old + // buffer (drained, capacity kept) becomes the next merge's scratch. + let mut merged = std::mem::take(&mut self.merge_scratch); + merged.clear(); + merged.reserve(n + k); + let mut old_iter = self.entries.drain(..).enumerate().peekable(); + let mut batch_iter = batch.into_iter().peekable(); + loop { + let take_old = match (old_iter.peek(), batch_iter.peek()) { + (None, None) => break, + (Some(_), None) => true, + (None, Some(_)) => false, + // Existing first on ties (`!= Greater` in `insert_sorted`). + (Some((_, o)), Some(b)) => cmp_entries(o, b, mode, dir) != Ordering::Greater, + }; + let out_idx = merged.len(); + if take_old { + let (i, entry) = old_iter.next().expect("peeked"); + if i == old_cursor { + new_cursor = out_idx; + } + if let Some((_, idx)) = tracked.as_mut() { + if *idx == i { + *idx = out_idx; + } + } + merged.push(entry); + } else { + let entry = batch_iter.next().expect("peeked"); + if let Some(a) = anchor.as_deref() { + if let Some(&rank) = a.rank.get(&entry.name) { + if tracked.is_none_or(|(best, _)| rank < best) { + tracked = Some((rank, out_idx)); + } + } + } + merged.push(entry); + } + } + // The tracked index above was shifted while walking the old entries, + // so by now it is current for the merged list. + if let Some(a) = anchor { + a.resolved = tracked; + } + drop(old_iter); + std::mem::swap(&mut self.entries, &mut merged); + self.merge_scratch = merged; + if !self.cursor_user_moved || n == 0 { self.cursor = 0; } else { - self.clamp_cursor(); + self.cursor = new_cursor; } + self.clamp_cursor(); + self.rebuild_filtered(); + } + + /// Announce a request outstanding for this panel on the mini-status + /// line (`Checking …`, `Opening …`). A new command + /// supersedes an old error, so `last_error` is cleared at the same time + /// (offload-blocking-io design D3). + pub fn set_pending_note(&mut self, note: String) { + self.last_error = None; + self.pending_note = Some(note); } /// Re-sort the entries already in hand under the current mode. Stable, /// and it issues no directory read and no per-entry metadata query. + /// Rebuilds the quick-filter cache (its indices moved) and drops any + /// in-flight re-read anchor's tracked index — the next + /// [`Self::apply_reread_anchor`] re-finds it with one linear scan + /// (streaming-listing-performance design D5/D6). pub fn resort(&mut self) { let mode = self.sort_mode; let dir = self.sort_direction; self.entries.sort_by(|a, b| cmp_entries(a, b, mode, dir)); + if let Some(anchor) = self.reread_anchor.as_mut() { + anchor.resolved = None; + } + self.rebuild_filtered(); + } + + /// Free the buffer [`Self::merge_streamed`] keeps between batches — called + /// by `core::update` once a listing has completed or failed, so a panel + /// at rest holds its entries once, not twice (design D3). + pub fn release_merge_scratch(&mut self) { + self.merge_scratch = Vec::new(); + } + + /// Replace the whole entry list — tests and benchmarks setting up a + /// panel — keeping the quick-filter cache in step (streaming-listing- + /// performance design D5). Production code streams entries in through + /// [`Self::merge_streamed`] instead; the cursor is left for the caller + /// to place. + pub fn set_entries(&mut self, entries: Vec) { + self.entries = entries; + if let Some(anchor) = self.reread_anchor.as_mut() { + anchor.resolved = None; + } + self.rebuild_filtered(); } /// Set the sort mode and re-sort in place, keeping the cursor on the @@ -467,8 +680,22 @@ impl PanelState { } pub fn begin_new_listing(&mut self, cwd: PathBuf) { + // A navigation must never inherit a re-read anchor captured for an + // earlier re-read (reread-preserves-cursor design D4). A re-read of + // the directory already displayed keeps the anchor + // `begin_listing_inner` captured just before calling this. + if cwd != self.cwd { + self.reread_anchor = None; + } + // A kept anchor's tracked index referred to the entries cleared + // below; the re-read's batches rebuild it (streaming-listing- + // performance design D6). + if let Some(anchor) = self.reread_anchor.as_mut() { + anchor.resolved = None; + } self.cwd = cwd; self.entries.clear(); + self.merge_scratch = Vec::new(); self.cursor = 0; self.cursor_user_moved = false; // A fresh directory has an entirely different entry set, so any @@ -478,6 +705,9 @@ impl PanelState { self.scroll_offset = 0; self.progress = ListingProgress::Streaming { count: 0 }; self.last_error = None; + // A new listing supersedes whatever the panel was waiting on; the + // reply, if it still applies, no longer needs announcing (design D3). + self.pending_note = None; self.selected.clear(); // Directory-scoped Info values (and the drive's, if the drive // changed) no longer describe what the panel shows. The sort mode @@ -492,6 +722,7 @@ impl PanelState { // documented way to clear it deliberately, but a directory change // clears it implicitly too. self.quick_filter = None; + self.filtered = None; // Directory-scoped git info (like `info` above) no longer describes // what the panel shows; `update::begin_listing` mints a fresh // request and issues `Effect::QueryGitInfo` for wherever the panel @@ -506,6 +737,107 @@ impl PanelState { self.pending_cursor_target = None; } + /// Snapshot the panel's position for a re-read of the directory it is + /// already showing: every entry's name in display order plus the cursor + /// index and scroll offset (reread-preserves-cursor design D1/D5). Only + /// a `Complete` listing is worth anchoring to — a re-read issued while + /// the previous listing is still streaming clears any anchor instead, + /// keeping the old jump-to-top behavior rather than anchoring to a + /// half-loaded list. + pub fn capture_reread_anchor(&mut self) { + self.reread_anchor = match self.progress { + ListingProgress::Complete { .. } => { + let names: Vec = self.entries.iter().map(|e| e.name.clone()).collect(); + let cursor = self.cursor; + Some(RereadAnchor { + rank: Self::anchor_ranks(&names, cursor), + resolved: None, + names, + cursor, + // The offset counts rows of the quick-filter-narrowed list + // and `begin_new_listing` clears that filter, so with a + // filter active there is no meaningful offset to restore — + // capture 0 and let the post-restore reconcile scroll the + // re-anchored cursor into view (design D3). + scroll_offset: if self.quick_filter.is_some() { 0 } else { self.scroll_offset }, + }) + } + ListingProgress::Streaming { .. } => None, + }; + } + + /// Each captured name's priority in the anchor's resolution order — + /// `names[cursor]` first, then `names[cursor + 1..]`, then + /// `names[..cursor]` reversed (reread-preserves-cursor design D1) — as + /// a map for O(1) lookups per streamed entry (streaming-listing- + /// performance design D6). Empty for an empty capture. + fn anchor_ranks(names: &[OsString], cursor: usize) -> HashMap { + let mut rank = HashMap::with_capacity(names.len()); + if names.is_empty() { + return rank; + } + let at = cursor.min(names.len() - 1); + let order = std::iter::once(&names[at]).chain(names[at + 1..].iter()).chain(names[..at].iter().rev()); + for (r, name) in order.enumerate() { + rank.entry(name.clone()).or_insert(r); + } + rank + } + + /// Re-anchor the cursor and scroll offset of a listing that is + /// re-reading the directory captured by [`Self::capture_reread_anchor`]. + /// `core::update` runs this after every chunk of the fresh listing (and + /// once more on completion, which consumes the anchor) so the restored + /// position holds while the re-read streams in rather than sitting on + /// row 0 until the final event (panel-navigation "No jump while the + /// re-read streams"; design D2). The cursor resolves to the first + /// captured name still listed, tried in the order `names[cursor]`, + /// `names[cursor + 1..]`, `names[..cursor]` reversed: the same entry if + /// it survived, else the first survivor at or after its old row, else + /// the nearest survivor before it (design D1). A hit also sets + /// `cursor_user_moved` so `insert_streamed`'s pin-to-top stops competing + /// with the anchor, and restores the scroll offset — clamped to + /// `visible_len.saturating_sub(rows)` so a list that shrank at the + /// bottom shows no blank trailing rows; in Brief mode the offset is a + /// column position rather than a row position, so the rows-based clamp + /// does not apply and the raw offset is left for the caller's + /// `ensure_cursor_visible_brief` pass to re-align to a whole-column + /// multiple (design D3). Returns whether a name resolved; `false` (no + /// anchor, or no captured name present in the entries so far) leaves + /// the panel untouched. + pub fn apply_reread_anchor(&mut self, rows: usize) -> bool { + let Some(anchor) = self.reread_anchor.as_mut() else { return false }; + if anchor.names.is_empty() { + return false; + } + // `merge_streamed` keeps `resolved` current across batches + // (streaming-listing-performance design D6). It is re-found with one + // linear scan — O(n) with a hash lookup per entry, never O(names × + // entries) — when nothing has tracked it yet or the entries were + // re-ordered behind its back (a re-sort, or a wholesale replacement). + let tracked_is_current = + anchor.resolved.is_some_and(|(rank, idx)| self.entries.get(idx).is_some_and(|e| anchor.rank.get(&e.name) == Some(&rank))); + if !tracked_is_current { + anchor.resolved = self + .entries + .iter() + .enumerate() + .filter_map(|(idx, e)| anchor.rank.get(&e.name).map(|&rank| (rank, idx))) + .min_by_key(|&(rank, _)| rank); + } + let offset = anchor.scroll_offset; + let Some((_, idx)) = anchor.resolved else { return false }; + self.cursor = idx; + self.cursor_user_moved = true; + if self.display_mode == DisplayMode::Brief { + self.scroll_offset = offset; + } else { + let visible_len = self.visible_len(); + self.scroll_offset = offset.min(visible_len.saturating_sub(rows)); + } + true + } + /// Drop any selected names that no longer appear in `entries` — used /// after a fresh listing (e.g. following a file-op job) so selection /// never references a vanished entry. @@ -517,13 +849,18 @@ impl PanelState { /// Toggle selection on the entry under the cursor, then advance the /// cursor by one row (never wrapping past the last entry). The parent /// `..` pseudo-entry is never selectable and toggling it is a no-op. + /// The entry comes from [`Self::selected`], so with no current entry (a + /// quick filter hiding everything) nothing is toggled (quick-filter + /// "Ins does nothing while no entry is visible"; filter-selection-safety + /// design D1). pub fn toggle_selection_and_advance(&mut self) { - if let Some(entry) = self.entries.get(self.cursor) { - if entry.kind != EntryKind::ParentDir { - let name = entry.name.clone(); - if !self.selected.remove(&name) { - self.selected.insert(name); - } + let name = match self.selected() { + Some(entry) if entry.kind != EntryKind::ParentDir => Some(entry.name.clone()), + _ => None, + }; + if let Some(name) = name { + if !self.selected.remove(&name) { + self.selected.insert(name); } } self.move_cursor(CursorMove::Down(1)); @@ -576,31 +913,140 @@ impl PanelState { Some(format!("{} files selected, {} bytes", format_count(self.selected.len()), format_count(self.selected_bytes() as usize))) } + /// How many of `sources` are selected entries of this panel that the + /// active quick filter currently hides — the count the copy/move/delete + /// setup dialogs disclose (selection "Operations disclose selected + /// entries hidden by the quick filter"; filter-selection-safety design + /// D2). A source counts when it lies in this panel's `cwd`, its name is + /// in the panel's selection set, and its listing entry fails the filter. + /// Always 0 with no active filter. + pub fn hidden_selected_count(&self, sources: &[crate::fs_ops::SourceItem]) -> usize { + let Some(pattern) = &self.quick_filter else { return 0 }; + let pattern_lower = pattern.to_lowercase(); + sources + .iter() + .filter(|s| s.path.parent() == Some(self.cwd.as_path()) && self.selected.contains(&s.original_name)) + .filter(|s| self.entries.iter().any(|e| e.name == s.original_name && !Self::matches_quick_filter(e, &pattern_lower))) + .count() + } + // ------------------------------------------------------------------- // Quick filter (Ctrl+P) // ------------------------------------------------------------------- - /// Whether `entry` is shown under the current `quick_filter` pattern. - /// The `..` parent entry always matches so upward navigation is never - /// blocked (quick-filter "Substring narrowing as the pattern is + /// Whether `entry` is shown under the quick-filter pattern + /// `pattern_lower`, which the caller has already lowercased once. The + /// match reads the entry's precomputed [`Entry::sort_key`], so no name + /// is lowercased per entry per call (streaming-listing-performance + /// design D4). The `..` parent entry always matches so upward navigation + /// is never blocked (quick-filter "Substring narrowing as the pattern is /// typed"). - fn matches_quick_filter(entry: &Entry, pattern: &str) -> bool { - entry.kind == EntryKind::ParentDir || entry.name.to_string_lossy().to_lowercase().contains(&pattern.to_lowercase()) + fn matches_quick_filter(entry: &Entry, pattern_lower: &str) -> bool { + entry.kind == EntryKind::ParentDir || entry.sort_key().contains(pattern_lower) + } + + /// Recompute the cached visible-index list from the current entries and + /// pattern — one O(n) pass over precomputed keys, `None` when no filter + /// is active (streaming-listing-performance design D5). Called by every + /// mutator of either. + fn rebuild_filtered(&mut self) { + self.filtered = self.quick_filter.as_ref().map(|pattern| { + let pattern_lower = pattern.to_lowercase(); + self.entries.iter().enumerate().filter(|(_, e)| Self::matches_quick_filter(e, &pattern_lower)).map(|(i, _)| i).collect() + }); + } + + /// The cache's consistency checks, for the debug builds tests run under: + /// a filter and its cache exist together, and every cached index is in + /// range — catching an `entries` or `quick_filter` write that bypassed + /// [`Self::set_entries`] / the quick-filter mutators (design D5). + #[inline] + fn debug_check_filtered(&self) { + debug_assert_eq!( + self.filtered.is_some(), + self.quick_filter.is_some(), + "quick_filter was written without rebuilding the visible-index cache; use set_quick_filter or the quick-filter mutators" + ); + if let Some(cached) = &self.filtered { + debug_assert!( + cached.last().is_none_or(|&i| i < self.entries.len()), + "the visible-index cache is stale: entries were replaced without set_entries" + ); + } + } + + /// How many entries the panel body shows: every entry without a filter, + /// the cached matching subset with one (design D5). O(1). + pub fn visible_len(&self) -> usize { + self.debug_check_filtered(); + match &self.filtered { + None => self.entries.len(), + Some(cached) => cached.len(), + } + } + + /// The entry index at visible position `pos`, or `None` past the end. + /// O(1). + pub fn visible_at(&self, pos: usize) -> Option { + self.debug_check_filtered(); + match &self.filtered { + None => (pos < self.entries.len()).then_some(pos), + Some(cached) => cached.get(pos).copied(), + } + } + + /// The visible position of entry `index`, or `None` when the filter + /// hides it (or it is out of range). The identity without a filter, a + /// binary search in the ascending cache with one: O(log n). + pub fn visible_position(&self, index: usize) -> Option { + self.debug_check_filtered(); + match &self.filtered { + None => (index < self.entries.len()).then_some(index), + Some(cached) => cached.binary_search(&index).ok(), + } + } + + /// The entry indices at visible positions `start..start + len`, in + /// order, stopping at the end of the visible list — what the renderer + /// and hit-tester iterate for the rows on screen, costing the rows shown + /// rather than the directory's size (design D5). + pub fn visible_window(&self, start: usize, len: usize) -> impl Iterator + '_ { + self.debug_check_filtered(); + let end = start.saturating_add(len).min(self.visible_len()); + let range = start.min(end)..end; + match &self.filtered { + None => VisibleWindow::Identity(range), + Some(cached) => VisibleWindow::Cached(cached[range].iter().copied()), + } } /// Indices into `entries` visible under the active `quick_filter`, or - /// every index when no filter is active. + /// every index when no filter is active. Allocates a fresh `Vec` on every + /// call — a convenience for tests and one-off checks, not for per-frame + /// or per-keystroke use, which go through [`Self::visible_len`], + /// [`Self::visible_at`], [`Self::visible_position`] and + /// [`Self::visible_window`] (streaming-listing-performance design D5). pub fn visible_indices(&self) -> Vec { - match &self.quick_filter { + self.debug_check_filtered(); + match &self.filtered { None => (0..self.entries.len()).collect(), - Some(pattern) => self.entries.iter().enumerate().filter(|(_, e)| Self::matches_quick_filter(e, pattern)).map(|(i, _)| i).collect(), + Some(cached) => cached.clone(), } } /// Ctrl+P: enter quick-filter mode with an empty pattern (quick-filter /// "Activating the quick filter"). pub fn activate_quick_filter(&mut self) { - self.quick_filter = Some(String::new()); + self.set_quick_filter(Some(String::new())); + self.rebuild_filtered(); + } + + /// Set (or clear) the whole pattern at once, keeping the visible-index + /// cache in step — the one way to write `quick_filter` from outside the + /// typing mutators (tests, tab restore). Does not move the cursor. + pub fn set_quick_filter(&mut self, pattern: Option) { + self.quick_filter = pattern; + self.rebuild_filtered(); } /// Append `c` to the quick-filter pattern and re-narrow (quick-filter @@ -609,6 +1055,7 @@ impl PanelState { if let Some(pattern) = &mut self.quick_filter { pattern.push(c); } + self.rebuild_filtered(); self.snap_cursor_to_visible(); } @@ -620,6 +1067,7 @@ impl PanelState { if let Some(pattern) = &mut self.quick_filter { pattern.pop(); } + self.rebuild_filtered(); self.snap_cursor_to_visible(); } @@ -628,19 +1076,35 @@ impl PanelState { /// shown (quick-filter "Clearing the quick filter"). pub fn clear_quick_filter(&mut self) { self.quick_filter = None; + self.filtered = None; } /// After a pattern change, move the cursor onto the nearest still- /// visible entry if the one it was on got filtered out (quick-filter - /// "Cursor and mini-status behavior under an active filter"). + /// "Cursor and mini-status behavior under an active filter"). Reads the + /// cache: a binary search finds the neighbours on either side, and the + /// lower index wins a tie, as the old linear `min_by_key` did. fn snap_cursor_to_visible(&mut self) { - let visible = self.visible_indices(); - if visible.is_empty() || visible.contains(&self.cursor) { + if self.visible_len() == 0 || self.visible_position(self.cursor).is_some() { return; } - if let Some(&nearest) = visible.iter().min_by_key(|&&i| i.abs_diff(self.cursor)) { - self.cursor = nearest; - } + let Some(cached) = &self.filtered else { return }; + let ip = cached.partition_point(|&i| i < self.cursor); + let below = ip.checked_sub(1).map(|p| cached[p]); + let above = cached.get(ip).copied(); + let nearest = match (below, above) { + (Some(b), Some(a)) => { + if self.cursor.abs_diff(b) <= self.cursor.abs_diff(a) { + b + } else { + a + } + } + (Some(b), None) => b, + (None, Some(a)) => a, + (None, None) => return, + }; + self.cursor = nearest; } // ------------------------------------------------------------------- @@ -653,9 +1117,15 @@ impl PanelState { } /// Snapshot everything a tab independently owns from the fields - /// currently inline (i.e. the active tab's state). The active tab is - /// never itself stale — a completed job re-reads it immediately rather - /// than deferring — so this always snapshots `stale: false`; only + /// currently inline (i.e. the active tab's state). A completed job never + /// leaves the active tab stale — it re-reads it immediately rather than + /// deferring — so the only staleness stamped here is a listing still + /// being read when the tab is stashed: that read is cancelled by the + /// reducer the moment the tab stops being active, so the stashed entries + /// are a partial listing that will never complete, and the tab must be + /// re-read when it next becomes active (panel-tabs "Switching or closing + /// a tab mid-listing never mixes listings"; listing-request-guard design + /// D6). Otherwise `stale: false`, and only /// [`Self::mark_background_tabs_stale`] sets the flag, directly on /// entries already sitting in `tabs`. fn to_tab_data(&self) -> TabData { @@ -676,10 +1146,12 @@ impl PanelState { quick_filter: self.quick_filter.clone(), git_info: self.git_info.clone(), git_request: self.git_request, + listing_request: self.listing_request, tree: self.tree.clone(), pending_cursor_target: self.pending_cursor_target.clone(), + reread_anchor: self.reread_anchor.clone(), scroll_offset: self.scroll_offset, - stale: false, + stale: self.progress.is_streaming(), } } @@ -701,8 +1173,17 @@ impl PanelState { self.quick_filter = data.quick_filter; self.git_info = data.git_info; self.git_request = data.git_request; + self.listing_request = data.listing_request; self.tree = data.tree; self.pending_cursor_target = data.pending_cursor_target; + // A tab switched away mid-re-read keeps its anchor so the listing + // that completes after it is reactivated still restores its place + // (reread-preserves-cursor design D4). + self.reread_anchor = data.reread_anchor; + // The visible-index cache is not stashed in `TabData`; it is rebuilt + // from the restored entries and pattern (streaming-listing- + // performance design D5). + self.rebuild_filtered(); // Restored as-is; it may no longer fit the current viewport height // (the panel may have been resized, or the split/tab-strip state // differs from when this tab was stashed) — `core::update` re-clamps @@ -767,7 +1248,7 @@ impl PanelState { /// Each tab's directory, in tab order — cheap to call every frame for /// tab-strip label rendering since it clones only the directory /// `PathBuf`s, never the full [`TabData`] (which carries each tab's - /// entire entry list). See [`Self::full_tab_list`] for the equivalent + /// entire entry list). See `Self::full_tab_list` for the equivalent /// that also carries state, used when actually switching tabs (panel- /// tabs "Tab label rendering and active styling"). pub fn tab_dirs(&self) -> Vec { @@ -811,6 +1292,32 @@ impl PanelState { } } +/// The iterator behind [`PanelState::visible_window`]: a plain index range +/// while no quick filter is active, a slice of the cached visible indices +/// with one (streaming-listing-performance design D5). +enum VisibleWindow<'a> { + Identity(std::ops::Range), + Cached(std::iter::Copied>), +} + +impl Iterator for VisibleWindow<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + match self { + VisibleWindow::Identity(range) => range.next(), + VisibleWindow::Cached(iter) => iter.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + VisibleWindow::Identity(range) => range.size_hint(), + VisibleWindow::Cached(iter) => iter.size_hint(), + } + } +} + /// One inactive tab's full, independent state — directory, entries, /// cursor, sort mode, display mode, and filter — snapshotted when it stops /// being the active tab (design D4; panel-tabs "Per-panel tab list with @@ -834,35 +1341,73 @@ pub struct TabData { pub quick_filter: Option, pub git_info: GitInfo, pub git_request: Option, + /// See [`PanelState::listing_request`] — stashed with the tab so a + /// background tab's listing id is restored with it; while stashed, its + /// listing's replies mismatch the inline id and are dropped. + pub listing_request: Option, pub tree: Option, pub pending_cursor_target: Option, + /// See [`PanelState::reread_anchor`] — stashed alongside the rest of + /// the tab's state so a tab switched away mid-re-read keeps it. + pub reread_anchor: Option, pub scroll_offset: usize, /// Set when a completed/cancelled-with-partial-changes file-operation /// job touched `cwd` while this tab was in the background, so its - /// cached `entries` may no longer reflect the on-disk state. Cleared - /// (consumed) the moment this tab becomes active again — `switch_tab`/ - /// `close_tab` read it before reinstating this data and issue a fresh - /// read instead of restoring the stale cache (file-operations - /// "Automatic panel re-read on completion"; panel-tabs "Stale - /// background tab refresh on activation"). + /// cached `entries` may no longer reflect the on-disk state — or when + /// the tab was stashed while its listing was still being read, so its + /// cached `entries` are a partial listing whose read has been cancelled + /// (panel-tabs "Switching or closing a tab mid-listing never mixes + /// listings"). Cleared (consumed) the moment this tab becomes active + /// again — `switch_tab`/`close_tab` read it before reinstating this data + /// and issue a fresh read instead of restoring the stale cache + /// (file-operations "Automatic panel re-read on completion"; panel-tabs + /// "Stale background tab refresh on activation"). pub stale: bool, } /// DOS-style wildcard match (`*` = any run of characters, `?` = any single -/// character), case-insensitive. +/// character), case-insensitive. `+` (select a group) and `-` (deselect a +/// group) share this matcher (selection "Wildcard pattern matching for group +/// select and deselect"; filter-selection-safety design D4). +/// +/// Iterative two-pointer glob with single-star backtracking over `Vec`: +/// `?` consumes exactly one Unicode scalar value, never one byte, and only +/// the most recent `*` is ever backtracked to, so the worst case is +/// O(|pattern| × |name|) with no recursion and no exponential blow-up. +/// Case-insensitivity is per character (equal, or equal after +/// `to_lowercase`) rather than a whole-string lowercase pass, which can +/// change the character count (`İ` lowercases to two scalars) and would +/// break "`?` is one character". pub fn wildcard_match(pattern: &str, name: &str) -> bool { - fn match_bytes(pat: &[u8], s: &[u8]) -> bool { - match (pat.first(), s.first()) { - (None, None) => true, - (Some(b'*'), _) => match_bytes(&pat[1..], s) || (!s.is_empty() && match_bytes(pat, &s[1..])), - (Some(b'?'), Some(_)) => match_bytes(&pat[1..], &s[1..]), - (Some(pc), Some(sc)) if pc == sc => match_bytes(&pat[1..], &s[1..]), - _ => false, - } - } - let pattern = pattern.to_lowercase(); - let name = name.to_lowercase(); - match_bytes(pattern.as_bytes(), name.as_bytes()) + fn char_eq(a: char, b: char) -> bool { + a == b || a.to_lowercase().eq(b.to_lowercase()) + } + let pattern: Vec = pattern.chars().collect(); + let name: Vec = name.chars().collect(); + let (mut p, mut s) = (0, 0); + // The most recent `*`'s pattern index, plus the name index it has + // consumed up to: on a mismatch, retry with the star absorbing one more + // character. + let mut star: Option = None; + let mut mark = 0; + while s < name.len() { + if p < pattern.len() && (pattern[p] == '?' || char_eq(pattern[p], name[s])) { + p += 1; + s += 1; + } else if p < pattern.len() && pattern[p] == '*' { + star = Some(p); + mark = s; + p += 1; + } else if let Some(st) = star { + p = st + 1; + mark += 1; + s = mark; + } else { + return false; + } + } + // Whatever of the pattern the name didn't consume must be trailing stars. + pattern[p..].iter().all(|&c| c == '*') } /// Compare two entries for sort order. `..` always sorts first regardless of @@ -905,26 +1450,26 @@ mod tests { use std::ffi::OsString; fn file(name: &str) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size: 0, modified: None } + Entry::new(OsString::from(name), EntryKind::File, 0, None, false, false) } fn dir(name: &str) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::Directory, size: 0, modified: None } + Entry::new(OsString::from(name), EntryKind::Directory, 0, None, false, false) } #[test] fn parent_dir_sorts_first_regardless_of_direction() { - let mut entries = vec![file("b.txt"), dir("a"), Entry::parent_dir()]; + let mut entries = [file("b.txt"), dir("a"), Entry::parent_dir()]; entries.sort_by(|a, b| cmp_entries(a, b, SortMode::Name, SortDirection::Asc)); assert_eq!(entries[0].kind, EntryKind::ParentDir); - let mut entries = vec![file("b.txt"), dir("a"), Entry::parent_dir()]; + let mut entries = [file("b.txt"), dir("a"), Entry::parent_dir()]; entries.sort_by(|a, b| cmp_entries(a, b, SortMode::Name, SortDirection::Desc)); assert_eq!(entries[0].kind, EntryKind::ParentDir); } #[test] fn name_sort_is_case_insensitive() { - let mut entries = vec![file("Banana"), file("apple"), file("Cherry")]; + let mut entries = [file("Banana"), file("apple"), file("Cherry")]; entries.sort_by(|a, b| cmp_entries(a, b, SortMode::Name, SortDirection::Asc)); let names: Vec = entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); assert_eq!(names, vec!["apple", "Banana", "Cherry"]); @@ -975,8 +1520,8 @@ mod tests { #[test] fn cursor_pinned_to_top_while_streaming_until_user_moves() { let mut p = PanelState::new(PathBuf::from("/")); - p.insert_streamed(file("m")); - p.insert_streamed(file("a")); // sorts before "m" -> cursor should stay at 0 + p.merge_streamed(vec![file("m")]); + p.merge_streamed(vec![file("a")]); // sorts before "m" -> cursor should stay at 0 assert_eq!(p.cursor, 0); assert_eq!(p.entries[p.cursor].name, OsString::from("a")); @@ -984,9 +1529,9 @@ mod tests { assert_eq!(p.cursor, 1); let selected_name = p.entries[p.cursor].name.clone(); - // Further streamed inserts must not yank the cursor back now that - // the user has moved it; re-clamp only if out of range. - p.insert_streamed(dir("z")); + // Further streamed batches must not yank the cursor back now that + // the user has moved it, nor move it off its entry. + p.merge_streamed(vec![dir("z")]); assert_eq!(p.entries[p.cursor].name, selected_name); } @@ -1069,10 +1614,8 @@ mod tests { #[test] fn selection_status_counts_files_and_zeros_directories() { let mut p = PanelState::new(PathBuf::from("/")); - p.entries = vec![ - Entry { name: "a.txt".into(), kind: EntryKind::File, size: 100, modified: None }, - Entry { name: "sub".into(), kind: EntryKind::Directory, size: 0, modified: None }, - ]; + p.entries = + vec![Entry::new("a.txt", EntryKind::File, 100, None, false, false), Entry::new("sub", EntryKind::Directory, 0, None, false, false)]; assert_eq!(p.selection_status(), None); p.select_matching("*"); assert_eq!(p.selection_status(), Some("2 files selected, 100 bytes".to_string())); @@ -1109,6 +1652,97 @@ mod tests { assert!(!wildcard_match("*.txt", "readme.md")); } + // ----------------------------------------------------------------- + // filter-selection-safety (R-19): character-correct, bounded matching + // (tasks.md 3.1, 4.7/4.8/4.9/4.10; design D4) + // ----------------------------------------------------------------- + + #[test] + fn wildcard_question_mark_matches_exactly_one_character_not_one_byte() { + // selection "Question mark matches one multi-byte character". + assert!(wildcard_match("?.txt", "é.txt"), "é is one character (two UTF-8 bytes)"); + assert!(wildcard_match("?.txt", "日.txt"), "日 is one character (three UTF-8 bytes)"); + assert!(!wildcard_match("?.txt", "ab.txt"), "two characters need ??"); + assert!(wildcard_match("??.txt", "ab.txt")); + } + + #[test] + fn wildcard_match_edge_cases() { + assert!(wildcard_match("*.TXT", "readme.txt"), "matching ignores case"); + assert!(wildcard_match("a*", "a"), "* matches the empty run"); + assert!(wildcard_match("*", ""), "* matches the empty string"); + assert!(!wildcard_match("?", ""), "? needs exactly one character"); + assert!(!wildcard_match("?.txt", ".txt")); + assert!(wildcard_match("*.*", "readme.txt")); + assert!(!wildcard_match("*.txt", "readme.txt.bak")); + } + + #[test] + fn wildcard_deselect_uses_the_same_character_based_matcher() { + // selection "Deselect uses the same matching" (tasks.md 4.10). + let mut p = PanelState::new(PathBuf::from("/")); + p.entries = vec![file("é.txt"), file("ab.txt")]; + p.select_matching("*.txt"); + assert_eq!(p.selected.len(), 2); + p.deselect_matching("?.txt"); + assert_eq!(p.selected, HashSet::from([OsString::from("ab.txt")]), "?.txt deselects é.txt (one char) but not ab.txt (two)"); + } + + #[test] + fn wildcard_pathological_pattern_completes_promptly() { + // selection "Pathological pattern completes promptly": 1,000 entries + // of 200 `a`s each against a ten-star pattern — the old recursive + // matcher was exponential here; the two-pointer one is + // O(|pattern| × |name|) per entry. + let mut p = PanelState::new(PathBuf::from("/")); + p.entries = (0..1000).map(|i| file(&format!("{}{i:04}", "a".repeat(200)))).collect(); + let start = std::time::Instant::now(); + p.select_matching("*a*a*a*a*a*a*a*a*a*a*b"); + let elapsed = start.elapsed(); + assert!(p.selected.is_empty(), "nothing matches"); + assert!(elapsed < std::time::Duration::from_secs(5), "select_matching stalled for {elapsed:?}"); + } + + mod wildcard_proptests { + use super::*; + use proptest::prelude::*; + + /// The naive recursive definition of the same semantics — correct + /// but exponential, so the strategies keep the inputs tiny. The + /// property pins the iterative two-pointer matcher against it + /// (tasks.md 4.9). + fn reference_match(pattern: &[char], name: &[char]) -> bool { + fn char_eq(a: char, b: char) -> bool { + a == b || a.to_lowercase().eq(b.to_lowercase()) + } + match (pattern.first(), name.first()) { + (None, None) => true, + (Some('*'), _) => reference_match(&pattern[1..], name) || (!name.is_empty() && reference_match(pattern, &name[1..])), + (Some('?'), Some(_)) => reference_match(&pattern[1..], &name[1..]), + (Some(&pc), Some(&nc)) if char_eq(pc, nc) => reference_match(&pattern[1..], &name[1..]), + _ => false, + } + } + + /// ASCII plus multi-byte characters, with the two wildcards mixed + /// into the pattern. + fn arb_pattern() -> impl Strategy { + prop::collection::vec(prop::sample::select(vec!['a', 'B', '.', 't', 'é', '日', '*', '?']), 0..8).prop_map(|cs| cs.into_iter().collect()) + } + + fn arb_name() -> impl Strategy { + prop::collection::vec(prop::sample::select(vec!['a', 'b', 'B', '.', 't', 'x', 'é', '日']), 0..8).prop_map(|cs| cs.into_iter().collect()) + } + + proptest! { + #[test] + fn matches_the_naive_reference_matcher(pattern in arb_pattern(), name in arb_name()) { + let want = reference_match(&pattern.chars().collect::>(), &name.chars().collect::>()); + prop_assert_eq!(wildcard_match(&pattern, &name), want, "pattern {:?} against name {:?}", pattern, name); + } + } + } + mod selection_proptests { use super::*; use proptest::prelude::*; @@ -1139,16 +1773,11 @@ mod tests { } fn sized(name: &str, size: u64) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size, modified: None } + Entry::new(OsString::from(name), EntryKind::File, size, None, false, false) } fn timed(name: &str, day: u8) -> Entry { - Entry { - name: OsString::from(name), - kind: EntryKind::File, - size: 0, - modified: Some(DateTime { year: 2026, month: 1, day, hour: 0, minute: 0 }), - } + Entry::new(OsString::from(name), EntryKind::File, 0, Some(DateTime { year: 2026, month: 1, day, hour: 0, minute: 0 }), false, false) } fn names(p: &PanelState) -> Vec { @@ -1240,9 +1869,8 @@ mod tests { fn streamed_inserts_follow_the_active_sort_mode() { let mut p = PanelState::new(PathBuf::from("/")); p.sort_mode = SortMode::Size; - p.insert_streamed(sized("big", 100)); - p.insert_streamed(sized("small", 1)); - p.insert_streamed(sized("mid", 50)); + p.merge_streamed(vec![sized("big", 100)]); + p.merge_streamed(vec![sized("small", 1), sized("mid", 50)]); assert_eq!(names(&p), vec!["small", "mid", "big"]); } @@ -1275,11 +1903,8 @@ mod tests { } fn arb_entry() -> impl Strategy { - (arb_name(), any::()).prop_map(|(name, is_dir)| Entry { - name: OsString::from(name), - kind: if is_dir { EntryKind::Directory } else { EntryKind::File }, - size: 0, - modified: None, + (arb_name(), any::()).prop_map(|(name, is_dir)| { + Entry::new(OsString::from(name), if is_dir { EntryKind::Directory } else { EntryKind::File }, 0, None, false, false) }) } @@ -1295,7 +1920,7 @@ mod tests { fn insert_sorted_preserves_sorted_order(names in prop::collection::vec(arb_name(), 0..15)) { let mut entries: Vec = Vec::new(); for name in names { - let e = Entry { name: OsString::from(name), kind: EntryKind::File, size: 0, modified: None }; + let e = Entry::new(OsString::from(name), EntryKind::File, 0, None, false, false); insert_sorted(&mut entries, e, SortMode::Name, SortDirection::Asc); } for w in entries.windows(2) { @@ -1327,11 +1952,15 @@ mod tests { const MODES: [SortMode; 4] = [SortMode::Name, SortMode::Extension, SortMode::Time, SortMode::Size]; fn arb_sortable_entry() -> impl Strategy { - ("[a-c]{1,3}(\\.[a-c]{1,3})?", 0u64..4, prop::option::of(1u8..4), any::()).prop_map(|(name, size, day, is_dir)| Entry { - name: OsString::from(name), - kind: if is_dir { EntryKind::Directory } else { EntryKind::File }, - size, - modified: day.map(|d| DateTime { year: 2026, month: 1, day: d, hour: 0, minute: 0 }), + ("[a-c]{1,3}(\\.[a-c]{1,3})?", 0u64..4, prop::option::of(1u8..4), any::()).prop_map(|(name, size, day, is_dir)| { + Entry::new( + OsString::from(name), + if is_dir { EntryKind::Directory } else { EntryKind::File }, + size, + day.map(|d| DateTime { year: 2026, month: 1, day: d, hour: 0, minute: 0 }), + false, + false, + ) }) } @@ -1495,6 +2124,27 @@ mod tests { assert_eq!(cursor_name, "cherry", "cursor must land on a still-visible entry, not a hidden one"); } + #[test] + fn narrowing_keeps_the_hidden_flag_on_matching_entries() { + // hidden-entry-styling tasks.md 1.3: the quick filter narrows by + // index into the same `Entry` values, so a hidden entry that + // matches keeps its flag (and `..` never carries it). + let mut p = PanelState::new(PathBuf::from("/")); + p.entries = vec![ + Entry::parent_dir(), + Entry::new(OsString::from("desktop.ini"), EntryKind::File, 0, None, true, false), + Entry::new(OsString::from("readme.md"), EntryKind::File, 0, None, false, false), + ]; + p.activate_quick_filter(); + for c in "desktop".chars() { + p.quick_filter_push(c); + } + let visible: Vec<&Entry> = p.visible_indices().into_iter().map(|i| &p.entries[i]).collect(); + assert_eq!(visible.len(), 2, "`..` plus the one matching entry"); + assert!(!visible[0].hidden, "`..` is never hidden"); + assert!(visible[1].hidden, "the flag survives quick-filter narrowing"); + } + #[test] fn navigation_is_restricted_to_matching_entries() { let mut p = panel_with(&["apple", "avocado", "banana", "apricot"]); @@ -1537,6 +2187,79 @@ mod tests { p.quick_filter_backspace(); assert_eq!(p.quick_filter.as_deref(), Some("")); } + + // ------------------------------------------------------------- + // filter-selection-safety: no current entry when the filter hides + // everything (tasks.md 1.1/1.2/1.3, 4.1/4.2; design D1) + // ------------------------------------------------------------- + + /// A drive-root-style listing: no `..`, so a pattern matching + /// nothing leaves *no* visible entry at all. + fn drive_root_with(names: &[&str]) -> PanelState { + let mut p = PanelState::new(PathBuf::from(r"C:\")); + p.entries = names.iter().map(|n| file(n)).collect(); + p + } + + fn filter_by(p: &mut PanelState, pattern: &str) { + p.activate_quick_filter(); + for c in pattern.chars() { + p.quick_filter_push(c); + } + } + + #[test] + fn a_filter_matching_nothing_at_a_drive_root_means_no_current_entry() { + // quick-filter "No visible entry at a drive root means no + // current entry". + let mut p = drive_root_with(&["pagefile.sys", "notes.txt"]); + filter_by(&mut p, "zzzz"); + assert!(p.visible_indices().is_empty(), "setup precondition: nothing visible"); + assert!(p.selected().is_none(), "the panel has no current entry while the filter hides everything"); + } + + #[test] + fn selected_returns_the_cursor_entry_when_it_matches_the_filter() { + let mut p = panel_with(&["report.txt", "readme.md"]); + p.cursor = 1; // "report.txt" + filter_by(&mut p, "rep"); + assert_eq!(p.selected().map(|e| e.name.to_string_lossy().into_owned()), Some("report.txt".to_string())); + } + + #[test] + fn selected_without_a_filter_returns_the_cursor_entry_exactly_as_before() { + let mut p = panel_with(&["a.txt", "b.txt"]); + p.cursor = 2; + assert_eq!(p.selected().map(|e| e.name.to_string_lossy().into_owned()), Some("b.txt".to_string())); + p.cursor = 0; + assert_eq!(p.selected().map(|e| e.kind), Some(EntryKind::ParentDir)); + } + + #[test] + fn ins_with_no_current_entry_leaves_the_selection_unchanged() { + // R-17 regression: Ins used to read `entries[cursor]` directly, + // toggling the hidden cursor entry. + let mut p = drive_root_with(&["pagefile.sys", "notes.txt"]); + p.cursor = 0; + filter_by(&mut p, "zzzz"); + p.toggle_selection_and_advance(); + assert!(p.selected.is_empty(), "Ins must not toggle an entry the filter hides"); + assert_eq!(p.cursor, 0, "no visible entry: the cursor cannot advance"); + } + + #[test] + fn widening_the_pattern_restores_a_current_entry() { + // quick-filter "Widening the pattern restores a current entry": + // the cursor stays put while nothing is visible and snaps to the + // nearest visible entry once the pattern matches again. + let mut p = drive_root_with(&["pagefile.sys", "notes.txt"]); + p.cursor = 1; // "notes.txt" + filter_by(&mut p, "notesx"); + assert!(p.selected().is_none()); + assert_eq!(p.cursor, 1, "the cursor itself is left where it was"); + p.quick_filter_backspace(); // "notes" + assert_eq!(p.selected().map(|e| e.name.to_string_lossy().into_owned()), Some("notes.txt".to_string())); + } } // ----------------------------------------------------------------- @@ -1674,7 +2397,7 @@ mod tests { use crate::listing::Entry as ListEntry; fn dir_child(name: &str) -> ListEntry { - ListEntry { name: OsString::from(name), kind: EntryKind::Directory, size: 0, modified: None } + ListEntry::new(OsString::from(name), EntryKind::Directory, 0, None, false, false) } #[test] @@ -1873,14 +2596,14 @@ mod tests { // Narrow to only even-numbered entries: "e25" is filtered out // and the cursor snaps to the nearest still-visible entry. - p.quick_filter = Some(String::new()); + p.set_quick_filter(Some(String::new())); p.activate_quick_filter(); for c in "e2".chars() { p.quick_filter_push(c); } // "e2","e20".."e29" all contain "e2"; narrow further to land on // a single, known entry. - p.quick_filter = Some("e24".to_string()); + p.set_quick_filter(Some("e24".to_string())); let visible = p.visible_indices(); assert_eq!(visible.len(), 1, "exactly \"e24\" should match"); p.cursor = visible[0]; @@ -1903,7 +2626,7 @@ mod tests { p.set_sort_mode(SortMode::Size); p.resort(); // no-op re-affirmation the mode is size-sorted already p.entries.reverse(); // now largest-to-smallest ("e0" last) - p.cursor = p.entries.iter().position(|e| e.name == OsString::from("e0")).unwrap(); + p.cursor = p.entries.iter().position(|e| e.name == "e0").unwrap(); assert_eq!(p.cursor, 29); p.ensure_cursor_visible(10); assert!(p.scroll_offset <= p.cursor && p.cursor < p.scroll_offset + 10); @@ -1915,7 +2638,7 @@ mod tests { let mut p = PanelState::new(PathBuf::from("/")); p.sort_mode = SortMode::Name; for i in 0..15 { - p.insert_streamed(file(&format!("e{i:02}"))); + p.merge_streamed(vec![file(&format!("e{i:02}"))]); // Mirrors what `update::apply_listing_event` does after // every `ListingChunk`: re-clamp with a fixed 10-row window. p.ensure_cursor_visible(10); @@ -1947,6 +2670,630 @@ mod tests { } } + // ----------------------------------------------------------------- + // Re-read anchor (reread-preserves-cursor tasks 1.3-1.5, design D1/D3) + // ----------------------------------------------------------------- + + mod reread_anchor_tests { + use super::*; + + /// A panel of `n` plain files, `Unsorted` so position `i` is exactly + /// `e{i}`, with a `Complete` listing (anchors only capture from one). + fn complete_panel_of(n: usize) -> PanelState { + let mut p = PanelState::new(PathBuf::from("/a")); + p.sort_mode = SortMode::Unsorted; + p.entries = (0..n).map(|i| file(&format!("e{i}"))).collect(); + p.progress = ListingProgress::Complete { count: n }; + p + } + + #[test] + fn capture_records_names_cursor_and_offset_only_when_complete() { + let mut p = complete_panel_of(5); + p.cursor = 3; + p.scroll_offset = 2; + p.capture_reread_anchor(); + let anchor = p.reread_anchor.as_ref().expect("a Complete listing captures"); + assert_eq!(anchor.names, p.entries.iter().map(|e| e.name.clone()).collect::>()); + assert_eq!(anchor.cursor, 3); + assert_eq!(anchor.scroll_offset, 2); + + // A still-streaming listing captures nothing — and clears any + // anchor an earlier re-read left behind (design D5). + p.progress = ListingProgress::Streaming { count: 5 }; + p.capture_reread_anchor(); + assert!(p.reread_anchor.is_none()); + } + + #[test] + fn capture_with_an_active_quick_filter_records_a_zero_offset() { + // The offset counts rows of the filter-narrowed list, which + // `begin_new_listing` discards — so only the cursor entry is + // restorable, not the offset (design D3). + let mut p = complete_panel_of(30); + p.set_quick_filter(Some("e1".to_string())); + p.cursor = 10; + p.scroll_offset = 7; + p.capture_reread_anchor(); + let anchor = p.reread_anchor.as_ref().unwrap(); + assert_eq!(anchor.scroll_offset, 0); + assert_eq!(anchor.cursor, 10, "the cursor entry still anchors"); + } + + #[test] + fn apply_resolves_the_same_entry_when_it_survived() { + let mut p = complete_panel_of(30); + p.cursor = 12; // "e12" + p.scroll_offset = 5; + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p.entries = (0..30).map(|i| file(&format!("e{i}"))).collect(); + + assert!(p.apply_reread_anchor(10)); + assert_eq!(p.cursor, 12); + assert!(p.cursor_user_moved, "a resolved anchor marks the cursor as user-placed so streaming stops pinning it to the top"); + assert_eq!(p.scroll_offset, 5); + } + + #[test] + fn apply_falls_forward_to_the_next_survivor_then_backward_to_the_nearest_previous() { + // Resolution order (design D1): names[cursor], then + // names[cursor + 1..], then names[..cursor] reversed — covering + // a multi-selection delete with no special casing. + let mut p = complete_panel_of(5); // e0..e4, cursor on e2 + p.cursor = 2; + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + // {e1, e2, e3} deleted with the cursor on e2 -> land on e4. + p.entries = vec![file("e0"), file("e4")]; + assert!(p.apply_reread_anchor(10)); + assert_eq!(p.entries[p.cursor].name, OsString::from("e4")); + + // The whole tail from the cursor onward is gone -> nearest + // survivor *before* the old row. + let mut p = complete_panel_of(5); + p.cursor = 3; // "e3" + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p.entries = vec![file("e0"), file("e1")]; + assert!(p.apply_reread_anchor(10)); + assert_eq!(p.entries[p.cursor].name, OsString::from("e1")); + assert_eq!(p.cursor, 1, "e1 is now the last entry"); + } + + #[test] + fn apply_returns_false_and_stays_put_when_no_captured_name_is_present_yet() { + // The anchored entries have not streamed in yet (or never will): + // the panel keeps whatever the stream did to it (design D2). + let mut p = complete_panel_of(5); + p.cursor = 2; + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p.entries = vec![file("zz")]; + assert!(!p.apply_reread_anchor(10)); + assert_eq!(p.cursor, 0); + assert!(!p.cursor_user_moved); + } + + #[test] + fn apply_re_clamps_the_restored_offset_against_the_shorter_list() { + // 60 entries in a 20-row window, offset 40, cursor on the last; + // the re-read comes back one entry shorter (design D3). + let mut p = complete_panel_of(60); + p.cursor = 59; + p.scroll_offset = 40; + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p.entries = (0..59).map(|i| file(&format!("e{i}"))).collect(); + assert!(p.apply_reread_anchor(20)); + assert_eq!(p.entries[p.cursor].name, OsString::from("e58")); + assert_eq!(p.scroll_offset, 39, "min(40, 59 - 20): the window must not extend past the end of the shorter list"); + } + + #[test] + fn brief_mode_restores_the_offset_through_the_column_aligned_clamp() { + // Brief's offset is a column position, so the rows-based clamp + // does not apply: the raw offset is restored and the caller's + // `ensure_cursor_visible_brief` pass keeps it on a whole-column + // multiple (design D3). + let mut p = complete_panel_of(60); + p.display_mode = DisplayMode::Brief; + p.cursor = 59; + p.scroll_offset = 40; // column 2 of a rows=20 grid + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p.entries = (0..59).map(|i| file(&format!("e{i}"))).collect(); + + assert!(p.apply_reread_anchor(20)); + assert_eq!(p.entries[p.cursor].name, OsString::from("e58")); + assert_eq!(p.scroll_offset, 40, "no rows-based clamp in Brief mode"); + + // The reducer's follow-up reconciliation re-aligns it (rows=20, + // cols=3): the cursor's column is already inside the window, so + // the offset stands — still on an exact column boundary. + p.ensure_cursor_visible_brief(20, 3); + assert_eq!(p.scroll_offset, 40); + assert_eq!(p.scroll_offset % 20, 0, "the restored offset stays on a whole-column multiple"); + } + + #[test] + fn begin_new_listing_keeps_the_anchor_on_a_same_directory_reread_and_clears_it_on_navigation() { + let mut p = complete_panel_of(5); + p.cursor = 2; + p.capture_reread_anchor(); + + p.begin_new_listing(PathBuf::from("/a")); + assert!(p.reread_anchor.is_some(), "a same-directory re-read keeps the freshly captured anchor (design D4)"); + + p.begin_new_listing(PathBuf::from("/b")); + assert!(p.reread_anchor.is_none(), "navigation to a different directory clears it (design D4)"); + } + + #[test] + fn the_anchor_round_trips_through_tab_stash_and_restore() { + // A tab switched away mid-re-read keeps its anchor for when its + // listing completes (design D4). + let mut p = complete_panel_of(5); + p.cursor = 2; + p.scroll_offset = 1; + p.capture_reread_anchor(); + + p.open_tab(); // the anchored tab is stashed; the new tab inherits a clone + p.switch_tab(1); + let anchor = p.reread_anchor.as_ref().expect("the stashed tab's anchor must round-trip"); + assert_eq!(anchor.cursor, 2); + assert_eq!(anchor.scroll_offset, 1); + assert_eq!(anchor.names.len(), 5); + } + } + + // ----------------------------------------------------------------- + // streaming-listing-performance: the per-batch merge (design D3) and + // the batch-proportional re-read anchor (design D6) + // ----------------------------------------------------------------- + + mod streaming_merge_tests { + use super::*; + use proptest::prelude::*; + + fn arb_entry() -> impl Strategy { + ("[a-c]{1,3}(\\.[a-c]{1,3})?", 0u64..4, prop::option::of(1u8..4), 0u8..3).prop_map(|(name, size, day, kind)| { + let kind = match kind { + 0 => EntryKind::File, + 1 => EntryKind::Directory, + _ => EntryKind::ParentDir, + }; + let name = if kind == EntryKind::ParentDir { "..".to_string() } else { name }; + Entry::new(OsString::from(name), kind, size, day.map(|d| DateTime { year: 2026, month: 1, day: d, hour: 0, minute: 0 }), false, false) + }) + } + + fn arb_mode() -> impl Strategy { + prop_oneof![Just(SortMode::Name), Just(SortMode::Extension), Just(SortMode::Time), Just(SortMode::Size), Just(SortMode::Unsorted)] + } + + proptest! { + /// Random entries (ties included) split at random points into + /// batches, under every mode and direction: the merged listing is + /// exactly one bulk stable sort of everything received. + #[test] + fn merging_batches_equals_one_bulk_stable_sort(items in prop::collection::vec((arb_entry(), any::()), 0..24), mode in arb_mode(), desc in any::()) { + let dir = if desc { SortDirection::Desc } else { SortDirection::Asc }; + let mut expected: Vec = items.iter().map(|(e, _)| e.clone()).collect(); + expected.sort_by(|a, b| cmp_entries(a, b, mode, dir)); + + let mut p = PanelState::new(PathBuf::from("/")); + p.sort_mode = mode; + p.sort_direction = dir; + let mut batch = Vec::new(); + for (e, cut) in items { + batch.push(e); + if cut { + p.merge_streamed(std::mem::take(&mut batch)); + } + } + p.merge_streamed(batch); + prop_assert_eq!(p.entries, expected); + prop_assert_eq!(p.cursor, 0, "pinned to the top while the user has not moved"); + } + + /// Once the user has moved, the cursor follows its entry through + /// every later batch. + #[test] + fn a_moved_cursor_stays_on_its_entry_through_every_batch(first in prop::collection::vec(arb_entry(), 1..8), later in prop::collection::vec((arb_entry(), any::()), 0..24), pick in 0usize..8, mode in arb_mode()) { + let mut p = PanelState::new(PathBuf::from("/")); + p.sort_mode = mode; + p.merge_streamed(first); + p.move_cursor(CursorMove::Down(pick)); + let on = p.entries[p.cursor].clone(); + let mut batch = Vec::new(); + for (e, cut) in later { + batch.push(e); + if cut { + p.merge_streamed(std::mem::take(&mut batch)); + prop_assert_eq!(&p.entries[p.cursor], &on); + } + } + p.merge_streamed(batch); + prop_assert_eq!(&p.entries[p.cursor], &on); + } + } + + #[test] + fn cursor_stays_on_its_entry_when_a_batch_sorts_before_it() { + // panel-navigation "Cursor stays on its entry while entries + // stream in". + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![Entry::parent_dir(), file("report.txt"), file("zeta.txt")]); + p.move_cursor(CursorMove::Down(1)); + assert_eq!(p.entries[p.cursor].name, OsString::from("report.txt")); + p.merge_streamed(vec![file("zz.txt"), file("alpha.txt"), file("readme.md"), file("beta.txt")]); + assert_eq!(p.entries[p.cursor].name, OsString::from("report.txt"), "three entries landed above it; the cursor index moved with it"); + assert_eq!(p.cursor, 4); + assert_eq!(names(&p), vec!["..", "alpha.txt", "beta.txt", "readme.md", "report.txt", "zeta.txt", "zz.txt"]); + } + + #[test] + fn cursor_stays_pinned_to_row_zero_until_the_user_moves() { + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![file("m")]); + p.merge_streamed(vec![file("z"), file("a")]); + assert_eq!(p.cursor, 0); + assert_eq!(p.entries[0].name, OsString::from("a")); + assert!(!p.cursor_user_moved); + } + + #[test] + fn unsorted_mode_appends_in_arrival_order_with_the_parent_first() { + let mut p = PanelState::new(PathBuf::from("/")); + p.sort_mode = SortMode::Unsorted; + p.merge_streamed(vec![file("zebra"), file("apple")]); + p.merge_streamed(vec![Entry::parent_dir()]); + p.merge_streamed(vec![file("mango"), file("banana")]); + assert_eq!(names(&p), vec!["..", "zebra", "apple", "mango", "banana"]); + } + + #[test] + fn an_empty_batch_changes_nothing() { + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![file("a"), file("b")]); + p.move_cursor(CursorMove::Down(1)); + let before = p.clone(); + p.merge_streamed(Vec::new()); + assert_eq!(p, before); + } + + #[test] + fn merging_with_an_active_filter_keeps_the_visible_list_current() { + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![Entry::parent_dir(), file("report.txt"), file("notes.md")]); + p.activate_quick_filter(); + p.quick_filter_push('r'); + p.quick_filter_push('e'); + p.merge_streamed(vec![file("readme.md"), file("alpha")]); + let visible: Vec = p.visible_indices().into_iter().map(|i| p.entries[i].name.to_string_lossy().into_owned()).collect(); + assert_eq!(visible, vec!["..", "readme.md", "report.txt"]); + } + + // --- the re-read anchor, resolved from each batch (design D6) --- + + /// The resolution the anchor made before this change — a full scan of + /// the captured names against the entries — as the oracle. + fn old_resolve(names: &[OsString], cursor: usize, entries: &[Entry]) -> Option { + if names.is_empty() { + return None; + } + let at = cursor.min(names.len() - 1); + std::iter::once(&names[at]) + .chain(names[at + 1..].iter()) + .chain(names[..at].iter().rev()) + .find_map(|name| entries.iter().position(|e| &e.name == name)) + } + + /// A `Complete`, `Unsorted` panel of `e0..e{n}` with the cursor on + /// `e{cursor}`, with its anchor captured and a same-directory re-read + /// begun — ready to receive the re-read's batches. + fn rereading(n: usize, cursor: usize, mode: SortMode) -> PanelState { + let mut p = PanelState::new(PathBuf::from("/a")); + p.sort_mode = mode; + p.set_entries((0..n).map(|i| file(&format!("e{i}"))).collect()); + p.resort(); + p.progress = ListingProgress::Complete { count: n }; + p.cursor = p.entries.iter().position(|e| e.name == OsString::from(format!("e{cursor}"))).unwrap(); + p.capture_reread_anchor(); + p.begin_new_listing(PathBuf::from("/a")); + p + } + + fn cursor_name(p: &PanelState) -> String { + p.entries[p.cursor].name.to_string_lossy().into_owned() + } + + #[test] + fn the_anchored_entry_arriving_in_the_last_of_many_batches_restores_the_cursor() { + let mut p = rereading(100, 57, SortMode::Unsorted); + let anchor_names = p.reread_anchor.as_ref().unwrap().names.clone(); + // Everything but e57 streams in first, in batches of ten; e57 + // arrives alone in the last batch. + let others: Vec = (0..100).filter(|&i| i != 57).map(|i| file(&format!("e{i}"))).collect(); + for chunk in others.chunks(10) { + p.merge_streamed(chunk.to_vec()); + assert!(p.apply_reread_anchor(10)); + // Until e57 lands the forward survivor (e58) holds the cursor, + // exactly as the full scan decided before. + let expected = old_resolve(&anchor_names, 57, &p.entries).unwrap(); + assert_eq!(p.cursor, expected); + } + assert_eq!(cursor_name(&p), "e58"); + p.merge_streamed(vec![file("e57")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e57"); + assert!(p.cursor_user_moved); + } + + #[test] + fn a_better_ranked_name_arriving_after_a_worse_ranked_one_moves_the_cursor_to_it() { + // Resolution order from a cursor on e5: e5, e6, e7, ..., then e4, + // e3, ... (reread-preserves-cursor design D1). + let mut p = rereading(10, 5, SortMode::Unsorted); + p.merge_streamed(vec![file("e7")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7"); + p.merge_streamed(vec![file("e3")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7", "a name behind the cursor ranks below one ahead of it"); + p.merge_streamed(vec![file("e6")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e6"); + p.merge_streamed(vec![file("e5")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e5"); + p.merge_streamed(vec![file("e4"), file("e8")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e5", "nothing outranks the cursor's own entry"); + } + + #[test] + fn a_user_move_during_the_re_read_stream_is_overridden_by_the_next_batch_as_before() { + let mut p = rereading(10, 5, SortMode::Unsorted); + p.merge_streamed(vec![file("e0"), file("e7")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7"); + p.move_cursor(CursorMove::Home); + assert_eq!(cursor_name(&p), "e0"); + // The next batch's application re-applies the anchor (panel- + // navigation "No jump while the re-read streams" keeps the anchor + // in charge until completion). + p.merge_streamed(vec![file("e9")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7"); + } + + #[test] + fn a_re_sort_mid_re_read_still_resolves_the_anchor() { + // Ctrl+F3..F6 while the re-read streams invalidates the tracked + // index; the next application re-finds it with one scan, and + // later batches track it again (design D6). + let mut p = rereading(10, 5, SortMode::Name); + p.merge_streamed(vec![file("e7"), file("e1")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7"); + p.set_sort_mode(SortMode::Size); + p.sort_direction = SortDirection::Desc; + p.resort(); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e7", "re-found after the re-order"); + p.merge_streamed(vec![file("e5"), file("e2")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e5"); + p.set_sort_mode(SortMode::Name); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e5"); + } + + #[test] + fn a_tab_round_trip_mid_re_read_keeps_the_tracked_anchor_usable() { + let mut p = rereading(10, 5, SortMode::Unsorted); + p.merge_streamed(vec![file("e6")]); + assert!(p.apply_reread_anchor(10)); + p.open_tab(); + p.switch_tab(1); + p.merge_streamed(vec![file("e5")]); + assert!(p.apply_reread_anchor(10)); + assert_eq!(cursor_name(&p), "e5"); + } + + proptest! { + /// Any subset of the captured names arriving in any order and any + /// batching, under `Unsorted` or `Name` order: after every batch + /// the anchor lands where the old full scan would have. + #[test] + fn batch_tracked_resolution_matches_the_full_scan(items in prop::collection::vec((any::(), 0u8..=255, any::()), 1..14), cursor_pick in 0usize..14, by_name in any::(), extra in prop::collection::vec("[x-z]{1,2}", 0..4)) { + let n = items.len(); + let cursor = cursor_pick % n; + let mode = if by_name { SortMode::Name } else { SortMode::Unsorted }; + let mut p = rereading(n, cursor, mode); + let anchor_names = p.reread_anchor.as_ref().unwrap().names.clone(); + let anchor_cursor = p.reread_anchor.as_ref().unwrap().cursor; + + // Present captured names in shuffle-key order, with some + // uncaptured newcomers mixed in, cut into batches. + let mut arrivals: Vec<(u8, Entry, bool)> = items + .iter() + .enumerate() + .filter(|(_, (present, _, _))| *present) + .map(|(i, (_, key, cut))| (*key, file(&format!("e{i}")), *cut)) + .collect(); + for (j, name) in extra.iter().enumerate() { + arrivals.push(((j * 37) as u8, file(name), j % 2 == 0)); + } + arrivals.sort_by_key(|(key, _, _)| *key); + + let mut batch = Vec::new(); + let flush = |p: &mut PanelState, batch: &mut Vec| -> Result<(), TestCaseError> { + p.merge_streamed(std::mem::take(batch)); + let hit = p.apply_reread_anchor(10); + let expected = old_resolve(&anchor_names, anchor_cursor, &p.entries); + prop_assert_eq!(hit, expected.is_some()); + if let Some(idx) = expected { + prop_assert_eq!(p.cursor, idx); + } + Ok(()) + }; + for (_, e, cut) in arrivals { + batch.push(e); + if cut { + flush(&mut p, &mut batch)?; + } + } + flush(&mut p, &mut batch)?; + } + } + } + + // ----------------------------------------------------------------- + // streaming-listing-performance: the cached visible list (design D5) + // ----------------------------------------------------------------- + + mod visible_cache_tests { + use super::*; + + /// The visible list computed the way `visible_indices()` did before + /// the cache existed. + fn from_scratch(p: &PanelState) -> Vec { + match &p.quick_filter { + None => (0..p.entries.len()).collect(), + Some(pattern) => p + .entries + .iter() + .enumerate() + .filter(|(_, e)| e.kind == EntryKind::ParentDir || e.name.to_string_lossy().to_lowercase().contains(&pattern.to_lowercase())) + .map(|(i, _)| i) + .collect(), + } + } + + /// Every accessor agrees with a from-scratch filter. + fn assert_cache_consistent(p: &PanelState) { + let expected = from_scratch(p); + assert_eq!(p.visible_indices(), expected, "visible_indices"); + assert_eq!(p.visible_len(), expected.len(), "visible_len"); + for (pos, &i) in expected.iter().enumerate() { + assert_eq!(p.visible_at(pos), Some(i), "visible_at({pos})"); + assert_eq!(p.visible_position(i), Some(pos), "visible_position({i})"); + } + assert_eq!(p.visible_at(expected.len()), None); + for i in 0..p.entries.len() { + if !expected.contains(&i) { + assert_eq!(p.visible_position(i), None, "a hidden entry has no visible position"); + } + } + assert_eq!(p.visible_position(p.entries.len()), None); + assert_eq!(p.visible_window(0, usize::MAX).collect::>(), expected, "the whole window"); + if expected.len() >= 3 { + assert_eq!(p.visible_window(1, 2).collect::>(), expected[1..3].to_vec(), "an inner window"); + } + assert_eq!( + p.visible_window(expected.len().saturating_sub(1), 10).count(), + expected.len().min(1), + "a window running off the end is clipped" + ); + assert_eq!(p.visible_window(expected.len() + 5, 3).count(), 0, "a window past the end is empty"); + } + + #[test] + fn the_cache_matches_a_from_scratch_filter_after_every_mutator() { + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![Entry::parent_dir(), file("Report.txt"), file("readme.md"), file("notes.txt"), dir("repo")]); + assert_cache_consistent(&p); + p.activate_quick_filter(); + assert_cache_consistent(&p); + for c in "RE".chars() { + p.quick_filter_push(c); + assert_cache_consistent(&p); + } + assert_eq!(p.visible_len(), 4, "`..`, Report.txt, readme.md, repo — case-insensitively"); + p.merge_streamed(vec![file("reply.txt"), file("zebra")]); + assert_cache_consistent(&p); + p.set_sort_mode(SortMode::Size); + assert_cache_consistent(&p); + p.sort_direction = SortDirection::Desc; + p.resort(); + assert_cache_consistent(&p); + p.quick_filter_backspace(); + assert_cache_consistent(&p); + p.set_entries(vec![Entry::parent_dir(), file("alpha.txt"), file("rho")]); + assert_cache_consistent(&p); + p.set_quick_filter(Some("txt".to_string())); + assert_cache_consistent(&p); + assert_eq!(p.visible_indices(), vec![0, 1]); + p.clear_quick_filter(); + assert_cache_consistent(&p); + assert_eq!(p.visible_len(), p.entries.len()); + p.set_quick_filter(Some("zzz".to_string())); + assert_cache_consistent(&p); + p.begin_new_listing(PathBuf::from("/other")); + assert_cache_consistent(&p); + assert_eq!(p.quick_filter, None); + } + + #[test] + fn the_cache_follows_a_tab_switch() { + let mut p = PanelState::new(PathBuf::from("/")); + p.merge_streamed(vec![Entry::parent_dir(), file("report.txt"), file("notes.md")]); + p.set_quick_filter(Some("re".to_string())); + p.open_tab(); // the new, active tab inherits the filter + assert_cache_consistent(&p); + p.set_quick_filter(Some("notes".to_string())); + p.merge_streamed(vec![file("notes2.md")]); + assert_cache_consistent(&p); + assert_eq!(p.visible_len(), 3); + assert!(p.switch_tab(1)); + assert_cache_consistent(&p); + assert_eq!(p.quick_filter.as_deref(), Some("re")); + assert_eq!(p.visible_len(), 2); + assert!(p.switch_tab(2)); + assert_cache_consistent(&p); + assert_eq!(p.visible_len(), 3); + assert!(p.close_tab() || true); + assert_cache_consistent(&p); + } + + #[test] + fn snapping_prefers_the_lower_index_on_a_tie_as_before() { + // Visible entries at indices 1 and 3 with the cursor on 2: the old + // linear `min_by_key` kept the first minimum, index 1. + let mut p = PanelState::new(PathBuf::from(r"C:\")); + p.set_entries(vec![file("a0"), file("x1"), file("a2"), file("x3"), file("a4")]); + p.cursor = 2; + p.activate_quick_filter(); + p.quick_filter_push('x'); + assert_eq!(p.cursor, 1); + // Nearer on one side wins regardless of order. + p.clear_quick_filter(); + p.set_entries(vec![file("x0"), file("a1"), file("a2"), file("x3"), file("a4")]); + p.cursor = 2; + p.activate_quick_filter(); + p.quick_filter_push('x'); + assert_eq!(p.cursor, 3); + p.cursor = 4; + p.quick_filter_backspace(); + p.quick_filter_push('x'); + assert_eq!(p.cursor, 3, "only a lower neighbour exists"); + } + + #[test] + fn selected_uses_the_cache_to_hide_a_filtered_cursor_entry() { + let mut p = PanelState::new(PathBuf::from(r"C:\")); + p.set_entries(vec![file("pagefile.sys"), file("notes.txt")]); + p.cursor = 1; + p.set_quick_filter(Some("zzzz".to_string())); + assert!(p.selected().is_none()); + p.set_quick_filter(Some("notes".to_string())); + assert_eq!(p.selected().map(|e| e.name.to_string_lossy().into_owned()), Some("notes.txt".to_string())); + } + } + // ----------------------------------------------------------------- // Brief column-window and Tree scrolling (panel-scrolling task 2.3) // ----------------------------------------------------------------- @@ -2072,7 +3419,7 @@ mod tests { use crate::listing::Entry as ListEntry; fn dir_child(name: &str) -> ListEntry { - ListEntry { name: OsString::from(name), kind: EntryKind::Directory, size: 0, modified: None } + ListEntry::new(OsString::from(name), EntryKind::Directory, 0, None, false, false) } /// A flattened tree of `n` top-level directories under `C:\`. diff --git a/crates/filecommand-core/src/quicksearch.rs b/crates/filecommand-core/src/quicksearch.rs index a4aa640..78f268b 100644 --- a/crates/filecommand-core/src/quicksearch.rs +++ b/crates/filecommand-core/src/quicksearch.rs @@ -29,7 +29,9 @@ pub fn type_ahead_match(entries: &[Entry], pattern: &str) -> Option { return None; } let needle = pattern.to_lowercase(); - entries.iter().position(|e| e.name.to_string_lossy().to_lowercase().starts_with(&needle)) + // The precomputed lowercase key is exactly `to_string_lossy().to_lowercase()` + // (streaming-listing-performance design D4), so no name is lowercased here. + entries.iter().position(|e| e.sort_key().starts_with(&needle)) } // --------------------------------------------------------------------- @@ -46,17 +48,63 @@ pub struct FrecencyEntry { pub last_visited_ms: u64, } +/// The most directories the frecency index holds. Beyond this, the lowest +/// frecency entry is dropped, so the per-navigation work and the +/// `history.json` size stay bounded however long FileCommand has been in use +/// (fuzzy-jump "History is capped by frecency"; history-persistence- +/// hardening design D3). +pub const MAX_DIR_HISTORY: usize = 1_000; + /// Record a navigation into `path`: increments an existing entry's visit /// count and refreshes its last-visit time, or inserts a fresh one-visit /// entry when `path` has not been seen before (fuzzy-jump "Directory -/// history persistence" navigation-record scenario). +/// history persistence" navigation-record scenario). `now_ms` is Unix-epoch +/// time (design D1). When the insert pushes the index past +/// [`MAX_DIR_HISTORY`], the lowest-frecency entries are pruned — never the +/// one just visited. pub fn record_visit(history: &mut Vec, path: &Path, now_ms: u64) { match history.iter_mut().find(|e| e.path == path) { Some(entry) => { entry.visit_count += 1; entry.last_visited_ms = now_ms; } - None => history.push(FrecencyEntry { path: path.to_path_buf(), visit_count: 1, last_visited_ms: now_ms }), + None => { + history.push(FrecencyEntry { path: path.to_path_buf(), visit_count: 1, last_visited_ms: now_ms }); + prune_to_cap_keeping(history, MAX_DIR_HISTORY, now_ms, Some(path)); + } + } +} + +/// Drop entries until `history` holds at most `cap`: the lowest +/// [`frecency_score`] at `now_ms` goes first, and among equal scores the one +/// that appears last in list order, so the result is deterministic +/// (fuzzy-jump "History is capped by frecency"; design D3). +pub fn prune_to_cap(history: &mut Vec, cap: usize, now_ms: u64) { + prune_to_cap_keeping(history, cap, now_ms, None); +} + +/// [`prune_to_cap`], but `keep` (the directory just visited) is never the +/// one removed — excluded explicitly rather than by score, because two +/// one-visit entries stamped in the same millisecond tie, and the tie rule +/// would otherwise pick the newest, which is the visited one. +pub fn prune_to_cap_keeping(history: &mut Vec, cap: usize, now_ms: u64, keep: Option<&Path>) { + while history.len() > cap { + let mut victim: Option<(usize, u64)> = None; + for (i, entry) in history.iter().enumerate() { + if keep.is_some_and(|k| entry.path == k) { + continue; + } + let score = frecency_score(entry, now_ms); + // `<=` so a later entry with an equal score replaces an earlier + // one: ties remove the last in list order. + if victim.is_none_or(|(_, best)| score <= best) { + victim = Some((i, score)); + } + } + match victim { + Some((i, _)) => history.remove(i), + None => break, + }; } } @@ -97,7 +145,9 @@ pub fn rank_directories(history: &[FrecencyEntry], pattern: &str, now_ms: u64) - /// shows" (fuzzy-jump "Fuzzy jump dialog invocation"). #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct FuzzyJumpState { - pub pattern: String, + /// The fuzzy match pattern — a shared text field per text-field-editing + /// ("the pattern field is a shared text field"). + pub pattern: crate::line_edit::LineEdit, pub cursor: usize, } @@ -108,21 +158,17 @@ impl FuzzyJumpState { FuzzyJumpState::default() } - /// Append `c` to the pattern. The cursor resets to the top of the - /// newly narrowed list, mirroring how a fresh search naturally starts - /// from the best match (fuzzy-jump "Typing narrows to subsequence - /// matches"). - pub fn push(&mut self, c: char) { - self.pattern.push(c); - self.cursor = 0; - } - - /// Backspace: shorten the pattern by one character and reset the - /// cursor, mirroring [`Self::push`] (fuzzy-jump "Backspace re-widens - /// the list"). - pub fn backspace(&mut self) { - self.pattern.pop(); - self.cursor = 0; + /// Apply one editing op to the pattern (fuzzy-jump "Pattern edited + /// mid-string re-narrows the list"). A text-mutating op resets the + /// cursor to the top of the newly narrowed list, mirroring how a fresh + /// search naturally starts from the best match (fuzzy-jump "Typing + /// narrows to subsequence matches", "Backspace re-widens the list"); a + /// pure caret move leaves the highlight where it is. + pub fn apply(&mut self, op: &crate::line_edit::TextEditOp) { + self.pattern.apply(op); + if op.is_text_mutation() { + self.cursor = 0; + } } /// Move the highlighted row by `delta`, clamped to `[0, len)`. A no-op @@ -140,21 +186,38 @@ impl FuzzyJumpState { #[cfg(test)] mod fuzzy_jump_state_tests { use super::*; + use crate::line_edit::TextEditOp; #[test] fn typing_and_backspace_reset_the_cursor_to_the_top() { let mut fj = FuzzyJumpState::new(); fj.cursor = 2; - fj.push('a'); - assert_eq!(fj.pattern, "a"); + fj.apply(&TextEditOp::Insert('a')); + assert_eq!(fj.pattern.text(), "a"); assert_eq!(fj.cursor, 0); fj.cursor = 3; - fj.backspace(); - assert_eq!(fj.pattern, ""); + fj.apply(&TextEditOp::Backspace); + assert_eq!(fj.pattern.text(), ""); assert_eq!(fj.cursor, 0); } + #[test] + fn a_caret_move_keeps_the_highlight_where_it_is() { + let mut fj = FuzzyJumpState::new(); + fj.apply(&TextEditOp::Insert('p')); + fj.apply(&TextEditOp::Insert('r')); + fj.apply(&TextEditOp::Insert('j')); + fj.cursor = 2; + fj.apply(&TextEditOp::Left); + assert_eq!(fj.pattern.text(), "prj"); + assert_eq!(fj.pattern.caret(), 2); + assert_eq!(fj.cursor, 2, "a pure caret move never re-narrows the list"); + fj.apply(&TextEditOp::Insert('o')); + assert_eq!(fj.pattern.text(), "proj", "pattern edited mid-string re-narrows the list"); + assert_eq!(fj.cursor, 0, "a text change resets the highlight to the top"); + } + #[test] fn move_cursor_clamps_within_bounds() { let mut fj = FuzzyJumpState::new(); @@ -182,7 +245,7 @@ mod tests { use std::ffi::OsString; fn entry(name: &str) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size: 0, modified: None } + Entry::new(OsString::from(name), EntryKind::File, 0, None, false, false) } fn dir_entry(path: &str) -> FrecencyEntry { @@ -258,6 +321,75 @@ mod tests { assert_eq!(b.visit_count, 1); } + // ------------------------------------------------------------------- + // Cap and prune (fuzzy-jump "History is capped by frecency"; design D3) + // ------------------------------------------------------------------- + + fn full_history(now: u64) -> Vec { + // Entry i has i+1 visits, all stamped now, so entry 0 scores lowest. + (0..MAX_DIR_HISTORY) + .map(|i| FrecencyEntry { path: PathBuf::from(format!(r"C:\d{i}")), visit_count: i as u32 + 1, last_visited_ms: now }) + .collect() + } + + #[test] + fn prune_to_cap_removes_the_lowest_score_and_breaks_ties_by_last_position() { + let now = 10_000; + let mut history = vec![ + FrecencyEntry { path: PathBuf::from(r"C:\high"), visit_count: 9, last_visited_ms: now }, + FrecencyEntry { path: PathBuf::from(r"C:\tie-first"), visit_count: 1, last_visited_ms: now }, + FrecencyEntry { path: PathBuf::from(r"C:\tie-last"), visit_count: 1, last_visited_ms: now }, + ]; + prune_to_cap(&mut history, 2, now); + assert_eq!( + history.iter().map(|e| e.path.to_string_lossy().into_owned()).collect::>(), + vec![r"C:\high", r"C:\tie-first"], + "the last of the equal-score entries goes first" + ); + prune_to_cap(&mut history, 1, now); + assert_eq!(history[0].path, PathBuf::from(r"C:\high"), "then the lowest score"); + prune_to_cap(&mut history, 5, now); + assert_eq!(history.len(), 1, "a cap above the length changes nothing"); + } + + #[test] + fn record_visit_on_a_full_history_adds_the_path_and_never_prunes_it() { + let now = 1_000_000; + let mut history = full_history(now); + assert_eq!(history.len(), MAX_DIR_HISTORY); + record_visit(&mut history, Path::new(r"C:\brand-new"), now); + assert_eq!(history.len(), MAX_DIR_HISTORY, "the cap holds"); + assert!(history.iter().any(|e| e.path == Path::new(r"C:\brand-new")), "the visited path survives although it scores lowest"); + assert!(!history.iter().any(|e| e.path == Path::new(r"C:\d0")), "the previous lowest-frecency entry was dropped"); + } + + #[test] + fn revisiting_an_existing_path_in_a_full_history_prunes_nothing() { + let now = 1_000_000; + let mut history = full_history(now); + record_visit(&mut history, Path::new(r"C:\d0"), now + 1); + assert_eq!(history.len(), MAX_DIR_HISTORY); + assert_eq!(history[0].visit_count, 2); + assert_eq!(history[0].last_visited_ms, now + 1); + } + + /// R-13 regression (fuzzy-jump "Recency survives a restart"): visits + /// stamped with epoch time in an earlier session rank correctly in the + /// next, because both sessions share the time base. + #[test] + fn recency_survives_a_restart_when_visits_are_stamped_with_epoch_time() { + let e = 1_787_000_000_000u64; // some wall-clock epoch + let day = 24 * 60 * 60 * 1000; + let history = vec![ + FrecencyEntry { path: PathBuf::from(r"C:\old"), visit_count: 3, last_visited_ms: e }, + FrecencyEntry { path: PathBuf::from(r"C:\new"), visit_count: 3, last_visited_ms: e + 3 * day }, + ]; + // The "next session": its monotonic clock starts near zero, but the + // epoch reading is a minute after the last visit. + let ranked = rank_directories(&history, "", e + 3 * day + 60_000); + assert_eq!(ranked[0].path, PathBuf::from(r"C:\new")); + } + #[test] fn empty_pattern_shows_all_entries_in_frecency_rank_order() { let history = vec![ diff --git a/crates/filecommand-core/src/shell.rs b/crates/filecommand-core/src/shell.rs index ba0d1a6..bb5cc23 100644 --- a/crates/filecommand-core/src/shell.rs +++ b/crates/filecommand-core/src/shell.rs @@ -124,10 +124,7 @@ pub fn split_spec(spec: &str) -> Vec { /// The conventional "run this command string" flag(s) for a shell program, /// used when `shell =` names a program without giving its own arguments. fn conventional_args(program: &str) -> Vec { - let stem = Path::new(program) - .file_stem() - .map(|s| s.to_string_lossy().to_lowercase()) - .unwrap_or_else(|| program.to_lowercase()); + let stem = Path::new(program).file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_else(|| program.to_lowercase()); match stem.as_str() { "cmd" => vec!["/C".to_string()], // `-NoLogo` keeps the banner out of the suspended terminal; the @@ -155,7 +152,7 @@ pub fn resolve_shell(configured: Option<&str>) -> ShellSpec { /// Build the full invocation for `user_text`, run from `cwd` (the active /// panel's directory). Both the program path and `cwd` are routed through -/// [`spawn_safe_path`] so a shell configured with a long quoted program path, +/// `spawn_safe_path` so a shell configured with a long quoted program path, /// or a panel directory near/over `MAX_PATH`, still spawns correctly. pub fn build_command(configured: Option<&str>, user_text: &str, cwd: &Path) -> Invocation { let spec = resolve_shell(configured); @@ -174,15 +171,11 @@ pub fn is_executable_name(name: &str, pathext: &str) -> bool { if lower.ends_with(".lnk") { return true; } - pathext - .split(';') - .map(str::trim) - .filter(|ext| !ext.is_empty()) - .any(|ext| { - let ext = ext.to_lowercase(); - let ext = if ext.starts_with('.') { ext } else { format!(".{ext}") }; - lower.ends_with(&ext) - }) + pathext.split(';').map(str::trim).filter(|ext| !ext.is_empty()).any(|ext| { + let ext = ext.to_lowercase(); + let ext = if ext.starts_with('.') { ext } else { format!(".{ext}") }; + lower.ends_with(&ext) + }) } /// Whether the entry under the cursor is an executable target, using the diff --git a/crates/filecommand-core/src/stamps.rs b/crates/filecommand-core/src/stamps.rs new file mode 100644 index 0000000..961df80 --- /dev/null +++ b/crates/filecommand-core/src/stamps.rs @@ -0,0 +1,199 @@ +//! The reducer's animation-stamp post-condition (ui-animations "Render-only +//! motion model driven by the reducer clock"; design D1/D4). +//! +//! Animations interpolate `State::clock_ms - stamp` at render time, so the +//! reducer's only animation duty is to record *when* an overlay opened or +//! changed. Doing that at each of the many places an overlay opens would +//! scatter the rule (and miss ones added later), so — like the drag-clearing +//! rule in `update` — it is enforced once, as a post-condition on every +//! public `update` call: [`Snapshot::capture`] a few cheap marks before the +//! transition, then [`Snapshot::apply`] stamps whatever opened or changed +//! after it, using `state.clock_ms` (the last `Tick` reading). Nothing here +//! reads a clock, delays a transition, or changes anything but the stamps. + +use std::mem::{discriminant, Discriminant}; + +use crate::fs_ops::dialog::{FileOpSetup, RunningDialog}; +use crate::menu::{MenuId, MenuState}; +use crate::update::{Overlay, State, UiPhase}; + +/// What the F9 menu looked like before the transition. +struct MenuMark { + active: MenuId, + pulldown_open: bool, + selected: usize, + opened_at_ms: u64, + pulldown_opened_at_ms: u64, + selected_at_ms: Option, +} + +/// What a cursor-over-a-list pop-up (F2 user menu, Enter action menu) looked +/// like before the transition. +struct PopupMark { + cursor: usize, + opened_at_ms: u64, + selected_at_ms: Option, +} + +/// Which modal dialog is visible, at the granularity "a different dialog +/// replaced the previous one". Typing into an input, a progress counter +/// ticking, or a Help page turning leaves the mark unchanged; a progress +/// dialog giving way to a conflict dialog, or any dialog opening, changes it. +#[derive(Clone, Copy, PartialEq, Eq)] +struct DialogMark { + setup: Option>, + running: Option>, + summary: bool, + /// Which of the overlays that iris in (drive select, fuzzy jump, find + /// file, theme picker, help, the startup warning) is in the slot. The + /// pop-up menus animate on their own stamps and are not counted here. + iris_overlay: Option>, +} + +impl DialogMark { + fn of(state: &State) -> DialogMark { + DialogMark { + setup: match &state.phase { + UiPhase::FileOpSetup(setup) => Some(discriminant(setup)), + _ => None, + }, + running: match &state.phase { + UiPhase::FileOpRunning { dialog, .. } => Some(discriminant(dialog)), + _ => None, + }, + summary: matches!(state.phase, UiPhase::FileOpSummary(_)), + iris_overlay: match state.overlay() { + Some( + overlay @ (Overlay::DriveSelect(_) + | Overlay::FuzzyJump(_) + | Overlay::FindFile(_) + | Overlay::ThemePicker(_) + | Overlay::Help(_) + | Overlay::StartupWarning(_)), + ) => Some(discriminant(overlay)), + _ => None, + }, + } + } + + fn any(&self) -> bool { + self.setup.is_some() || self.running.is_some() || self.summary || self.iris_overlay.is_some() + } +} + +/// The pre-transition marks [`Snapshot::apply`] diffs against. +pub(crate) struct Snapshot { + menu: Option, + user_menu: Option, + file_action_menu: Option, + history_dialog: Option, + dialog: DialogMark, + quit_confirm: bool, + in_splash: bool, +} + +impl Snapshot { + pub(crate) fn capture(state: &State) -> Snapshot { + Snapshot { + menu: state.menu().map(|m| MenuMark { + active: m.active, + pulldown_open: m.pulldown_open, + selected: m.selected, + opened_at_ms: m.opened_at_ms, + pulldown_opened_at_ms: m.pulldown_opened_at_ms, + selected_at_ms: m.selected_at_ms, + }), + user_menu: state.user_menu().map(|m| PopupMark { cursor: m.cursor, opened_at_ms: m.opened_at_ms, selected_at_ms: m.selected_at_ms }), + file_action_menu: state.file_action_menu().map(|m| PopupMark { + cursor: m.cursor, + opened_at_ms: m.opened_at_ms, + selected_at_ms: m.selected_at_ms, + }), + history_dialog: state.history_dialog().map(|d| PopupMark { + cursor: d.cursor, + opened_at_ms: d.opened_at_ms, + selected_at_ms: d.selected_at_ms, + }), + dialog: DialogMark::of(state), + quit_confirm: state.quit_confirm, + in_splash: matches!(state.phase, UiPhase::Splash { .. }), + } + } + + /// Stamp, from `state.clock_ms`, everything that opened or changed since + /// [`Snapshot::capture`]. + pub(crate) fn apply(self, state: &mut State) { + let now = state.clock_ms; + + if let Some(menu) = state.menu_mut() { + stamp_menu(self.menu.as_ref(), menu, now); + } + if let Some(menu) = state.user_menu_mut() { + let (opened, selected) = stamp_popup(self.user_menu.as_ref(), menu.cursor, now); + menu.opened_at_ms = opened; + menu.selected_at_ms = selected; + } + if let Some(menu) = state.file_action_menu_mut() { + let (opened, selected) = stamp_popup(self.file_action_menu.as_ref(), menu.cursor, now); + menu.opened_at_ms = opened; + menu.selected_at_ms = selected; + } + if let Some(dialog) = state.history_dialog_mut() { + let (opened, selected) = stamp_popup(self.history_dialog.as_ref(), dialog.cursor, now); + dialog.opened_at_ms = opened; + dialog.selected_at_ms = selected; + } + + // A dialog opening, or a different one replacing it, irises; closing + // one stamps nothing (there is nothing left to animate). + let dialog = DialogMark::of(state); + if dialog.any() && dialog != self.dialog { + state.dialog_opened_at_ms = now; + } + if state.quit_confirm && !self.quit_confirm { + state.quit_opened_at_ms = now; + } + // Splash → Panels only: leaving the splash for the placeholder (a + // resize below the floor, which now lands on `Panels` with + // `undersized` set) or never showing it records nothing. + if self.in_splash && matches!(state.phase, UiPhase::Panels) && !state.undersized { + state.panels_revealed_at_ms = Some(now); + } + } +} + +/// Stamp the F9 menu. A freshly opened bar stamps all three clocks; an +/// already-open one keeps `opened_at_ms` (handlers that rebuild the whole +/// `MenuState`, like a title click, would otherwise reset it to `0`), re- +/// stamps the pull-down when a different one opens or a closed one reopens, +/// and stamps the highlight only when it moves within a pull-down that was +/// already unfolding or settled — a new pull-down's first highlight belongs +/// to its unfold, not to a settle flash. +fn stamp_menu(before: Option<&MenuMark>, menu: &mut MenuState, now: u64) { + match before { + None => { + menu.opened_at_ms = now; + menu.pulldown_opened_at_ms = now; + menu.selected_at_ms = None; + } + Some(b) => { + menu.opened_at_ms = b.opened_at_ms; + let new_pulldown = menu.pulldown_open && (!b.pulldown_open || menu.active != b.active); + if new_pulldown { + menu.pulldown_opened_at_ms = now; + menu.selected_at_ms = None; + } else { + menu.pulldown_opened_at_ms = b.pulldown_opened_at_ms; + menu.selected_at_ms = if menu.selected != b.selected { Some(now) } else { b.selected_at_ms }; + } + } + } +} + +/// `(opened_at_ms, selected_at_ms)` for a cursor-over-a-list pop-up. +fn stamp_popup(before: Option<&PopupMark>, cursor: usize, now: u64) -> (u64, Option) { + match before { + None => (now, None), + Some(b) => (b.opened_at_ms, if cursor != b.cursor { Some(now) } else { b.selected_at_ms }), + } +} diff --git a/crates/filecommand-core/src/theme.rs b/crates/filecommand-core/src/theme.rs index c590d60..ee21f18 100644 --- a/crates/filecommand-core/src/theme.rs +++ b/crates/filecommand-core/src/theme.rs @@ -1,5 +1,5 @@ //! Named 16-color theme model. Every renderer resolves colors by role, never -//! by hardcoding an ANSI or RGB value directly — see [`Theme::resolve`]. +//! by hardcoding an ANSI or RGB value directly — see [`ColorSpec::resolve_fg`] and [`ColorSpec::resolve_bg`]. use std::collections::BTreeMap; use std::fmt; @@ -118,6 +118,10 @@ pub enum Role { PanelHeader, PanelFile, PanelDirectory, + /// Hidden/system entries' names, visibly dimmer than `PanelFile` on the + /// same background in every built-in theme (hidden-entry-styling; + /// design D2). Applied to hidden files and hidden directories alike. + PanelHidden, PanelCursor, PanelSelected, PanelMinistatus, @@ -187,6 +191,7 @@ pub const ALL_ROLES: &[Role] = &[ Role::PanelHeader, Role::PanelFile, Role::PanelDirectory, + Role::PanelHidden, Role::PanelCursor, Role::PanelSelected, Role::PanelMinistatus, @@ -239,6 +244,7 @@ impl Role { Role::PanelHeader => "panel.header", Role::PanelFile => "panel.file", Role::PanelDirectory => "panel.directory", + Role::PanelHidden => "panel.hidden", Role::PanelCursor => "panel.cursor", Role::PanelSelected => "panel.selected", Role::PanelMinistatus => "panel.ministatus", @@ -339,10 +345,7 @@ impl Theme { let input = roles.remove(role).ok_or(ThemeError::MissingRole(*role))?; let fg = input.fg.ok_or(ThemeError::MissingAnsi16Fg(*role))?; let bg = input.bg.ok_or(ThemeError::MissingAnsi16Bg(*role))?; - resolved.insert( - *role, - ColorSpec { fg, bg, fg_truecolor: input.fg_truecolor, bg_truecolor: input.bg_truecolor }, - ); + resolved.insert(*role, ColorSpec { fg, bg, fg_truecolor: input.fg_truecolor, bg_truecolor: input.bg_truecolor }); } Ok(Theme { name: name.into(), roles: resolved }) } @@ -393,6 +396,9 @@ impl Theme { set(Role::PanelHeader, A(BrightYellow), A(Blue)); set(Role::PanelFile, A(Cyan), A(Blue)); set(Role::PanelDirectory, A(BrightWhite), A(Blue)); + // Bright-black on blue is deliberately low-contrast — hidden/system + // entries recede against ordinary files (hidden-entry-styling D2). + set(Role::PanelHidden, A(BrightBlack), A(Blue)); set(Role::PanelCursor, A(Black), A(Cyan)); set(Role::PanelSelected, A(BrightYellow), A(Blue)); set(Role::PanelMinistatus, A(Cyan), A(Blue)); @@ -458,6 +464,9 @@ impl Theme { set(Role::PanelHeader, A(White), A(Black)); set(Role::PanelFile, A(White), A(Black)); set(Role::PanelDirectory, A(BrightWhite), A(Black)); + // The one dimmer tier mono has below White on its black base + // (design D2). + set(Role::PanelHidden, A(BrightBlack), A(Black)); set(Role::PanelCursor, A(Black), A(White)); set(Role::PanelSelected, A(BrightWhite), A(Black)); set(Role::PanelMinistatus, A(White), A(Black)); @@ -536,6 +545,7 @@ impl Theme { set(Role::PanelHeader, A(Green), A(Black)); set(Role::PanelFile, A(Green), A(Black)); set(Role::PanelDirectory, A(BrightGreen), A(Black)); + set(Role::PanelHidden, A(BrightBlack), A(Black)); set(Role::PanelCursor, A(Black), A(Green)); set(Role::PanelSelected, A(BrightGreen), A(Black)); set(Role::PanelMinistatus, A(Green), A(Black)); @@ -574,16 +584,17 @@ impl Theme { set(Role::ViewerMatch, A(Black), A(Green)); set(Role::TabActive, A(Black), A(Green)); set(Role::TabInactive, A(Green), A(Black)); - Theme::build("terminal-green", m) - .expect("terminal-green role table is complete") - .with_truecolor_overrides(&[ - (Role::PanelFrame, None, Some(Rgb(0, 40, 0))), - (Role::PanelScrollbar, None, Some(Rgb(0, 40, 0))), - (Role::PanelFile, Some(Rgb(51, 204, 51)), Some(Rgb(0, 40, 0))), - (Role::PanelDirectory, Some(Rgb(102, 255, 102)), Some(Rgb(0, 40, 0))), - (Role::PanelCursor, None, Some(Rgb(51, 204, 51))), - (Role::ScreenBackdrop, None, Some(Rgb(0, 40, 0))), - ]) + Theme::build("terminal-green", m).expect("terminal-green role table is complete").with_truecolor_overrides(&[ + (Role::PanelFrame, None, Some(Rgb(0, 40, 0))), + (Role::PanelScrollbar, None, Some(Rgb(0, 40, 0))), + (Role::PanelFile, Some(Rgb(51, 204, 51)), Some(Rgb(0, 40, 0))), + (Role::PanelDirectory, Some(Rgb(102, 255, 102)), Some(Rgb(0, 40, 0))), + // A darker phosphor green on the file background — the + // truecolor rendering of "dimmed" (design D2). + (Role::PanelHidden, Some(Rgb(30, 122, 30)), Some(Rgb(0, 40, 0))), + (Role::PanelCursor, None, Some(Rgb(51, 204, 51))), + (Role::ScreenBackdrop, None, Some(Rgb(0, 40, 0))), + ]) } /// `nc-classic`'s structure with magenta standing in for blue and @@ -606,6 +617,9 @@ impl Theme { set(Role::PanelHeader, A(BrightYellow), A(Magenta)); set(Role::PanelFile, A(BrightBlack), A(Magenta)); set(Role::PanelDirectory, A(BrightWhite), A(Magenta)); + // Blue on magenta is this palette's subdued tier — dimmer than the + // grey file text, within the theme's own family (design D2). + set(Role::PanelHidden, A(Blue), A(Magenta)); set(Role::PanelCursor, A(Black), A(BrightMagenta)); set(Role::PanelSelected, A(BrightYellow), A(Magenta)); set(Role::PanelMinistatus, A(BrightMagenta), A(Magenta)); @@ -642,15 +656,14 @@ impl Theme { set(Role::ViewerMatch, A(Black), A(BrightMagenta)); set(Role::TabActive, A(Black), A(BrightMagenta)); set(Role::TabInactive, A(BrightMagenta), A(Magenta)); - Theme::build("purple-lights", m) - .expect("purple-lights role table is complete") - .with_truecolor_overrides(&[ - (Role::ScreenBackdrop, None, Some(Rgb(48, 0, 64))), - (Role::PanelFrame, Some(Rgb(186, 85, 211)), Some(Rgb(48, 0, 64))), - (Role::PanelScrollbar, Some(Rgb(186, 85, 211)), Some(Rgb(48, 0, 64))), - (Role::PanelFile, Some(Rgb(169, 169, 169)), Some(Rgb(48, 0, 64))), - (Role::PanelCursor, None, Some(Rgb(186, 85, 211))), - ]) + Theme::build("purple-lights", m).expect("purple-lights role table is complete").with_truecolor_overrides(&[ + (Role::ScreenBackdrop, None, Some(Rgb(48, 0, 64))), + (Role::PanelFrame, Some(Rgb(186, 85, 211)), Some(Rgb(48, 0, 64))), + (Role::PanelScrollbar, Some(Rgb(186, 85, 211)), Some(Rgb(48, 0, 64))), + (Role::PanelFile, Some(Rgb(169, 169, 169)), Some(Rgb(48, 0, 64))), + (Role::PanelHidden, Some(Rgb(110, 90, 128)), Some(Rgb(48, 0, 64))), + (Role::PanelCursor, None, Some(Rgb(186, 85, 211))), + ]) } /// Amber terminal: yellow on black base, bright-yellow directories, @@ -674,6 +687,7 @@ impl Theme { set(Role::PanelHeader, A(Yellow), A(Black)); set(Role::PanelFile, A(Yellow), A(Black)); set(Role::PanelDirectory, A(BrightYellow), A(Black)); + set(Role::PanelHidden, A(BrightBlack), A(Black)); set(Role::PanelCursor, A(Black), A(Yellow)); // Selected entries are bright-white, distinct from both the base // yellow (normal files) and bright-yellow (directories). @@ -716,15 +730,16 @@ impl Theme { set(Role::ViewerMatch, A(Black), A(Yellow)); set(Role::TabActive, A(Black), A(Yellow)); set(Role::TabInactive, A(Yellow), A(Black)); - Theme::build("yellow-storm", m) - .expect("yellow-storm role table is complete") - .with_truecolor_overrides(&[ - (Role::PanelFrame, Some(Rgb(255, 191, 0)), None), - (Role::PanelScrollbar, Some(Rgb(255, 191, 0)), None), - (Role::PanelFile, Some(Rgb(255, 191, 0)), None), - (Role::PanelDirectory, Some(Rgb(255, 214, 51)), None), - (Role::PanelCursor, None, Some(Rgb(255, 191, 0))), - ]) + Theme::build("yellow-storm", m).expect("yellow-storm role table is complete").with_truecolor_overrides(&[ + (Role::PanelFrame, Some(Rgb(255, 191, 0)), None), + (Role::PanelScrollbar, Some(Rgb(255, 191, 0)), None), + (Role::PanelFile, Some(Rgb(255, 191, 0)), None), + (Role::PanelDirectory, Some(Rgb(255, 214, 51)), None), + // A darker amber on the unchanged black background (design + // D2). + (Role::PanelHidden, Some(Rgb(128, 96, 0)), None), + (Role::PanelCursor, None, Some(Rgb(255, 191, 0))), + ]) } /// High-contrast black-on-bright-white accessibility theme, the light @@ -754,6 +769,9 @@ impl Theme { // them from body text by inversion instead: bright-white on black, // the same treatment as the cursor bar. set(Role::PanelDirectory, A(BrightWhite), A(Black)); + // A lighter black on the bright-white base — the only "dimmed" tier + // available in a no-hue light theme (design D2). + set(Role::PanelHidden, A(BrightBlack), A(BrightWhite)); set(Role::PanelCursor, A(BrightWhite), A(Black)); set(Role::PanelSelected, A(BrightWhite), A(Black)); set(Role::PanelMinistatus, A(Black), A(BrightWhite)); @@ -810,8 +828,7 @@ impl Theme { } /// All built-in theme names, in menu/picker display order. -pub const BUILTIN_THEME_NAMES: &[&str] = - &["nc-classic", "nc-mono", "terminal-green", "purple-lights", "yellow-storm", "inverted"]; +pub const BUILTIN_THEME_NAMES: &[&str] = &["nc-classic", "nc-mono", "terminal-green", "purple-lights", "yellow-storm", "inverted"]; pub const DEFAULT_THEME_NAME: &str = "nc-classic"; @@ -835,10 +852,7 @@ mod tests { for role in ALL_ROLES { m.insert(*role, ColorSpecInput::new(ColorValue::Ansi16(Ansi16::White), ColorValue::Ansi16(Ansi16::Black))); } - m.insert( - Role::PanelFrame, - ColorSpecInput { fg: None, bg: Some(ColorValue::Ansi16(Ansi16::Black)), fg_truecolor: None, bg_truecolor: None }, - ); + m.insert(Role::PanelFrame, ColorSpecInput { fg: None, bg: Some(ColorValue::Ansi16(Ansi16::Black)), fg_truecolor: None, bg_truecolor: None }); let err = Theme::build("bad", m).unwrap_err(); assert_eq!(err, ThemeError::MissingAnsi16Fg(Role::PanelFrame)); } @@ -852,6 +866,74 @@ mod tests { assert!(Theme::build("ok", m).is_ok()); } + // ----------------------------------------------------------------- + // hidden-entry-styling: the `panel.hidden` role (tasks.md 2.2) + // ----------------------------------------------------------------- + + /// theme-system "`panel.hidden` role" ("Built-in themes define the + /// role"): every built-in theme resolves it to a defined pair that + /// differs from `panel.file` on the same background. + #[test] + fn every_builtin_theme_defines_panel_hidden_distinct_from_panel_file() { + for theme in all_builtin_themes() { + let hidden = theme.get(Role::PanelHidden); + let file = theme.get(Role::PanelFile); + assert_ne!(hidden, file, "{}: panel.hidden must be visibly distinct from panel.file", theme.name); + assert_eq!(hidden.bg, file.bg, "{}: panel.hidden sits on the same background as panel.file", theme.name); + assert_eq!(hidden.bg_truecolor, file.bg_truecolor, "{}: panel.hidden keeps panel.file's truecolor background", theme.name); + } + } + + /// The design D2 per-theme values: the mandatory ANSI-16 pair for every + /// built-in theme (covering scenario "nc-classic hidden color"), plus + /// the three truecolor overrides. + #[test] + fn panel_hidden_matches_design_d2_in_every_builtin_theme() { + use Ansi16::*; + let cases: &[(&str, Ansi16, Ansi16)] = &[ + ("nc-classic", BrightBlack, Blue), + ("nc-mono", BrightBlack, Black), + ("terminal-green", BrightBlack, Black), + ("purple-lights", Blue, Magenta), + ("yellow-storm", BrightBlack, Black), + ("inverted", BrightBlack, BrightWhite), + ]; + for (name, fg, bg) in cases { + let theme = Theme::by_name(name).expect("builtin theme name resolves"); + let spec = theme.get(Role::PanelHidden); + assert_eq!(spec.fg, ColorValue::Ansi16(*fg), "{name} panel.hidden fg"); + assert_eq!(spec.bg, ColorValue::Ansi16(*bg), "{name} panel.hidden bg"); + } + // Truecolor overrides (bg is always the theme's file background). + let hidden = Theme::terminal_green().get(Role::PanelHidden); + assert_eq!(hidden.fg_truecolor, Some(Rgb(30, 122, 30))); + assert_eq!(hidden.bg_truecolor, Some(Rgb(0, 40, 0))); + let hidden = Theme::purple_lights().get(Role::PanelHidden); + assert_eq!(hidden.fg_truecolor, Some(Rgb(110, 90, 128))); + assert_eq!(hidden.bg_truecolor, Some(Rgb(48, 0, 64))); + let hidden = Theme::yellow_storm().get(Role::PanelHidden); + assert_eq!(hidden.fg_truecolor, Some(Rgb(128, 96, 0))); + assert_eq!(hidden.bg_truecolor, None); + } + + /// theme-system "`panel.hidden` role" ("Missing role is rejected"): a + /// table omitting it fails validation naming `panel.hidden`, like any + /// other missing role. + #[test] + fn build_rejects_a_table_missing_panel_hidden_naming_the_role() { + let mut m: BTreeMap = Theme::classic() + .roles + .iter() + .map(|(role, spec)| { + (*role, ColorSpecInput { fg: Some(spec.fg), bg: Some(spec.bg), fg_truecolor: spec.fg_truecolor, bg_truecolor: spec.bg_truecolor }) + }) + .collect(); + m.remove(&Role::PanelHidden); + let err = Theme::build("no-hidden", m).unwrap_err(); + assert_eq!(err, ThemeError::MissingRole(Role::PanelHidden)); + assert!(err.to_string().contains("panel.hidden"), "the error names the role: {err}"); + } + #[test] fn classic_theme_is_role_complete() { let theme = Theme::classic(); @@ -913,8 +995,7 @@ mod tests { #[test] fn resolve_uses_truecolor_only_when_reported_and_present() { - let with_true = ColorSpec::new(ColorValue::Ansi16(Ansi16::Cyan), ColorValue::Ansi16(Ansi16::Blue)) - .with_truecolor(Some(Rgb(1, 2, 3)), None); + let with_true = ColorSpec::new(ColorValue::Ansi16(Ansi16::Cyan), ColorValue::Ansi16(Ansi16::Blue)).with_truecolor(Some(Rgb(1, 2, 3)), None); assert_eq!(with_true.resolve_fg(ColorDepth::TrueColor), ResolvedColor::Rgb(Rgb(1, 2, 3))); // bg has no truecolor override even though depth is truecolor -> falls back to ANSI-16. assert_eq!(with_true.resolve_bg(ColorDepth::TrueColor), ResolvedColor::Ansi16(Ansi16::Blue)); diff --git a/crates/filecommand-core/src/update.rs b/crates/filecommand-core/src/update.rs index 68f3e25..f4bb17e 100644 --- a/crates/filecommand-core/src/update.rs +++ b/crates/filecommand-core/src/update.rs @@ -5,20 +5,22 @@ //! events alike. It performs no I/O, spawns no threads, and reads no clock; //! callers supply the current time via [`Command::Tick`]. +use std::collections::HashSet; use std::ffi::{OsStr, OsString}; use std::path::{Component, Path, PathBuf}; use crate::config::{self, UserMenuEntry}; -use crate::dialogs::{FileActionMenuEntry, FileActionMenuState, HelpState, ThemePickerState, UserMenuState}; +use crate::dialogs::{FileActionMenuEntry, FileActionMenuState, HelpState, HistoryDialogState, ThemePickerState, UserMenuState}; use crate::drives::{self, DriveSelect}; -use crate::editor::{EditorMove, EditorState, ReplacePrompt}; +use crate::editor::{EditorMove, EditorState, NotEditableReason, ReplacePrompt}; use crate::external_editor::{self, EditorInvocation}; use crate::find_file::FindFileState; use crate::fs_ops::dialog::{DropButtons, FileOpSetup, RunningDialog}; use crate::fs_ops::{ConflictChoice, ConflictInfo, ErrorChoice, ErrorInfo, Job, JobKind, JobOutcome, ProgressInfo, SkippedItem, SourceItem}; -use crate::git_info::GitInfo; +use crate::git_info::{self, GitInfo}; use crate::info::InfoValues; -use crate::listing::{Entry, EntryKind, FindMatch, SortMode}; +use crate::line_edit::{LineEdit, TextEditOp}; +use crate::listing::{format_count, Entry, EntryKind, FindMatch, SortMode}; use crate::menu::{MenuAction, MenuEntry, MenuId, MenuState}; use crate::panel::{ClipboardFeedback, CursorMove, DisplayMode, PanelState, TreeState}; use crate::panel_split; @@ -27,9 +29,32 @@ use crate::shell::{self, ShellConfig}; use crate::theme::Theme; use crate::viewer::ViewerState; +mod command; +mod editor; +mod file_op; +mod listing; +mod overlays; +mod panels; +mod viewer; + +pub use command::*; +pub use listing::panel_viewport_rows; +pub use overlays::{button_command, menu_action_command, Overlay}; +pub use panels::{parse_cd, parse_del, parse_rmdir, resolve_cd_target}; + +use editor::*; +use file_op::*; +use listing::*; +use overlays::*; +use panels::*; +use viewer::*; + pub const MIN_COLS: u16 = 60; + pub const MIN_ROWS: u16 = 16; + pub const SPLASH_MIN_HOLD_MS: u64 = 800; + /// How long a clipboard action's mini-status feedback stays up before a /// `Tick` expires it, absent an intervening key press (clipboard-export /// "Clipboard feedback"). @@ -50,115 +75,32 @@ impl PanelSide { } } -/// A mouse click's modifier state, as `input::map_mouse` resolves it from -/// the raw `crossterm::event::KeyModifiers` on a `MouseEvent` — never -/// `crossterm::event::KeyModifiers` itself, which must never cross into this -/// crate (mouse-input "Hit-testing stays in the TUI"; design D2). `Shift` is -/// carried for completeness even though Shift+click range selection is -/// deliberately unspecified (design D3) — most terminal emulators intercept -/// it for native text selection before it ever reaches crossterm. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClickMods { - Plain, - Ctrl, - Shift, -} - -/// Where a drag-and-drop is currently hovering, already validated against -/// mouse-drag's "Valid drop targets" (design D4) — never a raw, unvalidated -/// coordinate hit. `PanelDir`'s `side` stands for that whole panel as a -/// target — its title, blank body area, or any non-directory row all -/// resolve to this same variant, since they all mean "drop into this -/// panel's current directory" (the TUI's hit-testing doesn't need to -/// distinguish them). `SubDir`'s `name` covers the `..` row exactly like an -/// ordinary subdirectory row. `TreeNode` is a Tree-mode panel's node. -/// `Tab`'s `index` is a position in that panel's full ordered tab list -/// (`PanelState::tab_dirs`), standing for that tab's directory. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DropTarget { - PanelDir(PanelSide), - SubDir { side: PanelSide, name: OsString }, - TreeNode { side: PanelSide, path: PathBuf }, - Tab { side: PanelSide, index: usize }, -} - -/// An in-progress mouse drag's frozen identity plus its live target and -/// proposed verb (mouse-drag "Drag lifecycle"; design D4). `source`, -/// `source_dir`, and `items` are captured once at `DragBegin` and never -/// change for the life of the drag — a streamed listing chunk, a re-sort, or -/// even the source panel navigating away changes nothing here (mouse-drag -/// "Robust against listing changes"); `op` and `target` are instead updated -/// by every later `DragOver`/`DragDrop`, since the proposed verb is -/// recomputed from the modifiers of each of those events (design D2) and the -/// hovered target can change on every pointer move. Lives beside `UiPhase`, -/// like `State::menu`/`drive_select`/etc., rather than inside it — a drag -/// overlays the live panels, it is not itself a phase. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DragState { - pub source: PanelSide, - pub source_dir: PathBuf, - pub items: Vec, - pub op: JobKind, - pub target: Option, -} - -/// Which dialog button a click landed on, resolved by the TUI's hit map -/// (design D2: "Dialog buttons" includes the hotkey text spans of dialogs -/// with no framed buttons, e.g. the conflict dialog's `(O)verwrite (S)kip -/// …` row). `Command::DialogButtonClick` maps one of these straight onto the -/// exact command the equivalent keypress already issues, via -/// [`button_command`] — a dialog button is never a new way to do something, -/// only a new way to reach an existing one. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ButtonId { - DeleteConfirmYes, - DeleteConfirmNo, - ConflictOverwrite, - ConflictOverwriteAll, - ConflictSkip, - ConflictSkipAll, - ConflictRename, - ErrorRetry, - ErrorSkip, - ErrorSkipAll, - ErrorAbort, - ProgressCancel, - /// The skipped-items summary's "Press any key to continue" footer — - /// there is no literal button glyph, but design D5 gates this dialog as - /// buttons-only, so the whole footer row is recorded as one clickable - /// span (mirroring how `Command::FileOpConfirm` is what any key already - /// does here). - SummaryContinue, - QuitYes, - QuitNo, - /// The drop-initiated destination dialog's `[ Copy ]` button - /// (operation-dialogs "Drop-initiated destination dialog"). - DropDialogCopy, - /// Its `[ Move ]` button. - DropDialogMove, - /// Its `[ Cancel ]` button — routes to exactly the same command as the - /// keyboard dialog's Esc. - DropDialogCancel, -} - /// Top-level UI phase. Governs how commands are interpreted by `update`. /// -/// The F9 menu and the drive-select dialog deliberately live *beside* the -/// phase (as `State::menu` / `State::drive_select`) rather than inside it: -/// they overlay the panels without replacing whatever phase is underneath. +/// The modal overlays (the F9 menu, the drive-select dialog, and the rest of +/// [`Overlay`]) deliberately live *beside* the phase, in [`State::overlay`], +/// rather than inside it: they overlay the panels without replacing whatever +/// phase is underneath. #[derive(Debug, Clone, PartialEq, Eq)] pub enum UiPhase { - Splash { started_at_ms: u64 }, + Splash { + started_at_ms: u64, + }, Panels, - /// Terminal is below the 60x16 hard floor. Growing back always resolves - /// to `Panels`, never back to `Splash`. - Placeholder, /// Gathering input before a Copy/Move/Mkdir/Delete job is dispatched. FileOpSetup(FileOpSetup), /// A job is running on the worker thread; `dialog` shows progress, or a /// conflict/error the worker is blocked waiting on. - FileOpRunning { source_dir: PathBuf, dest_dir: PathBuf, dialog: RunningDialog }, - /// End-of-job summary, shown only when the job skipped 1+ items. + FileOpRunning { + source_dir: PathBuf, + dest_dir: PathBuf, + dialog: RunningDialog, + }, + /// End-of-job summary, shown when the job skipped 1+ items or recorded + /// 1+ metadata-restore warnings — the two kinds share this vector and + /// are told apart by `SkippedItem::kind` (operation-dialogs "End-of-job + /// skipped-files summary", "Metadata-restore failures are reported in + /// the end-of-job summary"; error-diagnostics design D5). FileOpSummary(Vec), /// The F3 viewer, open full-screen in place of the panels (viewer: /// Frame-less full-screen chrome — "Viewer owns focus while open"). @@ -174,9 +116,11 @@ pub struct State { pub left: PanelState, pub right: PanelState, pub active: PanelSide, - /// The live command-line buffer. Printable keys land here whenever a - /// panel is focused and no dialog or quick-search owns them. - pub command_line: String, + /// The live command-line buffer — a shared text field per + /// text-field-editing (the caret model and editing keys). Printable keys + /// land here whenever a panel is focused and no dialog or quick-search + /// owns them. + pub command_line: LineEdit, /// Executed commands, oldest first. pub history: Vec, /// Where Up/Down recall currently sits in `history`; `None` means "not @@ -186,10 +130,14 @@ pub struct State { /// not the command line — consumes plain printable keys. Exactly one /// typing sink ever sees a given key. pub quick_search: Option, - /// The F9 menu overlay, or `None` when the bar is closed. - pub menu: Option, - /// The Alt+F1/F2 drive-select dialog, or `None` when closed. - pub drive_select: Option, + /// The one modal overlay open above the phase, or `None` (reducer- + /// overlay-structure design D1). Set through [`State::open_overlay`] + /// (which debug-asserts the slot is empty) and + /// [`State::set_startup_warning`], read through [`State::overlay`] and + /// the typed accessors (`menu()`, `fuzzy_jump()`, …). It lives beside the + /// phase: an overlay opens over the panels (or, for the startup warning, + /// over whatever phase is showing) without replacing them. + pub overlay: Option, /// Shell program + PATHEXT, snapshotted from config/environment at /// startup so `update` stays a pure function of `State`. pub shell: ShellConfig, @@ -200,6 +148,16 @@ pub struct State { pub phase: UiPhase, pub theme: Theme, pub term_size: (u16, u16), + /// `true` while `term_size` is below the 60x16 hard floor. A flag + /// *beside* the phase rather than a phase of its own, so shrinking the + /// terminal never discards the active screen: the phase, every overlay + /// and every dialog stay exactly as they were, the renderer draws the + /// "terminal too small" placeholder over them, and the TUI's input + /// mappers let nothing but F10/Esc (the quit dialog) and the quit/ + /// startup-warning dialogs' own keys through until the terminal is + /// enlarged again (application-shell "Resize handling with 60x16 hard + /// floor and placeholder"; resize-preserves-session-state design D1). + pub undersized: bool, /// The vertical panel split, stored as a left-panel percentage /// (default 50); `filecommand-tui::layout::compute` derives the /// effective column split from this via @@ -210,46 +168,32 @@ pub struct State { /// `DriveSelect::generation`) that let a worker reply be matched against /// the request that's still current, so an out-of-order completion from /// a superseded request is dropped instead of silently overwriting a - /// fresher answer. See [`State::next_request_id`]. + /// fresher answer. See `State::next_request_id`. pub request_seq: u64, /// Directory frecency history backing the Ctrl+J fuzzy-jump dialog, /// persisted alongside command history in `history.json` (fuzzy-jump /// "Directory history persistence"; design D6). pub dir_history: Vec, - /// The most recent clock reading `Command::Tick` delivered. Used to - /// timestamp frecency updates so `update` never reads a clock directly - /// — the TUI's injected `Clock` is still the only source of "now". + /// The most recent monotonic clock reading `Command::Tick` delivered. + /// Drives every animation and in-session timing; `update` never reads a + /// clock directly — the TUI's injected `Clock` is still the only source + /// of "now". pub clock_ms: u64, + /// The most recent wall-clock reading (Unix-epoch milliseconds) + /// `Command::Tick` delivered. Used only to stamp and rank frecency + /// visits, so recency stays meaningful across restarts (fuzzy-jump + /// "Frecency ranking"; history-persistence-hardening design D1). + pub epoch_ms: u64, /// The F2 user menu's entries, snapshotted from `usermenu.toml` at /// startup (user-menu "Parse label and command entries from /// usermenu.toml"). pub user_menu_entries: Vec, - /// The Ctrl+J fuzzy-jump dialog, or `None` when closed. - pub fuzzy_jump: Option, - /// The Alt+F7 find-file dialog, or `None` when closed. - pub find_file: Option, - /// The F2 user-menu overlay, or `None` when closed. - pub user_menu: Option, - /// The Options → Themes picker overlay, or `None` when closed - /// (theme-selection "Options menu opens the theme picker"). - pub theme_picker: Option, - /// The Enter-on-file action menu, or `None` when closed. Opened by - /// `handle_enter` and dismissed by activating an entry or Esc - /// (file-action-menu "Enter on a file opens the action menu"). - pub file_action_menu: Option, - /// The F1 Help window (and, nested within it, the About dialog), or - /// `None` when closed. - pub help: Option, - /// A dismissable startup-warning modal, or `None` when there is nothing - /// to warn about. Currently raised only for a malformed `usermenu.toml` - /// (user-menu falls back to default entries without overwriting the - /// file, per §6) — modeled the same "one-shot `Option` overlay, - /// dismissed by a dedicated `Command`" way as `drive_select`/ - /// `fuzzy_jump`/`find_file`/`user_menu`/`help`, rather than a panel - /// mini-status, since it must be visible regardless of which panel (if - /// either) it concerns (user-menu "Malformed file warns and falls back - /// without overwriting"). - pub startup_warning: Option, + /// The `usermenu.toml` path the application resolved at startup (the + /// profile directory's file — config-profile-directory), opened by + /// Commands → Menu file edit / `Command::EditUserMenuFile` and reloaded + /// via `Command::UserMenuReloaded` when the editor returns (user-menu + /// "Edit the user menu file from the Commands menu"; design D4). + pub user_menu_path: PathBuf, /// The quit-confirmation dialog, or `false` when closed. Modeled as a /// bare `bool` — like `EditorState::quit_confirm` for the built-in /// editor's own F10 quit prompt — rather than an `Option`, since the @@ -261,16 +205,66 @@ pub struct State { /// "Quit request keys and confirmation"; design D5). pub quit_confirm: bool, /// A mouse drag-and-drop in progress, or `None` otherwise - /// (mouse-panel-drag). Lives beside the phase like `menu`/`drive_select` - /// — it overlays the live panels rather than replacing them. Cleared by - /// a reducer post-condition (see `update`'s wrapper below) whenever a - /// command leaves `UiPhase::Panels` or opens any of the overlays above, + /// (mouse-panel-drag). Lives beside the phase like `overlay` — it + /// overlays the live panels rather than replacing them. Cleared by a + /// reducer post-condition (see `update`'s wrapper below) whenever a + /// command leaves `UiPhase::Panels` or opens an overlay, /// so it can never survive into a context where a drop could complete /// (mouse-drag "Cancel and phase-change clear the drag"; design D5). pub drag: Option, + /// Whether the decorative UI animations (splash entrance, menu sweeps + /// and unfolds, dialog iris, panel wipe) run at all. Copied from + /// `config.toml`'s `animations` key / `--noanimations` by the TUI at + /// startup; `false` in [`State::empty`] so states built without + /// configuration — every existing reducer and snapshot test — render + /// their final frame regardless of `clock_ms` (ui-animations + /// "Animations are disabled by config or launch flag"; design D5). + pub animations: bool, + /// `clock_ms` when the currently visible modal dialog opened — or was + /// replaced by a different one in the same slot (progress → conflict). + /// Only one of these dialogs is ever visible at a time, so one stamp + /// suffices; set by the reducer's animation post-condition in [`update`] + /// and read only by the renderer's iris (design D4). + pub dialog_opened_at_ms: u64, + /// `clock_ms` when `quit_confirm` last became `true`. Separate from + /// `dialog_opened_at_ms` because the quit dialog layers over whichever + /// dialog is open without making that one re-animate. + pub quit_opened_at_ms: u64, + /// `clock_ms` when the splash gave way to the panels — `Some` only on + /// that transition, so a skipped splash (`--nosplash`, below-floor + /// placeholder) leaves frame 1 as the complete panel screen with no + /// wipe. + pub panels_revealed_at_ms: Option, + /// Working-directory roots of repositories whose git status query timed + /// out this session, spelled the way the queried panel path spelled them + /// (git-info-timeout-memory design D2). While a root is remembered here, + /// no git query is issued for any directory under it and the panel shows + /// no git indicators there (git-info "Silent absence on timeout and + /// stale-result discarding"). Session-only, never persisted. + pub git_no_info_roots: Vec, + /// The command-line `cd`/`del`/`rmdir` target check in flight on a + /// worker thread, or `None`. The reducer itself never touches the + /// filesystem: a target that is not already listed in the panel is + /// checked through `Effect::ProbePath`, and the command completes when + /// the matching `Command::PathProbed` arrives (application-shell "The + /// reducer performs no filesystem I/O"; offload-blocking-io design D1). + pub command_probe: Option, + /// The F3/F4 open in flight on a worker thread, or `None`. A reply is + /// applied only when its id matches, the panels still own input, and no + /// menu, dialog or overlay has opened since (application-shell "Slow + /// storage never blocks the event loop"; design D4). + pub pending_open: Option, } impl State { + /// The clock reading animated surfaces should interpolate against: + /// `Some(clock_ms)` when animations are on, `None` when every surface + /// must render its final frame. The one place views learn whether to + /// animate, so "disabled" is byte-identical to the pre-animation render. + pub fn anim_now(&self) -> Option { + self.animations.then_some(self.clock_ms) + } + pub fn panel(&self, side: PanelSide) -> &PanelState { match side { PanelSide::Left => &self.left, @@ -305,7 +299,7 @@ impl State { /// touches persistence (theme-selection: "Live theme preview while the /// picker is open"). pub fn render_theme(&self) -> Theme { - if let Some(picker) = &self.theme_picker { + if let Some(picker) = self.theme_picker() { if let Some(name) = crate::theme::BUILTIN_THEME_NAMES.get(picker.highlight) { if let Some(theme) = Theme::by_name(name) { return theme; @@ -322,31 +316,33 @@ impl State { left: PanelState::new(PathBuf::from("/")), right: PanelState::new(PathBuf::from("/")), active: PanelSide::Left, - command_line: String::new(), + command_line: LineEdit::default(), history: Vec::new(), history_cursor: None, quick_search: None, - menu: None, - drive_select: None, + overlay: None, shell: ShellConfig::default(), editor: None, phase: UiPhase::Panels, theme, term_size: (MIN_COLS, MIN_ROWS), + undersized: false, split_percent: panel_split::DEFAULT_SPLIT_PERCENT, request_seq: 0, dir_history: Vec::new(), clock_ms: 0, + epoch_ms: 0, user_menu_entries: Vec::new(), - fuzzy_jump: None, - find_file: None, - user_menu: None, - theme_picker: None, - file_action_menu: None, - help: None, - startup_warning: None, + user_menu_path: PathBuf::new(), quit_confirm: false, drag: None, + animations: false, + dialog_opened_at_ms: 0, + quit_opened_at_ms: 0, + panels_revealed_at_ms: None, + git_no_info_roots: Vec::new(), + command_probe: None, + pending_open: None, } } @@ -354,6 +350,52 @@ impl State { size.0 < MIN_COLS || size.1 < MIN_ROWS } + /// The open modal overlay, if any (design D1). + pub fn overlay(&self) -> Option<&Overlay> { + self.overlay.as_ref() + } + + /// Open `overlay` in the slot. Every synchronous opener runs with the + /// slot empty — overlays open only from the `Panels` arm, which a command + /// reaches only when no open overlay claimed it, and an overlay that + /// leads to another closes itself first (`OverlayStep::Replace`/ + /// `CloseThen`) — so a non-empty slot here is a routing bug, caught in + /// debug builds (design D5). A release build keeps the newer overlay. + pub fn open_overlay(&mut self, overlay: Overlay) { + debug_assert!(self.overlay.is_none(), "open_overlay over an open {:?}", self.overlay.as_ref().map(std::mem::discriminant)); + self.overlay = Some(overlay); + } + + /// Close whatever overlay is open, returning it. + pub fn close_overlay(&mut self) -> Option { + self.overlay.take() + } + + /// Raise (or replace) the startup warning: the one overlay whose openers + /// are last-writer-wins — `app::run`'s startup assignments, the user-menu + /// reload after an edit — so it installs over an empty slot and replaces + /// the message of an existing warning, rather than asserting an empty + /// slot like [`State::open_overlay`] (design D5). It debug-asserts that + /// the slot holds nothing else: the warning is raised at session start + /// and after the editor over `usermenu.toml` returns, both with no other + /// overlay open. + pub fn set_startup_warning(&mut self, message: String) { + debug_assert!( + matches!(self.overlay, None | Some(Overlay::StartupWarning(_))), + "startup warning over an open {:?}", + self.overlay.as_ref().map(std::mem::discriminant) + ); + self.overlay = Some(Overlay::StartupWarning(message)); + } + + /// The startup warning's message while that overlay is open. + pub fn startup_warning(&self) -> Option<&str> { + match &self.overlay { + Some(Overlay::StartupWarning(message)) => Some(message), + _ => None, + } + } + /// Mint a new, never-repeated request id. Used to tag an outstanding /// async request (an Info query, a drive-label fetch) so that when its /// answer comes back, `update` can tell a still-current request from @@ -366,662 +408,192 @@ impl State { /// Build the initial state and the effects needed to kick off both /// panels' first directory listings. `show_splash` should already /// account for both the config's `splash` flag and `--nosplash` - /// (flag wins). + /// (flag wins). `epoch_ms` is the wall-clock reading at startup, so + /// visits recorded before the first `Tick` are stamped correctly + /// (history-persistence-hardening design D1). pub fn initial( theme: Theme, term_size: (u16, u16), now_ms: u64, + epoch_ms: u64, left_cwd: PathBuf, right_cwd: PathBuf, show_splash: bool, ) -> (State, Vec) { - let phase = if Self::too_small(term_size) { - UiPhase::Placeholder - } else if show_splash { - UiPhase::Splash { started_at_ms: now_ms } - } else { - UiPhase::Panels - }; + // Below the floor the splash is skipped outright: the session starts + // on the panels with the placeholder drawn over them, and enlarging + // simply reveals the panels (startup-splash "Below the floor at + // startup"; design D2). + let undersized = Self::too_small(term_size); + let phase = if show_splash && !undersized { UiPhase::Splash { started_at_ms: now_ms } } else { UiPhase::Panels }; let mut state = State { left: PanelState::new(left_cwd.clone()), right: PanelState::new(right_cwd.clone()), phase, term_size, + undersized, clock_ms: now_ms, + epoch_ms, ..State::empty(theme) }; + // Each panel's first listing is tagged like every later one + // (listing-request-guard design D1), so a reply from it can be told + // apart from the listing a navigation issued before it finished. + let left_request = state.next_request_id(); + state.left.listing_request = Some(left_request); + let right_request = state.next_request_id(); + state.right.listing_request = Some(right_request); let mut effects = vec![ - Effect::StartListing { panel: PanelSide::Left, path: left_cwd.clone() }, - Effect::StartListing { panel: PanelSide::Right, path: right_cwd.clone() }, + Effect::StartListing { panel: PanelSide::Left, path: left_cwd.clone(), request: left_request }, + Effect::StartListing { panel: PanelSide::Right, path: right_cwd.clone(), request: right_request }, ]; // Both panels start their git-info query alongside their first // listing, exactly as a later navigation does (git-info "Background // repository detection"). - effects.push(git_info_query_effect(&mut state, PanelSide::Left, left_cwd)); - effects.push(git_info_query_effect(&mut state, PanelSide::Right, right_cwd)); + effects.extend(git_info_query_effect(&mut state, PanelSide::Left, left_cwd)); + effects.extend(git_info_query_effect(&mut state, PanelSide::Right, right_cwd)); (state, effects) } } -/// Key-derived and worker-produced inputs to `update`. Both flow through the -/// same path — worker events are not special-cased at the top level. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Command { - MoveCursor(CursorMove), - ToggleActivePanel, - Enter, - ParentDir, - RequestQuit, - ConfirmQuit, - CancelQuit, - Resize(u16, u16), - /// Current time in ms, supplied by the TUI's injected `Clock`. - Tick(u64), - - // Adjustable panel split (Ctrl+Left/Ctrl+Right/Ctrl+=; panel-split - // "Adjust and reset the panel split"). - /// Ctrl+Right: move the divider `panel_split::SPLIT_STEP` columns - /// right (grows the left panel). A no-op at the right panel's minimum. - SplitGrow, - /// Ctrl+Left: move the divider `panel_split::SPLIT_STEP` columns left - /// (shrinks the left panel). A no-op at the left panel's minimum. - SplitShrink, - /// Ctrl+=: reset the split to 50/50, unconditionally. - SplitReset, - /// Re-read a panel's directory (Ctrl+R). Also the recovery action - /// offered after a listing failure, but available at any time. - RereadPanel(PanelSide), - - // Selection (Ins/+/-/*). - ToggleSelectAtCursor, - GroupSelectAll, - GroupDeselectAll, - InvertSelection, - - // F5/F6/F7/F8: enter the corresponding file-op setup dialog. No-ops if - // there is nothing selected (Mkdir is always available). - RequestCopy, - RequestMove, - RequestMkdir, - RequestDelete, - - // FileOpSetup dialog interaction. - FileOpInputChar(char), - FileOpInputBackspace, - FileOpConfirm, - FileOpCancel, - /// The drop-initiated destination dialog's `[ Copy ]`/`[ Move ]` buttons: - /// confirms exactly as `Command::FileOpConfirm` does, but commits the - /// given verb regardless of whichever `kind` the dialog opened with — - /// the only way to switch the verb without reopening the dialog - /// (operation-dialogs "Switching the verb in the dialog"). A no-op - /// outside `FileOpSetup::DestinationInput` (`DeleteConfirm`/ - /// `RenameInput` never carry a button row that could produce this). - FileOpConfirmAs(JobKind), - - // FileOpRunning dialog interaction. - FileOpConflictChoice(ConflictChoice), - FileOpBeginRename, - FileOpErrorChoice(ErrorChoice), - FileOpCancelJob, - - // Command line. - CommandLineChar(char), - CommandLineBackspace, - /// Esc: clears the buffer, which is the explicit mechanism that hands - /// Up/Down back to the panel cursor. - CommandLineClear, - CommandLineHistoryPrev, - CommandLineHistoryNext, - /// Ctrl+Enter — paste the cursor entry's file name. - PasteCursorName, - /// Ctrl+] — paste the cursor entry's full path. - PasteCursorPath, - /// Ctrl+O — drop to the host terminal's scrollback. - ShowScrollback, - - // Type-ahead quick search (§4.7), which competes with the command line - // for printable keys. - QuickSearchStart(char), - QuickSearchChar(char), - QuickSearchBackspace, - QuickSearchEnd, - - // Sort modes (Ctrl+F3..Ctrl+F7). - SetSortMode { side: PanelSide, mode: SortMode }, - - // Ctrl+P inline quick filter (§4.7), scoped to the active panel. - QuickFilterStart, - QuickFilterChar(char), - QuickFilterBackspace, - QuickFilterEnd, - - // Panel tabs (§4.1/§4.7), scoped to the active panel. - /// Ctrl+T. - OpenTab, - /// Ctrl+W. - CloseTab, - /// Alt+1..9 (one-based). - SwitchTab(usize), - - // Tree display mode (§4.2), scoped to a panel side. - /// Enter Tree mode on `side`, recording its current display mode so - /// Enter can restore it, and kicking off the drive root's immediate- - /// children read (additional-panel-modes "No up-front full-drive - /// scan"; design D7). - EnterTreeMode(PanelSide), - /// Reply to `Effect::ExpandTreeNode`: `path`'s immediate child - /// directories (empty on a read failure — skipped rather than - /// aborting, matching `find_in_subtree`'s precedent). Applied only if - /// `path` still names a not-yet-expanded node in `panel`'s tree, so a - /// reply for a node the tree no longer has (a since-superseded Tree - /// session) is silently dropped (additional-panel-modes "Children read - /// on expand"). - TreeNodeExpanded { panel: PanelSide, path: PathBuf, children: Vec }, - - // F9 menu overlay. - MenuOpen, - MenuClose, - /// Esc: closes the pull-down but leaves the bar open; a second Esc - /// closes the bar. - MenuCollapse, - MenuSelectPrev, - MenuSelectNext, - MenuPrevMenu, - MenuNextMenu, - MenuHotkey(char), - MenuActivate, - - // Drive select (Alt+F1/F2). - OpenDriveSelect(PanelSide), - DriveListReady { target: PanelSide, drives: Vec }, - DriveSelectMove(isize), - DriveSelectConfirm, - DriveSelectCancel, - - // Info display mode (Ctrl+L). - ToggleInfoMode(PanelSide), - - // F3 viewer (M4). - /// Open the viewer on the file under the active panel's cursor. - RequestViewer, - /// F10 in the viewer: close it and return focus to the panels. - ViewerClose, - /// F4 in the viewer: toggle text/hex mode. - ViewerToggleMode, - /// F2 in the viewer: toggle wrap/unwrap. - ViewerToggleWrap, - /// Move the top-of-screen anchor to this byte offset (clamped to the - /// file length). Used for both forward paging and the backward-scan - /// result, which the caller computes via [`crate::viewer::backward`] - /// before issuing this command, keeping `update` itself I/O-free. - ViewerSetTop(u64), - /// Set the unwrap-mode horizontal scroll, in display columns. - ViewerSetHScroll(usize), - /// F7 in the viewer: open the search prompt. - ViewerSearchStart, - ViewerSearchChar(char), - ViewerSearchBackspace, - ViewerSearchCancel, - /// Enter on the search prompt: run the search (via - /// `Effect::RunViewerSearch`) for the typed pattern. - ViewerSearchConfirm, - - // F4 external editor (M4). - /// Launch the configured external editor on the file under the active - /// panel's cursor. - RequestExternalEditor, - - // F4 built-in editor (M5). `RequestEditor` is the actual F4 keybinding - // target from a panel: it resolves the external-editor/built-in/size-cap - // precedence (builtin-editor "Editor invocation and size cap", "External - // editor takes precedence") and, when it wins, dispatches to the same - // `handle_request_external_editor` path `RequestExternalEditor` already - // exercises. - RequestEditor, - /// Reply to `Effect::OpenEditor`: the file loaded under the 10 MB cap - /// (builtin-editor "Small file opens in the editor"). - EditorOpened(Box), - /// Reply to `Effect::OpenEditor`: the file is `size` bytes, at or above - /// the cap — redirected to the F3 viewer with a notice (builtin-editor - /// "Large file redirects to the viewer"). - EditorTooLarge { path: PathBuf, size: u64 }, - /// Reply to `Effect::OpenEditor` when the file could not be opened. - EditorOpenFailed { message: String }, - - // Editor keymap, while `UiPhase::Editor` owns focus. - EditorChar(char), - EditorNewline, - EditorBackspace, - EditorMove(EditorMove), - /// Insert key: toggle insert/overwrite text-entry mode. - EditorToggleMode, - /// F3: anchor a line selection at the caret. - EditorMark, - EditorCut, - EditorCopy, - EditorPaste, - EditorUndo, - /// F7: open the literal-search prompt. - EditorSearchStart, - EditorSearchChar(char), - EditorSearchBackspace, - EditorSearchCancel, - /// Enter on the search prompt: run `EditorState::find_next` for the - /// typed pattern. - EditorSearchConfirm, - /// F4 (while the editor, not a panel, owns focus): open the - /// search-and-replace prompt's first (pattern) stage. - EditorReplaceStart, - EditorReplaceChar(char), - EditorReplaceBackspace, - EditorReplaceCancel, - /// Enter on the replace prompt: advances pattern -> replacement, or — - /// from the replacement stage — runs `EditorState::replace_first`. - EditorReplaceConfirm, - /// F2: save in place. - EditorSave, - /// Reply to `Effect::SaveEditor`: the write succeeded. `then_quit` - /// echoes back whether this save was requested by the save-on-exit - /// confirm's "Y", in which case the editor closes once applied. - EditorSaved { editor: Box, then_quit: bool }, - /// Reply to `Effect::SaveEditor`: the write failed — aborts a save-on- - /// exit quit attempt and surfaces the message inline rather than losing - /// the buffer (builtin-editor "Modified indicator and save-on-exit - /// prompt"). - EditorSaveFailed { message: String }, - /// F10: quit if unmodified, else raise the save-on-exit confirm - /// (builtin-editor "Quitting with unsaved changes prompts"). - EditorRequestQuit, - /// "Y" on the save-on-exit confirm: save, then close the editor. - EditorConfirmQuitSave, - /// "N" on the save-on-exit confirm: close the editor without saving. - EditorConfirmQuitDiscard, - /// Esc on the save-on-exit confirm: dismiss it and keep editing. - EditorCancelQuit, - - // Worker-produced events, re-entering through the same `update` path. - ListingChunk { panel: PanelSide, entries: Vec }, - ListingComplete { panel: PanelSide, total: usize }, - ListingFailed { panel: PanelSide, message: String }, - JobProgress(ProgressInfo), - JobConflict(ConflictInfo), - JobError(ErrorInfo), - JobDone { outcome: JobOutcome, source_dir: PathBuf, dest_dir: PathBuf }, - DriveLabelResolved { target: PanelSide, letter: char, label: Option, generation: u64 }, - InfoResolved { panel: PanelSide, path: PathBuf, request: u64, values: InfoValues }, - /// Reply to `Effect::QueryGitInfo`: either a resolved repository's - /// branch and per-entry statuses, or `GitInfo::none()` for "outside any - /// repository" and for a timed-out query (git-info "Background - /// repository detection", "Silent absence on timeout and stale-result - /// discarding"). `request` is matched against - /// `PanelState::git_request` the same way `InfoResolved`'s `request` is - /// matched against `info_request`, so a reply for a directory/generation - /// the panel has since moved past is dropped. - GitInfoResolved { panel: PanelSide, path: PathBuf, request: u64, info: GitInfo }, - /// Reply to `Effect::OpenViewer`: the file opened at `file_len` bytes - /// (viewer: Instant open). - ViewerOpened { path: PathBuf, file_len: u64 }, - /// Reply to `Effect::OpenViewer`: the file could not be opened or - /// mapped (§7 error policy — surfaced as an inline error, never a - /// panic). - ViewerOpenFailed { message: String }, - /// Reply to `Effect::RunViewerSearch` (viewer: F7 streaming search). - /// `request` echoes the id `Effect::RunViewerSearch` was issued with, so - /// a reply from a search superseded by a since-closed/reopened viewer - /// session (`ViewerState::search_request`) is recognized as stale and - /// dropped rather than applied to whatever viewer session happens to be - /// open when it arrives. - ViewerSearchResult { offset: Option, match_range: Option<(u64, u64)>, request: u64 }, - /// Reply to `Effect::RunExternalEditor` when the editor could not be - /// spawned (external-editor: Editor spawn errors do not crash the app). - ExternalEditorSpawnFailed { message: String }, - - // Ctrl+J fuzzy jump (M5). - /// Open the dialog (default binding, overridable via `config.toml`). - FuzzyJumpOpen, - FuzzyJumpChar(char), - FuzzyJumpBackspace, - FuzzyJumpMove(isize), - /// Enter: navigate the active panel to the highlighted directory and - /// close the dialog (fuzzy-jump "Enter navigates the active panel"). - FuzzyJumpConfirm, - /// Esc: close without navigating. - FuzzyJumpCancel, - - // Alt+F7 find file (M5). - /// Open the dialog, rooted at the active panel's current directory - /// (find-file "Find-file invocation"). - FindFileOpen, - FindFileChar(char), - FindFileBackspace, - /// Enter on the pattern input: kick off `Effect::FindInSubtree`. - FindFileSubmit, - /// Reply to `Effect::FindInSubtree`: one matched entry, streamed as the - /// walk finds it. `request` is matched against `FindFileState::request` - /// so a reply from an abandoned/superseded search is dropped (find-file - /// "Non-blocking search with static progress"). - FindFileMatch { request: u64, m: FindMatch }, - /// Reply to `Effect::FindInSubtree`: the walk has finished. - FindFileSearchDone { request: u64 }, - FindFileMove(isize), - /// Enter on a result: navigate the active panel's current tab in place - /// and close the dialog (find-file "Navigate to a chosen result"). - FindFileConfirm, - /// Esc: close without navigating, abandoning any in-progress search - /// (find-file "Dismiss the find-file dialog"). - FindFileCancel, - - // F2 user menu (M5). - UserMenuOpen, - UserMenuMove(isize), - /// Enter: on a user entry, run its command via the shell passthrough in - /// the active panel's directory (user-menu "Run the selected entry's - /// command via the shell in the active panel directory"); on the - /// compiled-in built-in Themes slot (cursor at `entries.len()`), open - /// the theme picker instead — no shell effect (user-menu "Built-in - /// Themes entry opens the theme selector"). Either way the user menu - /// closes first. - UserMenuConfirm, - /// Esc: close without running anything. - UserMenuCancel, - - // Options -> Themes picker (visual-themes). - /// Opens the picker with the active theme's row pre-highlighted. - /// Reached only via `MenuAction::OpenThemes` (Options → Themes), like - /// `UserMenuOpen`/`HelpOpen` above. - ThemePickerOpen, - ThemePickerMove(isize), - /// Enter: apply the highlighted theme immediately (switches - /// `state.theme` in the same reducer step), persist it to - /// `config.toml`, and close the dialog (theme-selection "Picker - /// navigation, apply, and cancel"). - ThemePickerConfirm, - /// Esc: close without changing the active theme or touching - /// `config.toml`. - ThemePickerCancel, - - // F1 Help window + About dialog (M5). - HelpOpen, - HelpMove(isize), - /// Enter / the `Help` button: opens the highlighted topic's page, or - /// the About dialog for the special first entry. - HelpActivate, - /// Esc / the `Cancel` button: dismisses the About dialog back to the - /// list, or a topic page back to the list, or closes the window - /// entirely from the list. - HelpCancel, - - // Panel display-mode switches reachable from the Left/Right menu (M5; - // design D7). `Info` and `Tree` keep their own dedicated commands - // (`ToggleInfoMode`, `EnterTreeMode`) since each carries extra - // bookkeeping (an Info query, a Tree session) this generic command - // deliberately does not. - SetDisplayMode { side: PanelSide, mode: DisplayMode }, - - /// Dismiss the startup-warning modal (any key, per the one-shot dialogs' - /// existing Esc/Enter-dismiss convention) (user-menu "Malformed file - /// warns and falls back without overwriting"). - DismissStartupWarning, - - // Enter-on-file action menu (file-action-menu). - /// Up/Down: move the highlight, clamped at both ends. - FileActionMenuMove(isize), - /// Enter: activate the highlighted entry and close the menu. - FileActionMenuConfirm, - /// Esc: close the menu with no action taken. - FileActionMenuCancel, - /// A first-letter hotkey: activate the matching entry directly, exactly - /// as if it had been highlighted and confirmed (file-action-menu - /// "First-letter hotkey activates directly"). - FileActionMenuHotkey(char), - /// A left-click on the open file-action menu's `index`'th row: activates - /// it exactly as `Command::FileActionMenuConfirm` would once the - /// highlight is there (mouse-input "File-action menu entries are - /// clickable"). `input::map_mouse` is the only source of this. - FileActionMenuItemClick(usize), - - // Clipboard export (clipboard-export). Reachable from Ctrl+C/Ctrl+Ins - // and Ctrl+Shift+Ins over the panels, the Files pull-down's three-item - // group, and the file-action menu's `Send to clipboard` (which always - // requests `Files`, scoped to its single target entry rather than - // `active_selection_sources` — see `activate_file_action`). - /// Places the F5-scope entries (`active_selection_sources`) on the OS - /// clipboard per `kind` (clipboard-export "Clipboard payloads and - /// scope"). A no-op, reported in the mini-status, when the scope is - /// empty (nothing selected and the cursor is on `..` or off the list). - CopyToClipboard(ClipboardPayloadKind), - /// Reply to `Effect::SetClipboard` once the TUI's `Clipboard` trait has - /// run. `payload` echoes back the request (the resolved paths are - /// needed to render the singular "Path copied: " message without - /// re-deriving scope); `fell_back_to_paths` is set only when a `Files` - /// request was carried out as a plain-text `Paths` write because file - /// objects aren't supported on this platform (clipboard-export - /// "Non-Windows fallback"). `Ok(())` shows the per-kind success - /// message; `Err(message)` shows `message` verbatim in the error role - /// (clipboard-export "Clipboard busy retry" — the TUI retries before - /// giving up, so by the time this arrives the failure is final). - ClipboardResult { payload: ClipboardPayload, fell_back_to_paths: bool, result: Result<(), String> }, - - // Mouse (mouse-basics). `input::map_mouse` is the only source of these — - // raw coordinates and `crossterm::event::KeyModifiers` never reach this - // crate (mouse-input "Hit-testing stays in the TUI"; design D2). - /// A left-click on an entry row: focuses `side` and moves its cursor to - /// the named entry, without changing the selection set (`Plain`), or - /// toggles that entry's selection in place (`Ctrl`) — never both - /// (mouse-input "Click focuses and places the cursor", "Ctrl+click - /// toggles selection"). `input::map_mouse` also turns a same-row - /// double-click into a plain `Command::Enter` instead of this (mouse- - /// input "Double-click acts as Enter"). - ClickEntry { side: PanelSide, name: OsString, mods: ClickMods }, - /// A left-click on a panel's title or blank body area: focuses `side` - /// without moving its cursor (mouse-input "Click focuses and places the - /// cursor"). - FocusPanel(PanelSide), - /// A wheel notch (or several, coalesced — design D7) over `side`'s - /// panel: move its cursor by `delta` rows (positive down, negative up), - /// independent of which panel is active (mouse-input "Wheel moves the - /// cursor of the panel under the pointer"). The viewer/editor have their - /// own wheel handling that never reaches this command (design D6). - ScrollPanel { side: PanelSide, delta: isize }, - /// A left-click on a function-key-bar slot (1..=10, `10` for F10): - /// dispatches exactly what that F-key would (mouse-input "Key bar, menu - /// bar, pull-down items, and dialog buttons are clickable"). - KeybarPress(u8), - /// A left-click on a menu-bar title: opens that pull-down (or switches - /// to it, if a different one is already open). - MenuTitleClick(MenuId), - /// A left-click on an open pull-down's `index`'th row: activates it - /// exactly as `Command::MenuActivate` would once the highlight is there. - MenuItemClick(usize), - /// A left-click on a dialog button (or a buttonless dialog's hotkey text - /// span — design D2): activates it exactly as the equivalent key would, - /// via [`button_command`]. - DialogButtonClick(ButtonId), - /// A right-click on an entry row: moves the cursor to the named entry - /// and opens the file-action menu for it (mouse-input "Right-click opens - /// the action menu"). `input::map_mouse` reaches this only for files - /// until the directory-target support of `file-action-menu` "Directory - /// targets and selection-scoped invocation" lands (mouse-basics section - /// 3). - OpenActionMenuAt { side: PanelSide, name: OsString }, +/// The typed read accessors over [`State::overlay`] (design D1): each +/// returns its overlay's state while that variant is open, and `None` +/// otherwise, so TUI readers and tests never match on the slot by hand. +macro_rules! overlay_accessors { + ($($(#[$doc:meta])* $name:ident / $name_mut:ident => $variant:ident($ty:ty);)*) => { + impl State { + $( + $(#[$doc])* + pub fn $name(&self) -> Option<&$ty> { + match &self.overlay { + Some(Overlay::$variant(v)) => Some(v), + _ => None, + } + } - // Mouse drag-and-drop (mouse-panel-drag; design D2/D4). `op` on each of - // these is the verb the drag's button/modifiers propose right now, - // recomputed by the TUI on every drag/release event and carried fresh - // rather than derived once and cached (design D2: "recomputed ... on - // every drag event"). `target`/`candidate` are the TUI's raw geometric - // hit-test result — a `DropTarget` or `None` for "off any potential - // target region" — which `update` is responsible for validating (mouse- - // drag "Valid drop targets"); core never receives raw coordinates. - /// A press on an entry row moved at least one cell (mouse-basics' - /// `press.moved`, mouse-panel-drag's territory): freezes the drag's - /// items — `name`'s panel's selection set if `name` is a member of it, - /// else `name` alone, never `..` — and its initial proposed verb - /// (mouse-drag "Drag lifecycle"). A no-op if `name` no longer names a - /// selectable entry. - DragBegin { side: PanelSide, name: OsString, op: JobKind }, - /// The drag's pointer resolved to a new de-duplicated position (the - /// TUI's `MouseTracker` owns de-duplication, so core only sees actual - /// changes — design D4). A no-op if no drag is in progress. - DragOver { op: JobKind, target: Option }, - /// The button released. Opens the drop-initiated destination dialog if - /// the drag's target is (still) valid and the source panel still shows - /// the captured directory; otherwise ends the drag with no effect - /// (mouse-drag "Release on an invalid spot", "Robust against listing - /// changes"). A no-op if no drag is in progress. - DragDrop { op: JobKind }, - /// Esc mid-drag: ends the drag with no effect (mouse-drag "Esc - /// cancels"). - DragCancel, + $(#[$doc])* + pub fn $name_mut(&mut self) -> Option<&mut $ty> { + match &mut self.overlay { + Some(Overlay::$variant(v)) => Some(v), + _ => None, + } + } + )* + } + }; } -/// Which of the three clipboard actions a `Command::CopyToClipboard` / -/// `Effect::SetClipboard` targets (clipboard-export "Clipboard payloads and -/// scope"). `Names` is menu-only — reachable only through the Files -/// pull-down, never a key binding (design D2). +overlay_accessors! { + /// The F9 menu while it is open. + menu / menu_mut => Menu(MenuState); + /// The drive-select dialog while it is open. + drive_select / drive_select_mut => DriveSelect(DriveSelect); + /// The Ctrl+J fuzzy-jump dialog while it is open. + fuzzy_jump / fuzzy_jump_mut => FuzzyJump(FuzzyJumpState); + /// The Alt+F7 find-file dialog while it is open. + find_file / find_file_mut => FindFile(FindFileState); + /// The F2 user menu while it is open. + user_menu / user_menu_mut => UserMenu(UserMenuState); + /// The Alt+F8 command-history dialog while it is open. + history_dialog / history_dialog_mut => HistoryDialog(HistoryDialogState); + /// The Options → Themes picker while it is open. + theme_picker / theme_picker_mut => ThemePicker(ThemePickerState); + /// The Enter-on-file action menu while it is open. + file_action_menu / file_action_menu_mut => FileActionMenu(FileActionMenuState); + /// The F1 Help window while it is open. + help / help_mut => Help(HelpState); +} + +/// Which layer owns input right now — the single precedence order the +/// reducer's routing, the TUI's key and mouse routing, and its render and +/// hit-map dispatch all follow (reducer-overlay-structure design D2; +/// application-shell "Modal overlays are mutually exclusive by +/// construction"). Highest first: the quit confirmation; the "terminal too +/// small" placeholder; a file-operation dialog; the viewer; the editor; the +/// open overlay; the panels or the startup splash. A layer handles only the +/// commands it claims and lets everything else pass to the layers below it, +/// so worker replies still reach a running job while the quit dialog is +/// open. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClipboardPayloadKind { - /// `CF_HDROP` file objects on Windows; falls back to `Paths`-as-text - /// elsewhere (clipboard-export "Windows file-object payload", - /// "Non-Windows fallback"). - Files, - /// One absolute path per line, plain text. - Paths, - /// One file name per line, plain text. - Names, +pub enum InputLayer { + /// The quit-confirmation dialog, reachable from every context. + QuitConfirm, + /// The terminal is below the 60x16 floor: the placeholder hides the + /// active screen, and only F10/Esc (a quit request) get through + /// (application-shell "Resize handling with 60x16 hard floor and + /// placeholder"). An open startup warning keeps its keys above it. + Undersized, + /// A file-operation dialog: setup, running job, or end-of-job summary. + FileOp, + /// The F3 viewer. + Viewer, + /// The F4 built-in editor. + Editor, + /// The open modal overlay in [`State::overlay`]. + Overlay, + /// The panels and the command line. + Panels, + /// The startup splash. + Splash, } -/// The resolved payload for an `Effect::SetClipboard`: the action kind and -/// the absolute, `..`-excluded paths of the F5-scope entries -/// (`active_selection_sources`/`named_entry_source`) it was built from. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClipboardPayload { - pub kind: ClipboardPayloadKind, - pub items: Vec, +/// The layer that owns input for `state` (design D2). See [`InputLayer`] for +/// the order. +pub fn input_layer(state: &State) -> InputLayer { + if state.quit_confirm { + return InputLayer::QuitConfirm; + } + // The startup warning is drawn over the placeholder and keeps its keys + // there (resize-preserves-session-state design D3); every other screen + // is hidden, so nothing may reach it. + if state.undersized && !matches!(state.overlay, Some(Overlay::StartupWarning(_))) { + return InputLayer::Undersized; + } + screen_layer(state) } -/// A side-effect request. `update` only ever returns these; it never -/// performs them. The TUI event loop executes them (spawning worker -/// threads, suspending the terminal, exiting the process, ...). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Effect { - StartListing { panel: PanelSide, path: PathBuf }, - Quit, - RunJob(Job), - CancelJob, - SendConflictReply(ConflictChoice), - SendErrorReply(ErrorChoice), - /// Suspend the TUI, run this invocation on the real terminal, wait for a - /// keypress, then restore and redraw. - /// The invocation to run, plus the panel side to re-read once the TUI - /// resumes — the shell spawn is inherently a TUI-side effect, so the - /// re-read command has to travel with it rather than reaching `update` - /// on its own (§ "Run command" — "the active panel is re-read"). - RunShellCommand(shell::Invocation, PanelSide), - /// Leave the alternate screen to expose the host terminal's scrollback - /// until any key is pressed. - ShowScrollback, - /// Rewrite `history.json` atomically with the command history and - /// directory frecency together (fuzzy-jump "Directory history - /// persistence"; design D6). - PersistHistory(config::HistoryFile), - /// Rewrite `config.toml`'s `theme =` key atomically after the Options → - /// Themes picker applies a theme (theme-selection "Applied theme - /// persists to configuration"). - PersistTheme(String), - /// Rewrite `config.toml`'s `panel_split =` key atomically after a - /// successful split adjustment or reset (panel-split "Split - /// persistence to configuration"). - PersistPanelSplit(u16), - /// Read the logical-drive bitmask (cheap, synchronous) and feed the - /// letters back as `DriveListReady` before the next paint. - EnumerateDrives(PanelSide), - /// Fetch one drive's volume label on a worker thread. `generation` - /// travels with the result so a reply from a since-superseded dialog - /// session can be told apart from the current one. - FetchDriveLabel { target: PanelSide, letter: char, generation: u64 }, - /// Gather the Info panel's async values on a worker thread. `request` - /// travels with the result so a reply from a since-superseded query can - /// be told apart from the current one. - QueryInfo { panel: PanelSide, path: PathBuf, request: u64 }, - /// Run `git_info::query` for `path` on a dedicated worker thread — - /// never the shared Info worker, since a slow status call on one panel - /// must not head-of-line-block the other (design D3) — and report the - /// result back via `Command::GitInfoResolved`. `request` travels with - /// the result so a stale or timed-out reply can be told apart from the - /// query that's still current (git-info "Background repository - /// detection", "Pathspec-scoped status queries"). - QueryGitInfo { panel: PanelSide, path: PathBuf, request: u64 }, - /// Read `path`'s immediate child directories for Tree-mode expansion - /// (`listing::list_child_dirs`) on a worker thread, reporting them back - /// via `Command::TreeNodeExpanded` — one directory's worth of I/O per - /// call, never a recursive scan (additional-panel-modes "Children read - /// on expand"; design D7). - ExpandTreeNode { panel: PanelSide, path: PathBuf }, - /// Open `path` for the F3 viewer: map/open it and report back its - /// length via `Command::ViewerOpened`, or the failure via - /// `Command::ViewerOpenFailed`. Cheap enough to run synchronously - /// before the next repaint, exactly like `EnumerateDrives` — O(1) per - /// the "instant open" requirement, never a worker-thread round trip - /// (viewer: Instant open). - OpenViewer { path: PathBuf }, - /// Run the F7 streaming search from `start_offset` for `pattern` - /// against `path`, reporting the match back via - /// `Command::ViewerSearchResult` (viewer: F7 streaming search). - /// `request` must be echoed back unchanged in the `ViewerSearchResult` - /// reply so a stale reply can be told apart from the current search. - RunViewerSearch { path: PathBuf, start_offset: u64, pattern: Vec, request: u64 }, - /// Suspend the TUI, launch the external editor per `EditorInvocation`, - /// wait for it to exit, then restore and re-read the panel side that - /// owned the cursor entry — the same suspend/restore seam - /// `RunShellCommand` uses (design D7; external-editor: TUI suspend and - /// restore, Synchronous wait and panel re-read). - RunExternalEditor(EditorInvocation, PanelSide), - /// Open `path` for the F4 built-in editor: stat it and, under the 10 MB - /// cap, read and decode it in full, reporting back via - /// `Command::EditorOpened`/`EditorTooLarge`/`EditorOpenFailed`. Run - /// synchronously before the next repaint rather than on a worker - /// thread — the same "cheap enough for the input path" precedent - /// `Effect::OpenViewer` sets, extended here to the whole-file read the - /// 10 MB cap explicitly bounds (design D1). - OpenEditor { path: PathBuf }, - /// Write `editor`'s buffer to disk via `EditorState::save`, reporting - /// the post-save state back via `Command::EditorSaved` (or the failure - /// via `Command::EditorSaveFailed`). `then_quit` travels through - /// unchanged so the reply knows whether to close the editor once the - /// write lands (builtin-editor "Save in place …", design D9). - SaveEditor { editor: Box, then_quit: bool }, - /// Walk `root`'s subtree for entries whose name contains `pattern` on a - /// worker thread, streaming each match back via `Command::FindFileMatch` - /// as it's found and finishing with `Command::FindFileSearchDone` — - /// never blocking the UI event loop (find-file "Non-blocking search - /// with static progress"). `request` travels with every reply so a - /// stale/abandoned search's results are dropped once the dialog has - /// moved on. - FindInSubtree { root: PathBuf, pattern: String, request: u64 }, - /// Write `payload` to the OS clipboard via the TUI's `Clipboard` trait - /// and report the outcome back via `Command::ClipboardResult`. Executed - /// synchronously, like `EnumerateDrives` — `OpenClipboard` binds to the - /// calling thread and set-and-close is sub-millisecond, so no worker - /// thread is needed (design D3; clipboard-export "Clipboard payloads - /// and scope"). - SetClipboard(ClipboardPayload), +/// The layer beneath the quit confirmation and the placeholder: what the +/// active screen itself is. This is the order the reducer routes a command +/// the quit layer did not claim through — the placeholder gates keys in the +/// TUI only, never the worker replies and timer ticks that must keep +/// reaching the hidden screen. +fn screen_layer(state: &State) -> InputLayer { + match &state.phase { + UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_) => InputLayer::FileOp, + UiPhase::Viewer(_) => InputLayer::Viewer, + UiPhase::Editor(_) => InputLayer::Editor, + UiPhase::Splash { .. } | UiPhase::Panels if state.overlay.is_some() => InputLayer::Overlay, + UiPhase::Splash { .. } => InputLayer::Splash, + UiPhase::Panels => InputLayer::Panels, + } } /// The pure state transition. Equal `(state, command)` always yields equal -/// `(state, Vec)`. A thin wrapper around [`update_impl`] that +/// `(state, Vec)`. A thin wrapper around `update_impl` that /// enforces the one invariant that must hold no matter which internal path a /// command took: `state.drag` is never `Some` outside `UiPhase::Panels` with /// no overlay open (mouse-drag "Cancel and phase-change clear the drag"; -/// design D5) — see [`drag_allowed`]. Applying it here, once, after every +/// design D5) — see `drag_allowed`. Applying it here, once, after every /// call — rather than threading a clear into each of `update_impl`'s many /// phase-transition arms — is what makes it a true postcondition: every /// return path is covered by construction, including ones a future phase /// addition might add without remembering this rule. +/// +/// The same wrapper records the animation stamps (`crate::stamps`): when an +/// overlay opened or changed during the transition, it is stamped with +/// `state.clock_ms`. That is all the reducer does for animations — it never +/// delays a transition (ui-animations "Render-only motion model driven by the +/// reducer clock"). pub fn update(state: State, cmd: Command) -> (State, Vec) { - let listing_failed = matches!(cmd, Command::ListingFailed { .. }); + // Only a failure the panel actually accepts counts: a stale + // `ListingFailed` from a directory the user already left is dropped by + // `apply_listing_event` and must not cancel a drag in progress elsewhere + // (panel-navigation "Superseded failure does not mark the new listing + // failed"; listing-request-guard design D2). + let listing_failed = matches!(cmd, Command::ListingFailed { .. }) && listing_reply_is_current(&state, &cmd); + let marks = crate::stamps::Snapshot::capture(&state); let (mut state, effects) = update_impl(state, cmd); + marks.apply(&mut state); // A listing failure doesn't change `state.phase` or open any overlay — // `drag_allowed` alone wouldn't catch it — but mouse-drag's "Cancel and // phase-change clear the drag" explicitly lists listing failure as a @@ -1034,25 +606,16 @@ pub fn update(state: State, cmd: Command) -> (State, Vec) { } /// Whether `state.drag` is allowed to remain `Some`: only in -/// `UiPhase::Panels` with none of the modal overlays below open — the same -/// set `input::mouse::context` (mouse-basics) already gates ordinary mouse -/// input behind, extended with `quit_confirm` since it uniquely overlays -/// every other context too (mouse-drag "Cancel and phase-change clear the -/// drag": job completion, listing failure, F9, quit request, resize below -/// the minimum — every one of those either leaves `UiPhase::Panels` or sets -/// one of these fields). +/// `UiPhase::Panels` with no modal overlay open — the same set +/// `input::mouse::context` (mouse-basics) already gates ordinary mouse input +/// behind, extended with `quit_confirm` since it uniquely overlays every +/// other context too (mouse-drag "Cancel and phase-change clear the drag": +/// job completion, listing failure, F9, quit request, resize below the +/// minimum — every one of those either leaves `UiPhase::Panels` or sets one +/// of these fields; the resize sets `undersized`, since the phase no longer +/// changes to signal it — design D6). fn drag_allowed(state: &State) -> bool { - matches!(state.phase, UiPhase::Panels) - && state.menu.is_none() - && state.drive_select.is_none() - && state.fuzzy_jump.is_none() - && state.find_file.is_none() - && state.user_menu.is_none() - && state.theme_picker.is_none() - && state.file_action_menu.is_none() - && state.help.is_none() - && state.startup_warning.is_none() - && !state.quit_confirm + matches!(state.phase, UiPhase::Panels) && !state.undersized && state.overlay.is_none() && !state.quit_confirm } /// The actual state-transition logic. Never call this directly outside @@ -1077,21 +640,36 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { let mut effects = Vec::new(); - // Resize is handled uniformly regardless of phase. + // Resize is handled uniformly regardless of phase. Dropping below the + // 60x16 floor only raises `undersized`; the phase and every overlay are + // left exactly as they are, so the screen the placeholder hides — + // editor buffer, viewer position, running job and its dialog, open menus + // — is still there when the terminal is enlarged (application-shell + // "Resize handling with 60x16 hard floor and placeholder"; design D1). if let Command::Resize(w, h) = cmd { + let was_undersized = state.undersized; state.term_size = (w, h); - let too_small = State::too_small((w, h)); - state.phase = match (&state.phase, too_small) { - (UiPhase::Placeholder, false) => UiPhase::Panels, - (UiPhase::Placeholder, true) => UiPhase::Placeholder, - (_, true) => UiPhase::Placeholder, - (other, false) => other.clone(), - }; + state.undersized = State::too_small((w, h)); + // The splash is the one exception: shrinking dismisses it for good, + // and enlarging reveals the panels rather than the splash + // (startup-splash "Shrinking below the floor mid-splash"; design D2). + if state.undersized && matches!(state.phase, UiPhase::Splash { .. }) { + state.phase = UiPhase::Panels; + } // A shrinking terminal can leave either panel's cursor below its new, // shorter window; re-clamp both sides unconditionally (panel- // navigation "Terminal resize re-clamps"). reconcile_panel_viewport(&mut state, PanelSide::Left); reconcile_panel_viewport(&mut state, PanelSide::Right); + // `handle_editor` skips its caret re-scroll while undersized (design + // D6), so the first resize back above the floor does it once here + // against the restored viewport. The buffer and caret are untouched. + if was_undersized && !state.undersized { + if let UiPhase::Editor(editor) = &mut state.phase { + let (rows, cols) = editor_viewport(state.term_size); + editor.ensure_caret_visible(rows, cols); + } + } return (state, effects); } @@ -1103,10 +681,11 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { // as "the next key press" and clears it immediately instead of waiting // out the timer — except the two commands that manage the feedback // themselves, whose own handling below sets it fresh. - if let Command::Tick(now) = cmd { - state.clock_ms = now; + if let Command::Tick { now_ms, epoch_ms } = cmd { + state.clock_ms = now_ms; + state.epoch_ms = epoch_ms; for side in [PanelSide::Left, PanelSide::Right] { - if matches!(&state.panel(side).clipboard_feedback, Some(f) if now >= f.expires_at_ms) { + if matches!(&state.panel(side).clipboard_feedback, Some(f) if now_ms >= f.expires_at_ms) { state.panel_mut(side).clipboard_feedback = None; } } @@ -1125,8 +704,11 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { // Async drive/Info results are applied only when they still describe // what is on screen; a stale answer is dropped, never rendered. match cmd { + // Applied only while the slot holds the drive-select dialog this + // label was fetched for, so a late label can never touch another + // overlay (reducer-overlay-structure design D5). Command::DriveLabelResolved { target, letter, label, generation } => { - if let Some(dialog) = &mut state.drive_select { + if let Some(dialog) = state.drive_select_mut() { if dialog.target == target && dialog.generation == generation { dialog.apply_label(letter, label); } @@ -1140,7 +722,17 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { } return (state, effects); } - Command::GitInfoResolved { panel, path, request, info } => { + Command::GitInfoResolved { panel, path, request, info, timed_out_root } => { + // A timeout is evidence about the *repository*, not about the + // panel's current directory, so the root is recorded even when + // the reply itself is stale (git-info "Timeout recorded after + // the panel moved on"). A root already covered by a remembered + // one is not stored twice. + if let Some(root) = timed_out_root { + if !state.git_no_info_roots.iter().any(|existing| git_info::is_under_root(&root, existing)) { + state.git_no_info_roots.push(root); + } + } let p = state.panel_mut(panel); if p.cwd == path && p.git_request == Some(request) { p.git_info = info; @@ -1159,21 +751,102 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { reconcile_panel_viewport(&mut state, panel); return (state, effects); } + // The command-line target probe and the F3/F4 open replies are + // matched against their pending request here, ahead of every + // phase- and overlay-specific arm, so a reply that arrives after + // the user opened a menu, a dialog, the viewer or the editor is + // recognized as superseded and discarded rather than reaching the + // Panels arm through an overlay that does not claim it + // (offload-blocking-io design D1/D4). + Command::PathProbed { request, kind } => { + effects.extend(apply_path_probed(&mut state, request, kind)); + return (state, effects); + } + Command::ViewerOpened { request, path, file_len } => { + if let Some(side) = take_current_open(&mut state, request) { + // A prior failed F3/F4 attempt may have left `last_error` + // set on this panel; a successful open has clearly moved + // past it, so the mini-status line must not keep showing + // the stale message (§7 error policy — errors are surfaced + // until superseded by success, not left to linger). + state.panel_mut(side).last_error = None; + state.phase = UiPhase::Viewer(ViewerState::new(path, file_len)); + } + return (state, effects); + } + Command::ViewerOpenFailed { request, message } => { + if let Some(side) = take_current_open(&mut state, request) { + state.panel_mut(side).last_error = Some(message); + } + return (state, effects); + } + Command::EditorOpened { request, editor } => { + if let Some(side) = take_current_open(&mut state, request) { + // Mirrors the `Command::ViewerOpened` clear (§7 error policy). + state.panel_mut(side).last_error = None; + state.phase = UiPhase::Editor(*editor); + } + return (state, effects); + } + Command::EditorTooLarge { request, path, size: _ } => { + if let Some(side) = take_current_open(&mut state, request) { + // builtin-editor "Large file redirects to the viewer": the + // notice lands on the panel's mini-status (the same surface + // `Command::ViewerOpenFailed` uses) since the viewer chrome + // itself has no room reserved for a banner; it's visible the + // moment the user returns from the viewer to the panels. The + // viewer open is a fresh request for the same side (design + // D4); its `Opening…` note is set first so it does not wipe + // the notice. + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + effects.push(request_open(&mut state, side, PendingOpenKind::Viewer, path)); + state.panel_mut(side).last_error = Some(format!("{name} is too large for the editor (10 MB limit) — opened in the viewer")); + } + return (state, effects); + } + Command::EditorNotEditable { request, path, reason } => { + if let Some(side) = take_current_open(&mut state, request) { + // Same mechanism and notice surface as the size redirect + // (design D2); the wording names the reason so a UTF-16 + // file is not just "not UTF-8". + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + effects.push(request_open(&mut state, side, PendingOpenKind::Viewer, path)); + state.panel_mut(side).last_error = Some(match reason { + NotEditableReason::Utf16 => format!("{name} is UTF-16 text — the editor only edits UTF-8; opened in the viewer"), + NotEditableReason::Binary => { + format!("{name} looks binary (contains NUL bytes) — the editor only edits UTF-8 text; opened in the viewer") + } + NotEditableReason::NotUtf8 => format!("{name} is not valid UTF-8 — the editor only edits UTF-8 text; opened in the viewer"), + }); + } + return (state, effects); + } + Command::EditorOpenFailed { request, message } => { + if let Some(side) = take_current_open(&mut state, request) { + state.panel_mut(side).last_error = Some(message); + } + return (state, effects); + } _ => {} } - // The quit-confirmation dialog is a modal overlay beside the phase, like - // the M5 dialogs below, but uniquely reachable from *every* context — - // panels, the viewer, an open menu, and every other modal dialog/ - // overlay, including while a file operation is running — so it is - // handled here, ahead of the phase-specific short-circuits that follow, - // rather than gated behind any one of them (application-shell "Quit - // request keys and confirmation"; design D5). + // From here on the command is routed through the layers of the single + // precedence order (`input_layer`; reducer-overlay-structure design D2): + // each layer handles only the commands it claims, and everything else + // falls through to the next layer down. + // + // The quit-confirmation dialog is the topmost layer, uniquely reachable + // from *every* context — panels, the viewer, an open overlay, and every + // dialog, including while a file operation is running — so it is handled + // here, ahead of every layer below, rather than gated behind any one of + // them (application-shell "Quit request keys and confirmation"; design + // D5). It claims only `ConfirmQuit` and `CancelQuit`; worker replies and + // everything else pass beneath it. if let Command::RequestQuit = cmd { state.quit_confirm = true; return (state, effects); } - if state.quit_confirm { + if input_layer(&state) == InputLayer::QuitConfirm { match cmd { Command::ConfirmQuit => { // Confirming while a job is running aborts it first, through @@ -1201,10 +874,16 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { } } - // File-op setup/running/summary phases (and the job events that drive - // them) are handled uniformly here, independent of the - // Splash/Placeholder/Panels phases below. - if matches!(state.phase, UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_)) + // The layers below the quit confirmation, in the same order + // (`screen_layer`: the placeholder gates keys in the TUI only, so the + // reducer looks straight through it). + let layer = screen_layer(&state); + + // File-op setup/running/summary phases are handled uniformly here, + // independent of the Splash/Panels phases below. This layer also claims + // the job events that drive them from every phase, so a running job's + // replies reach it beneath the quit dialog. + if layer == InputLayer::FileOp || matches!(cmd, Command::JobProgress(_) | Command::JobConflict(_) | Command::JobError(_) | Command::JobDone { .. }) { effects.extend(handle_file_op(&mut state, cmd)); @@ -1214,7 +893,7 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { // The F3 viewer replaces the panels full-screen and owns input focus // while open (viewer: Frame-less full-screen chrome — "Viewer owns // focus while open"), so its commands are handled uniformly here. - if matches!(state.phase, UiPhase::Viewer(_)) { + if layer == InputLayer::Viewer { effects.extend(handle_viewer(&mut state, cmd)); return (state, effects); } @@ -1222,64 +901,48 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { // The F4 built-in editor, likewise, replaces the panels full-screen and // owns input focus while open (builtin-editor "Full-screen editor // chrome"). - if matches!(state.phase, UiPhase::Editor(_)) { + if layer == InputLayer::Editor { effects.extend(handle_editor(&mut state, cmd)); return (state, effects); } - // The drive-select dialog and the F9 menu are modal over the panels: - // while one is open it claims the keys it understands. - if state.drive_select.is_some() && is_drive_select_command(&cmd) { - effects.extend(handle_drive_select(&mut state, cmd)); - return (state, effects); - } - if state.menu.is_some() && is_menu_command(&cmd) { - let follow_up = handle_menu(&mut state, cmd); - if let Some(next) = follow_up { - // An activated item re-enters as the command it stands for, so a - // menu action and its keyboard shortcut share one implementation. - let (state, more) = update_impl(state, next); - return (state, more); + // The open modal overlay claims the commands it understands and nothing + // else: take it out of the slot, hand its state to its handler, and + // restore the slot from the step the handler returns (design D3). A + // command it does not claim falls through to the base layer with the + // overlay back in place, exactly as before. + if let Some(mut overlay) = state.overlay.take() { + if overlay.claims(&cmd) { + return match overlay.handle(&mut state, cmd) { + OverlayStep::Keep(more) => { + state.overlay = Some(overlay); + effects.extend(more); + (state, effects) + } + OverlayStep::Close(more) => { + effects.extend(more); + (state, effects) + } + OverlayStep::Replace(next, more) => { + state.overlay = Some(next); + effects.extend(more); + (state, effects) + } + // An activated menu item re-enters as the command it stands + // for, so a menu action and its keyboard shortcut share one + // implementation — and the item's dialog opens into the slot + // the menu just left (application-shell "A menu item opens + // its dialog in place of the menu"). + OverlayStep::CloseThen(next) => update_impl(state, next), + }; } - return (state, effects); - } - // The M5 dialogs (fuzzy jump, find file, user menu, Help/About) are - // likewise modal overlays beside the phase, each claiming only the - // commands it understands while open — the same shape as the two blocks - // above. - if state.fuzzy_jump.is_some() && is_fuzzy_jump_command(&cmd) { - effects.extend(handle_fuzzy_jump(&mut state, cmd)); - return (state, effects); - } - if state.find_file.is_some() && is_find_file_command(&cmd) { - effects.extend(handle_find_file(&mut state, cmd)); - return (state, effects); - } - if state.user_menu.is_some() && is_user_menu_command(&cmd) { - effects.extend(handle_user_menu(&mut state, cmd)); - return (state, effects); - } - if state.theme_picker.is_some() && is_theme_picker_command(&cmd) { - effects.extend(handle_theme_picker(&mut state, cmd)); - return (state, effects); - } - if state.file_action_menu.is_some() && is_file_action_menu_command(&cmd) { - effects.extend(handle_file_action_menu(&mut state, cmd)); - return (state, effects); - } - if state.help.is_some() && is_help_command(&cmd) { - effects.extend(handle_help(&mut state, cmd)); - return (state, effects); - } - if state.startup_warning.is_some() && matches!(cmd, Command::DismissStartupWarning) { - state.startup_warning = None; - return (state, effects); + state.overlay = Some(overlay); } match &state.phase { UiPhase::Splash { started_at_ms } => match cmd { - Command::Tick(now) => { - if now.saturating_sub(*started_at_ms) >= SPLASH_MIN_HOLD_MS { + Command::Tick { now_ms, .. } => { + if now_ms.saturating_sub(*started_at_ms) >= SPLASH_MIN_HOLD_MS { state.phase = UiPhase::Panels; } } @@ -1290,307 +953,7 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { state.phase = UiPhase::Panels; } }, - UiPhase::Placeholder => {} - UiPhase::Panels => match cmd { - Command::MoveCursor(m) => { - // A movement key exits type-ahead (if active) *and* is - // still applied to the panel cursor as a normal movement, - // in the same keystroke — the `input/` mapper emits exactly - // this one command for a movement key while type-ahead owns - // the keyboard, relying on this side effect to also end the - // mode (type-ahead-jump "A movement key exits type-ahead - // and is applied to the panel"; design D5). - state.quick_search = None; - let side = state.active; - if state.panel(side).display_mode == DisplayMode::Tree { - effects.extend(handle_tree_cursor_move(&mut state, side, m)); - } else { - state.panel_mut(side).move_cursor(m); - reconcile_panel_viewport(&mut state, side); - } - } - Command::ToggleActivePanel => state.active = state.active.toggle(), - Command::Enter => effects.extend(handle_enter(&mut state)), - Command::ParentDir => { - let side = state.active; - effects.extend(handle_parent(&mut state, side)); - } - // `Command::RequestQuit` is handled globally, above, regardless - // of phase — it never reaches this arm. - Command::RereadPanel(side) => { - let path = state.panel(side).cwd.clone(); - effects.extend(begin_listing(&mut state, side, path)); - } - Command::ToggleSelectAtCursor => { - let side = state.active; - state.panel_mut(side).toggle_selection_and_advance(); - reconcile_panel_viewport(&mut state, side); - } - Command::GroupSelectAll => state.panel_mut(state.active).select_matching("*"), - Command::GroupDeselectAll => state.panel_mut(state.active).deselect_matching("*"), - Command::InvertSelection => state.panel_mut(state.active).invert_selection(), - Command::RequestCopy => enter_file_op_setup(&mut state, JobKind::Copy), - Command::RequestMove => enter_file_op_setup(&mut state, JobKind::Move), - Command::RequestMkdir => enter_file_op_setup(&mut state, JobKind::Mkdir), - Command::RequestDelete => enter_delete_confirm(&mut state), - Command::CopyToClipboard(kind) => effects.extend(handle_copy_to_clipboard(&mut state, kind)), - Command::ClipboardResult { payload, fell_back_to_paths, result } => { - apply_clipboard_result(&mut state, payload, fell_back_to_paths, result) - } - - Command::CommandLineChar(c) => { - state.command_line.push(c); - state.history_cursor = None; - } - Command::CommandLineBackspace => { - state.command_line.pop(); - state.history_cursor = None; - } - Command::CommandLineClear => { - state.command_line.clear(); - state.history_cursor = None; - } - Command::CommandLineHistoryPrev => recall_history(&mut state, -1), - Command::CommandLineHistoryNext => recall_history(&mut state, 1), - Command::PasteCursorName => paste_cursor_entry(&mut state, false), - Command::PasteCursorPath => paste_cursor_entry(&mut state, true), - Command::ShowScrollback => effects.push(Effect::ShowScrollback), - - Command::QuickSearchStart(c) => { - let mut pattern = String::new(); - pattern.push(c); - jump_to_prefix(&mut state, &pattern); - state.quick_search = Some(pattern); - } - Command::QuickSearchChar(c) => { - if let Some(mut pattern) = state.quick_search.take() { - pattern.push(c); - jump_to_prefix(&mut state, &pattern); - state.quick_search = Some(pattern); - } - } - Command::QuickSearchBackspace => { - if let Some(mut pattern) = state.quick_search.take() { - pattern.pop(); - // An empty pattern stays active rather than exiting - // type-ahead, and the cursor holds its position rather - // than re-jumping (type-ahead-jump "Backspace on a - // single-character pattern"). - if !pattern.is_empty() { - jump_to_prefix(&mut state, &pattern); - } - state.quick_search = Some(pattern); - } - } - Command::QuickSearchEnd => state.quick_search = None, - - Command::SetSortMode { side, mode } => { - state.panel_mut(side).set_sort_mode(mode); - reconcile_panel_viewport(&mut state, side); - } - - Command::QuickFilterStart => state.panel_mut(state.active).activate_quick_filter(), - Command::QuickFilterChar(c) => { - let side = state.active; - state.panel_mut(side).quick_filter_push(c); - reconcile_panel_viewport(&mut state, side); - } - Command::QuickFilterBackspace => { - let side = state.active; - state.panel_mut(side).quick_filter_backspace(); - reconcile_panel_viewport(&mut state, side); - } - Command::QuickFilterEnd => { - let side = state.active; - state.panel_mut(side).clear_quick_filter(); - reconcile_panel_viewport(&mut state, side); - } - - Command::OpenTab => { - let side = state.active; - // The new tab inherits the active tab's own (never-stale) - // state, so it can never itself come up stale — no - // fresh-read check needed here (panel-tabs "Stale - // background tab refresh on activation"). - state.panel_mut(side).open_tab(); - reconcile_panel_viewport(&mut state, side); - } - Command::CloseTab => { - let side = state.active; - if state.panel_mut(side).close_tab() { - // A stale-activation refresh is not a navigation (the - // user changed tabs, not directories), so it goes - // through `begin_listing_inner`: fresh read without the - // frecency visit or history persist (panel-tabs "Stale - // background tab refresh on activation"). - let path = state.panel(side).cwd.clone(); - effects.extend(begin_listing_inner(&mut state, side, path)); - } - reconcile_panel_viewport(&mut state, side); - } - Command::SwitchTab(n) => { - let side = state.active; - if state.panel_mut(side).switch_tab(n) { - // Same non-navigation refresh as `CloseTab` above. - let path = state.panel(side).cwd.clone(); - effects.extend(begin_listing_inner(&mut state, side, path)); - } - reconcile_panel_viewport(&mut state, side); - } - - Command::EnterTreeMode(side) => effects.extend(enter_tree_mode(&mut state, side)), - Command::SetDisplayMode { side, mode } => { - let p = state.panel_mut(side); - p.tree = None; - p.info_request = None; - p.display_mode = mode; - // A quick filter narrowed the panel's prior display mode; a - // stale pattern must not linger invisibly into Brief/Tree/ - // Info mode, whose renderers don't surface it the same way - // Full mode does (quick-filter "Substring narrowing as the - // pattern is typed"). - p.clear_quick_filter(); - } - - Command::FuzzyJumpOpen => state.fuzzy_jump = Some(FuzzyJumpState::new()), - Command::FindFileOpen => { - let root = state.active_panel().cwd.clone(); - state.find_file = Some(FindFileState::new(root)); - } - Command::UserMenuOpen => state.user_menu = Some(UserMenuState::new()), - Command::ThemePickerOpen => state.theme_picker = Some(ThemePickerState::open(&state.theme.name)), - Command::HelpOpen => state.help = Some(HelpState::new()), - - Command::MenuOpen => state.menu = Some(MenuState::opened()), - Command::OpenDriveSelect(side) => effects.push(Effect::EnumerateDrives(side)), - Command::DriveListReady { target, drives } => { - let current = drives::drive_letter_of(&state.panel(target).cwd); - let generation = state.next_request_id(); - for letter in &drives { - effects.push(Effect::FetchDriveLabel { target, letter: *letter, generation }); - } - let mut dialog = DriveSelect::new(target, drives, current); - dialog.generation = generation; - state.drive_select = Some(dialog); - } - Command::ToggleInfoMode(side) => effects.extend(toggle_info_mode(&mut state, side)), - - Command::RequestViewer => effects.extend(handle_request_viewer(&mut state)), - Command::ViewerOpened { path, file_len } => { - // A prior failed F3/F4 attempt may have left `last_error` - // set on this panel; a successful open has clearly moved - // past it, so the mini-status line must not keep showing - // the stale message (§7 error policy — errors are surfaced - // until superseded by success, not left to linger). - state.panel_mut(state.active).last_error = None; - state.phase = UiPhase::Viewer(ViewerState::new(path, file_len)); - } - Command::ViewerOpenFailed { message } => state.panel_mut(state.active).last_error = Some(message), - Command::RequestExternalEditor => effects.extend(handle_request_external_editor(&mut state)), - Command::ExternalEditorSpawnFailed { message } => state.panel_mut(state.active).last_error = Some(message), - - Command::RequestEditor => effects.extend(handle_request_editor(&mut state)), - Command::EditorOpened(editor) => { - // Mirrors the `Command::ViewerOpened` clear: a prior failed - // F3/F4 attempt may have left `last_error` set (§7 error - // policy). - state.panel_mut(state.active).last_error = None; - state.phase = UiPhase::Editor(*editor); - } - Command::EditorTooLarge { path, size: _ } => { - // builtin-editor "Large file redirects to the viewer": the - // notice lands on the panel's mini-status (the same surface - // `Command::ViewerOpenFailed` uses) since the viewer chrome - // itself has no room reserved for a banner; it's visible the - // moment the user returns from the viewer to the panels. - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); - state.panel_mut(state.active).last_error = - Some(format!("{name} is too large for the editor (10 MB limit) — opened in the viewer")); - effects.push(Effect::OpenViewer { path }); - } - Command::EditorOpenFailed { message } => state.panel_mut(state.active).last_error = Some(message), - - Command::SplitGrow => effects.extend(adjust_split(&mut state, panel_split::SPLIT_STEP as i32)), - Command::SplitShrink => effects.extend(adjust_split(&mut state, -(panel_split::SPLIT_STEP as i32))), - Command::SplitReset => { - if state.split_percent != panel_split::DEFAULT_SPLIT_PERCENT { - state.split_percent = panel_split::DEFAULT_SPLIT_PERCENT; - effects.push(Effect::PersistPanelSplit(state.split_percent)); - } - } - - // Mouse (mouse-basics; design D2). - Command::FocusPanel(side) => state.active = side, - Command::ClickEntry { side, name, mods } => effects.extend(handle_click_entry(&mut state, side, name, mods)), - Command::ScrollPanel { side, delta } => effects.extend(handle_scroll_panel(&mut state, side, delta)), - Command::KeybarPress(slot) => { - if let Some(next) = keybar_command(slot) { - return update_impl(state, next); - } - } - Command::MenuTitleClick(id) => state.menu = Some(MenuState::for_menu(id)), - Command::MenuItemClick(index) => { - if let Some(next) = handle_menu_item_click(&mut state, index) { - return update_impl(state, next); - } - } - Command::OpenActionMenuAt { side, name } => effects.extend(handle_open_action_menu_at(&mut state, side, name)), - - // Mouse drag-and-drop (mouse-panel-drag; design D2/D4). - Command::DragBegin { side, name, op } => { - let items = drag_selection_sources(&state, side, &name); - if !items.is_empty() { - let source_dir = state.panel(side).cwd.clone(); - state.drag = Some(DragState { source: side, source_dir, items, op, target: None }); - } - } - Command::DragOver { op, target } => { - // Both borrows below are immutable — computed fully before - // the mutable one that follows — so there's no conflict with - // `state.drag.as_mut()` afterwards. - let valid_target = - state.drag.as_ref().and_then(|drag| target.as_ref().filter(|t| valid_drop_target(&state, drag, t)).cloned()); - if let Some(drag) = state.drag.as_mut() { - drag.op = op; - drag.target = valid_target; - } - } - Command::DragDrop { op } => { - if let Some(mut drag) = state.drag.take() { - drag.op = op; - // "Robust against listing changes": the source panel - // must still show the directory the items were captured - // from, and the target must still resolve to a live - // directory (mouse-drag "Robust against listing - // changes"). - let source_ok = state.panel(drag.source).cwd == drag.source_dir; - let resolved = if source_ok { - drag.target.as_ref().filter(|t| valid_drop_target(&state, &drag, t)).and_then(|t| drop_target_path(&state, t)) - } else { - None - }; - if let Some(target_path) = resolved { - let prefill = target_path.display().to_string(); - enter_file_op_setup_for_sources( - &mut state, - drag.op, - drag.items, - drag.source, - prefill, - Some(DropButtons { focused: drag.op }), - ); - } - // Otherwise: invalid/cancelled — the drag is already - // taken (cleared), and nothing else happens (mouse-drag - // "Release on an invalid spot"). - } - } - Command::DragCancel => state.drag = None, - - Command::Tick(_) => {} - Command::ConfirmQuit | Command::CancelQuit | Command::Resize(..) | Command::DialogButtonClick(_) => unreachable!("handled above"), - _ => {} - }, + UiPhase::Panels => return handle_panels(state, cmd, effects), UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_) | UiPhase::Viewer(_) | UiPhase::Editor(_) => { unreachable!("handled above") } @@ -1599,2109 +962,5 @@ fn update_impl(mut state: State, cmd: Command) -> (State, Vec) { (state, effects) } -// --------------------------------------------------------------------- -// Command line -// --------------------------------------------------------------------- - -/// Walk `history` by `delta` (-1 = older, +1 = newer) into the buffer. -/// -/// Stepping past the newest entry stops recalling but deliberately leaves -/// the buffer as it is: backspacing the buffer to empty, not Down, is the -/// documented way to release Up/Down back to the panel cursor (Esc requests -/// quit instead — command-line "Command history navigation"). -fn recall_history(state: &mut State, delta: isize) { - if state.history.is_empty() { - return; - } - let last = state.history.len() - 1; - let next = match (state.history_cursor, delta) { - (None, d) if d < 0 => Some(last), - (None, _) => None, - (Some(i), d) if d < 0 => Some(i.saturating_sub(1)), - (Some(i), _) if i < last => Some(i + 1), - (Some(_), _) => None, - }; - match next { - Some(i) => { - state.command_line = state.history[i].clone(); - state.history_cursor = Some(i); - } - None => state.history_cursor = None, - } -} - -/// Ctrl+Enter / Ctrl+]: append the cursor entry's name or full path, -/// space-separating it from whatever is already typed. -fn paste_cursor_entry(state: &mut State, full_path: bool) { - let panel = state.active_panel(); - let Some(entry) = panel.selected() else { return }; - let text = if full_path { - panel.cwd.join(&entry.name).display().to_string() - } else { - entry.name.to_string_lossy().into_owned() - }; - if !state.command_line.is_empty() && !state.command_line.ends_with(' ') { - state.command_line.push(' '); - } - state.command_line.push_str(&text); - state.history_cursor = None; -} - -/// Run the typed line: only the three built-in verbs (`cd`, `del`, -/// `rmdir`) are recognized (command-line "Command-line builtin -/// whitelist") — anything else is rejected without spawning anything. The -/// file-action menu's Run entry and the F2 user menu build their own -/// `Effect::RunShellCommand` independently and are unaffected by this -/// dispatch (design.md "`Effect::RunShellCommand` and `shell:: -/// build_command` are untouched"). -fn run_command_line(state: &mut State) -> Vec { - let text = state.command_line.trim().to_string(); - if text.is_empty() { - return vec![]; - } - state.command_line.clear(); - state.history_cursor = None; - - // `cd` cannot work through the shell — each command runs in a fresh - // child, so its working directory dies with it. NC navigates the panel - // instead, which is also how a UNC path is entered by hand. - if let Some(target) = parse_cd(&text) { - return dispatch_cd(state, &text, &target); - } - if let Some(target) = parse_del(&text) { - return dispatch_delete_builtin(state, &target, false); - } - if let Some(target) = parse_rmdir(&text) { - return dispatch_delete_builtin(state, &target, true); - } - - // Unrecognized: reject outright — no process spawns, and (matching - // `cd`'s own rejection below) nothing is added to command history - // (command-line "An unrecognized command is rejected without spawning - // anything"). - let side = state.active; - let word = text.split_whitespace().next().unwrap_or(text.as_str()); - state.panel_mut(side).last_error = Some(format!("'{word}' is not a recognized command")); - vec![] -} - -/// `cd `: navigate the active panel to `target` resolved against its -/// cwd, but only after confirming the resolved path exists and is a -/// directory — a nonexistent or non-directory target is rejected outright, -/// leaving `cwd` untouched and starting no listing (command-line "cd -/// navigates the active panel or rejects a nonexistent target"; this is -/// also what fixes the panel switching into a nonexistent directory). -fn dispatch_cd(state: &mut State, text: &str, target: &str) -> Vec { - let side = state.active; - let Some(path) = resolve_cd_target(&state.panel(side).cwd, target) else { - // ".." at a drive root, the only case `resolve_cd_target` itself - // rejects — there is nowhere to navigate to. - state.panel_mut(side).last_error = Some(format!("{target} not found")); - return vec![]; - }; - match probe_command_line_target(state, side, &path) { - Some((true, _)) => { - config::push_history(&mut state.history, text); - let mut effects = - vec![Effect::PersistHistory(config::HistoryFile { commands: state.history.clone(), directories: state.dir_history.clone() })]; - effects.extend(begin_listing(state, side, path)); - effects - } - Some((false, _)) => { - state.panel_mut(side).last_error = Some(format!("{} is not a directory", path.display())); - vec![] - } - None => { - state.panel_mut(side).last_error = Some(format!("{} not found", path.display())); - vec![] - } - } -} - -/// `del ` (`want_dir == false`) / `rmdir ` -/// (`want_dir == true`): resolve `target` against the active panel's cwd -/// and, for an existing target of the matching type, open the same F8 -/// delete-confirmation dialog `enter_delete_confirm` uses — never deleting -/// directly. `.` and `..` are never valid targets and are rejected before -/// any lookup, so the dialog can never offer the panel's own or its parent -/// directory. A missing target or a type mismatch (`del` on a directory, -/// `rmdir` on a file) is rejected: no dialog opens (command-line "del and -/// rmdir route into the existing delete-confirmation flow"). -fn dispatch_delete_builtin(state: &mut State, target: &str, want_dir: bool) -> Vec { - let side = state.active; - // Reject `.`/`..` up front: `components()` normalizes a trailing - // separator away (`..\` yields just `ParentDir`), so one check covers - // every spelling, while a multi-component target like `..\sibling` is - // untouched — its first component is not the whole path. Neither name - // ever denotes a deletable entry, so no listed-entry lookup or - // filesystem check is spent on them. - let mut components = Path::new(target).components(); - if matches!(components.next(), Some(Component::CurDir | Component::ParentDir)) && components.next().is_none() { - let verb = if want_dir { "rmdir" } else { "del" }; - state.panel_mut(side).last_error = Some(format!("{verb}: invalid target {target}")); - return vec![]; - } - let Some(path) = resolve_cd_target(&state.panel(side).cwd, target) else { - state.panel_mut(side).last_error = Some(format!("{target} not found")); - return vec![]; - }; - match probe_command_line_target(state, side, &path) { - Some((is_dir, original_name)) if is_dir == want_dir => { - enter_delete_confirm_for_sources(state, vec![SourceItem { original_name, path, is_dir }]); - vec![] - } - Some((true, _)) => { - state.panel_mut(side).last_error = Some(format!("{} is a directory", path.display())); - vec![] - } - Some((false, _)) => { - state.panel_mut(side).last_error = Some(format!("{} is not a directory", path.display())); - vec![] - } - None => { - state.panel_mut(side).last_error = Some(format!("{} not found", path.display())); - vec![] - } - } -} - -/// Whether `path` exists and, if so, whether it's a directory, plus its -/// on-disk name — preferring the active panel's already-listed entries (no -/// I/O, and the exact on-disk-cased name) when `path` names a currently -/// visible direct child of the panel's directory, and otherwise falling -/// back to a single synchronous filesystem check. `None` means the target -/// doesn't exist. Shared by `cd`'s existence check and `del`/`rmdir`'s -/// target resolution (design.md decisions on both). -fn probe_command_line_target(state: &State, side: PanelSide, path: &Path) -> Option<(bool, OsString)> { - let panel = state.panel(side); - if let (Some(name), Some(parent)) = (path.file_name(), path.parent()) { - if parent == panel.cwd.as_path() { - if let Some(entry) = panel.entries.iter().find(|e| e.name == name) { - return Some((entry.is_dir_like(), entry.name.clone())); - } - } - } - let metadata = std::fs::metadata(path).ok()?; - let name = path.file_name().map(OsString::from).unwrap_or_else(|| OsString::from(path.display().to_string())); - Some((metadata.is_dir(), name)) -} - -/// The target of a `cd ` line, or `None` if this isn't a `cd`. -pub fn parse_cd(text: &str) -> Option { - parse_builtin_arg(text, "cd") -} - -/// The target of a `del ` line, or `None` if this isn't a `del`. -pub fn parse_del(text: &str) -> Option { - parse_builtin_arg(text, "del") -} - -/// The target of a `rmdir ` line, or `None` if this isn't a -/// `rmdir`. -pub fn parse_rmdir(text: &str) -> Option { - parse_builtin_arg(text, "rmdir") -} - -/// Strip a case-insensitive `" "` prefix (`cd`/`CD`/`Cd`/..., -/// matching classic NC/cmd usage — command-line "Builtin verbs are -/// case-insensitive") and return the trimmed, unquoted argument, or `None` -/// if `text` isn't `verb` or carries no argument. -fn parse_builtin_arg(text: &str, verb: &str) -> Option { - let trimmed = text.trim(); - // `get` (rather than `split_at`) never panics on a non-ASCII `trimmed` - // whose byte offset `verb.len()` doesn't land on a char boundary — it - // just reports no match, which is the correct outcome either way. - let head = trimmed.get(..verb.len())?; - if !head.eq_ignore_ascii_case(verb) { - return None; - } - let rest = &trimmed[verb.len()..]; - if !rest.starts_with(char::is_whitespace) { - return None; - } - let target = rest.trim().trim_matches('"'); - if target.is_empty() { - None - } else { - Some(target.to_string()) - } -} - -/// Resolve a `cd` target against `cwd`. Absolute paths, UNC paths, and bare -/// drive letters (`D:`) are taken as-is; anything else is relative. -pub fn resolve_cd_target(cwd: &Path, target: &str) -> Option { - if target == ".." { - return crate::panel::parent_path(cwd); - } - if target == "." { - return Some(cwd.to_path_buf()); - } - let path = Path::new(target); - if drives::is_unc_path(path) || path.is_absolute() { - return Some(path.to_path_buf()); - } - // `D:` on its own means that drive's root, not a relative path. - let mut chars = target.chars(); - if let (Some(letter), Some(':'), None) = (chars.next(), chars.next(), chars.next()) { - if letter.is_ascii_alphabetic() { - return Some(drives::drive_root(letter)); - } - } - Some(cwd.join(target)) -} - -/// Move the cursor to the first entry matching the type-ahead `pattern` -/// (`quicksearch::type_ahead_match`). A pattern that matches nothing leaves -/// the cursor where it is (type-ahead-jump "Alt+letter with no matching -/// entry", "Extended pattern no longer matches"). Input routing normally -/// keeps type-ahead and the Ctrl+P quick filter mutually exclusive, but if -/// both are ever active at once the jump must still only land within -/// `visible_indices()` — otherwise the cursor could land on an entry the -/// active filter hides (quick-filter "Navigation is restricted to matching -/// entries"). -fn jump_to_prefix(state: &mut State, pattern: &str) { - let side = state.active; - let panel = state.panel(side); - let found = if panel.quick_filter.is_some() { - let visible = panel.visible_indices(); - let visible_entries: Vec = visible.iter().map(|&i| panel.entries[i].clone()).collect(); - crate::quicksearch::type_ahead_match(&visible_entries, pattern).map(|pos| visible[pos]) - } else { - crate::quicksearch::type_ahead_match(&panel.entries, pattern) - }; - if let Some(index) = found { - let panel = state.panel_mut(side); - panel.cursor = index; - panel.cursor_user_moved = true; - } - reconcile_panel_viewport(state, side); -} - -// --------------------------------------------------------------------- -// Adjustable panel split (panel-split) -// --------------------------------------------------------------------- - -/// Ctrl+Left/Ctrl+Right: move the divider `delta_cols` columns (negative = -/// left, positive = right) via `panel_split::adjust_percent`, updating and -/// persisting `state.split_percent` only when the adjustment doesn't -/// violate either panel's minimum width — an adjustment at the limit is a -/// no-op, per panel-split "Adjustment at the limit is a no-op". -fn adjust_split(state: &mut State, delta_cols: i32) -> Vec { - match panel_split::adjust_percent(state.split_percent, delta_cols, state.term_size.0) { - Some(new_percent) => { - state.split_percent = new_percent; - vec![Effect::PersistPanelSplit(new_percent)] - } - None => vec![], - } -} - -// --------------------------------------------------------------------- -// Info mode -// --------------------------------------------------------------------- - -fn toggle_info_mode(state: &mut State, side: PanelSide) -> Vec { - let entering_info = state.panel(side).display_mode == DisplayMode::Full; - if entering_info { - let request = state.next_request_id(); - let panel = state.panel_mut(side); - panel.display_mode = DisplayMode::Info; - // Every value starts pending; the worker fills them in place. - panel.info = InfoValues::default(); - panel.info_request = Some(request); - // Same reasoning as `SetDisplayMode`/`EnterTreeMode`: Ctrl+L is a - // second path into Info mode, and a quick filter narrowing the - // prior Full-mode list must not linger invisibly here either - // (quick-filter "Substring narrowing as the pattern is typed"). - panel.clear_quick_filter(); - vec![Effect::QueryInfo { panel: side, path: panel.cwd.clone(), request }] - } else { - let panel = state.panel_mut(side); - panel.display_mode = DisplayMode::Full; - panel.info_request = None; - vec![] - } -} - -// --------------------------------------------------------------------- -// F3 viewer (M4) -// --------------------------------------------------------------------- - -/// F3: open the viewer on the file under the active panel's cursor. A no-op -/// on a directory (including `..`) or an empty panel; opening itself is an -/// `Effect` because it touches the filesystem, which `update` never does -/// directly (viewer: Instant open). -fn handle_request_viewer(state: &mut State) -> Vec { - let side = state.active; - let Some(entry) = state.panel(side).selected() else { return vec![] }; - if entry.is_dir_like() { - return vec![]; - } - let path = state.panel(side).cwd.join(&entry.name); - vec![Effect::OpenViewer { path }] -} - -/// Drive every command while the viewer is open (gated in [`update`] by -/// `UiPhase::Viewer`). Byte-level work (backward scan, search) is never -/// performed here — it requires file I/O that `update` must stay free of — -/// so `ViewerSetTop` carries an already-computed offset and -/// `ViewerSearchConfirm` only kicks off `Effect::RunViewerSearch`, whose -/// result later re-enters as `Command::ViewerSearchResult` (design D1). -fn handle_viewer(state: &mut State, cmd: Command) -> Vec { - let mut effects = Vec::new(); - let UiPhase::Viewer(mut viewer) = std::mem::replace(&mut state.phase, UiPhase::Panels) else { - unreachable!("handle_viewer only called when phase is Viewer"); - }; - match cmd { - Command::ViewerClose => return effects, - Command::ViewerToggleMode => viewer.toggle_mode(), - Command::ViewerToggleWrap => viewer.toggle_wrap(), - Command::ViewerSetTop(offset) => viewer.set_top_offset(offset), - Command::ViewerSetHScroll(col) => viewer.h_scroll = col, - Command::ViewerSearchStart => viewer.search_input = Some(String::new()), - Command::ViewerSearchChar(c) => { - if let Some(input) = &mut viewer.search_input { - input.push(c); - } - } - Command::ViewerSearchBackspace => { - if let Some(input) = &mut viewer.search_input { - input.pop(); - } - } - Command::ViewerSearchCancel => viewer.search_input = None, - Command::ViewerSearchConfirm => { - if let Some(pattern) = viewer.search_input.take().filter(|p| !p.is_empty()) { - let pattern_bytes = pattern.into_bytes(); - viewer.search_pattern = Some(pattern_bytes.clone()); - let path = viewer.path.clone(); - let start_offset = viewer.top_offset; - // A fresh request id per search, mirroring - // `PanelState::info_request` — lets a reply be matched - // against the search that's still outstanding, so an - // out-of-order or superseded-session reply is dropped - // rather than applied (viewer: F7 streaming search - // staleness). - let request = state.next_request_id(); - viewer.search_request = Some(request); - state.phase = UiPhase::Viewer(viewer); - effects.push(Effect::RunViewerSearch { path, start_offset, pattern: pattern_bytes, request }); - return effects; - } - } - // Only apply a reply whose id matches this session's outstanding - // search. A mismatch means either a stale reply from a search this - // same session has since superseded, or one from a viewer session - // that has since closed and been reopened (a fresh `ViewerState` - // starts with `search_request: None`, which can never equal a real - // request id) — either way it is silently dropped instead of - // jumping the user to a bogus offset with a phantom match - // highlight. - Command::ViewerSearchResult { offset: Some(offset), match_range, request } if viewer.search_request == Some(request) => { - viewer.set_top_offset(offset); - viewer.last_match = match_range; - } - _ => {} - } - state.phase = UiPhase::Viewer(viewer); - effects -} - -// --------------------------------------------------------------------- -// F4 external editor (M4) -// --------------------------------------------------------------------- - -/// F4 from a panel: resolve the cursor entry against the configured -/// `editor =` command and either dispatch the spawn effect or surface why -/// not (external-editor: F4 launches the editor, Config-driven external -/// editor command). -fn handle_request_external_editor(state: &mut State) -> Vec { - let side = state.active; - let panel = state.panel(side); - let Some(entry) = panel.selected() else { return vec![] }; - let is_dir = entry.is_dir_like(); - let name = entry.name.clone(); - let cwd = panel.cwd.clone(); - match external_editor::resolve(state.editor.as_deref(), &cwd, &name, is_dir) { - Ok(invocation) => { - // Mirrors the `Command::ViewerOpened` clear: a prior failed F3/F4 - // attempt may have left `last_error` set, and successfully - // dispatching the editor spawn has clearly moved past it — the - // mini-status line must not keep showing the stale message while - // the editor runs (§7 error policy). The eventual successful - // return also clears it again via `Command::RereadPanel` -> - // `begin_new_listing`, but that round trip only happens once the - // editor process exits, so this clears it immediately rather - // than leaving the stale message up for the whole edit session. - state.panel_mut(side).last_error = None; - vec![Effect::RunExternalEditor(invocation, side)] - } - Err(external_editor::TargetError::Unconfigured) => { - state.panel_mut(side).last_error = Some(external_editor::NO_EDITOR_CONFIGURED_MESSAGE.to_string()); - vec![] - } - // The directory case is silently ignored, matching how F5/F6/F8 - // are no-ops with nothing eligible selected. - Err(external_editor::TargetError::IsDirectory) => vec![], - } -} - -// --------------------------------------------------------------------- -// F4 built-in editor (M5) -// --------------------------------------------------------------------- - -/// F4 from a panel: the external editor takes precedence when configured -/// (builtin-editor "External editor takes precedence") — reusing -/// `handle_request_external_editor`'s own entry/directory resolution rather -/// than duplicating it. Otherwise, on a file (not `..`, not a directory), -/// kick off the size-gated open; the size cap itself is enforced by -/// `EditorState::open` on the TUI side once `Effect::OpenEditor` runs -/// (builtin-editor "Editor invocation and size cap"). -fn handle_request_editor(state: &mut State) -> Vec { - if state.editor.is_some() { - return handle_request_external_editor(state); - } - let side = state.active; - let panel = state.panel(side); - let Some(entry) = panel.selected() else { return vec![] }; - if entry.is_dir_like() { - return vec![]; - } - let path = panel.cwd.join(&entry.name); - state.panel_mut(side).last_error = None; - vec![Effect::OpenEditor { path }] -} - -/// The editor body's visible row count for a given terminal height: the -/// header and F-key bar each reserve one row, exactly like the viewer's -/// `body_rows` (kept as a one-line duplicate here rather than a cross-crate -/// dependency, since `core::update` needs it for `ensure_caret_visible` and -/// cannot depend on `filecommand-tui`). -fn editor_viewport(term_size: (u16, u16)) -> (usize, usize) { - (term_size.1.saturating_sub(2).max(1) as usize, term_size.0.max(1) as usize) -} - -/// A panel's body entry-row count for a given terminal height, display mode, -/// and tab count — the same shape as [`editor_viewport`], kept as a -/// duplicate of `filecommand-tui`'s `layout::compute` + -/// `views::panel::render` geometry rather than a cross-crate dependency, so -/// `core::update` can call [`PanelState::ensure_cursor_visible`] without -/// depending on `filecommand-tui` (design D2). Only the terminal height -/// matters here — the command-line and F-key bar rows are shared by both -/// panels regardless of the panel split, and `split_percent` only ever -/// changes each panel's *width*, which entry-row counting doesn't depend on -/// (Brief mode's column count, a width-derived quantity, is the -/// `additional-panel-modes` follow-up group's concern, not this one's). -/// -/// Mirrors, in order: `layout::compute`'s `panels_h` (terminal height minus -/// the one-row command line and one-row F-key bar), `views::panel::render`'s -/// `has_strip`/`reserved` (a tab strip, shown at 2+ tabs, costs a row beyond -/// the top+bottom border), and its `rows_h` per display mode — Full and Tree -/// both reserve a header row on top of the body, Brief does not. -/// -/// `pub` so `filecommand-tui` can also derive the PgUp/PgDn paging step from -/// it directly, in place of the layout-level `entries_visible` (which -/// over-counts by one when the tab strip is visible and mismatches Brief) — -/// one source of truth for both the viewport clamp and the paging step -/// (design D2's risk note). -pub fn panel_viewport_rows(term_size: (u16, u16), display_mode: DisplayMode, tab_count: usize) -> usize { - let panels_h = term_size.1.saturating_sub(2); - let has_strip = panels_h >= 4 && tab_count >= 2; - let reserved: u16 = if has_strip { 3 } else { 2 }; - let body_h = panels_h.saturating_sub(reserved); - let rows = match display_mode { - DisplayMode::Full | DisplayMode::Tree => body_h.saturating_sub(1), - _ => body_h, - }; - rows.max(1) as usize -} - -/// `side`'s panel interior width (the panel's rendered width, border -/// columns subtracted) for the current terminal width and split — mirrors -/// `layout::compute`'s `left_w`/`right_w` (via -/// `panel_split::effective_left_width`) and `views::panel::render`'s -/// `w.saturating_sub(2)`, kept as a duplicate here for the same reason -/// [`panel_viewport_rows`] is: `core::update` needs it (for Brief mode's -/// column count) without a cross-crate dependency on `filecommand-tui`. -fn panel_interior_width(term_size: (u16, u16), split_percent: u16, side: PanelSide) -> u16 { - let left_w = panel_split::effective_left_width(split_percent, term_size.0); - let panel_w = match side { - PanelSide::Left => left_w, - PanelSide::Right => term_size.0.saturating_sub(left_w), - }; - panel_w.saturating_sub(2) -} - -/// Brief mode's column count for a given interior width: `max(1, -/// floor(interior_w / 12))`, byte-identical to `filecommand-tui`'s -/// `brief_column_widths`/`render_brief_body` formula (design D4; -/// additional-panel-modes "Brief mode column scrolling"). -fn brief_column_count(interior_w: u16) -> usize { - ((interior_w / 12).max(1)) as usize -} - -/// Re-clamp `side`'s scroll offset against its current viewport — the one -/// call site every cursor-moving or list-mutating path funnels through -/// (design D6), so the renderer never has to reason about scrolling itself. -/// Dispatches to the mode-appropriate clamp: Brief's column-window -/// reconciliation, Tree's over its own `TreeState::scroll_offset` (a no-op -/// if Tree mode was already left before this ran), or Full/Info/QuickView's -/// line-wise `PanelState::ensure_cursor_visible`. -fn reconcile_panel_viewport(state: &mut State, side: PanelSide) { - let panel = state.panel(side); - let display_mode = panel.display_mode; - let rows = panel_viewport_rows(state.term_size, display_mode, panel.tab_count()); - match display_mode { - DisplayMode::Brief => { - let interior_w = panel_interior_width(state.term_size, state.split_percent, side); - let cols = brief_column_count(interior_w); - state.panel_mut(side).ensure_cursor_visible_brief(rows, cols); - } - DisplayMode::Tree => { - if let Some(tree) = state.panel_mut(side).tree.as_mut() { - tree.ensure_cursor_visible(rows); - } - } - _ => { - state.panel_mut(side).ensure_cursor_visible(rows); - } - } -} - -/// Drive every command while the editor is open (gated in [`update`] by -/// `UiPhase::Editor`). Saving is the one operation here that needs I/O -/// (`EditorState::save` writes to disk), so it is dispatched as -/// `Effect::SaveEditor` and applied only once `Command::EditorSaved`/ -/// `EditorSaveFailed` reply — mirroring how `handle_viewer` defers the -/// viewer's own I/O to effects and re-enters through their replies (design -/// D1/D2). -fn handle_editor(state: &mut State, cmd: Command) -> Vec { - let mut effects = Vec::new(); - let UiPhase::Editor(mut editor) = std::mem::replace(&mut state.phase, UiPhase::Panels) else { - unreachable!("handle_editor only called when phase is Editor"); - }; - let (rows, cols) = editor_viewport(state.term_size); - match cmd { - Command::EditorChar(c) => editor.type_char(c), - Command::EditorNewline => editor.insert_newline(), - Command::EditorBackspace => editor.backspace(), - Command::EditorMove(m) => match m { - EditorMove::Left => editor.move_left(), - EditorMove::Right => editor.move_right(), - EditorMove::Up => editor.move_up(), - EditorMove::Down => editor.move_down(), - EditorMove::Home => editor.move_home(), - EditorMove::End => editor.move_end(), - EditorMove::PageUp(rows) => editor.move_page_up(rows), - EditorMove::PageDown(rows) => editor.move_page_down(rows), - }, - Command::EditorToggleMode => editor.toggle_mode(), - Command::EditorMark => editor.start_mark(), - Command::EditorCut => editor.cut_selection(), - Command::EditorCopy => editor.copy_selection(), - Command::EditorPaste => editor.paste(), - Command::EditorUndo => editor.undo(), - - Command::EditorSearchStart => editor.search_prompt = Some(String::new()), - Command::EditorSearchChar(c) => { - if let Some(p) = &mut editor.search_prompt { - p.push(c); - } - } - Command::EditorSearchBackspace => { - if let Some(p) = &mut editor.search_prompt { - p.pop(); - } - } - Command::EditorSearchCancel => editor.search_prompt = None, - Command::EditorSearchConfirm => { - if let Some(pattern) = editor.search_prompt.take() { - if !pattern.is_empty() { - editor.find_next(&pattern); - } - } - } - - Command::EditorReplaceStart => editor.replace_prompt = Some(ReplacePrompt::Pattern(String::new())), - Command::EditorReplaceChar(c) => { - if let Some(prompt) = &mut editor.replace_prompt { - match prompt { - ReplacePrompt::Pattern(p) => p.push(c), - ReplacePrompt::Replacement { replacement, .. } => replacement.push(c), - } - } - } - Command::EditorReplaceBackspace => { - if let Some(prompt) = &mut editor.replace_prompt { - match prompt { - ReplacePrompt::Pattern(p) => { - p.pop(); - } - ReplacePrompt::Replacement { replacement, .. } => { - replacement.pop(); - } - } - } - } - Command::EditorReplaceCancel => editor.replace_prompt = None, - // Enter: the pattern stage advances to the replacement stage (an - // empty pattern instead cancels the prompt outright — nothing to - // search for); the replacement stage performs the replacement - // (builtin-editor "Replace substitutes a match"). - Command::EditorReplaceConfirm => { - if let Some(prompt) = editor.replace_prompt.take() { - match prompt { - ReplacePrompt::Pattern(pattern) if !pattern.is_empty() => { - editor.replace_prompt = Some(ReplacePrompt::Replacement { pattern, replacement: String::new() }); - } - ReplacePrompt::Pattern(_) => {} - ReplacePrompt::Replacement { pattern, replacement } => { - editor.replace_first(&pattern, &replacement); - } - } - } - } - - Command::EditorSave => { - effects.push(Effect::SaveEditor { editor: Box::new(editor.clone()), then_quit: false }); - } - Command::EditorSaved { editor: saved, then_quit } => { - if then_quit { - // Phase was already reset to `Panels` by the `mem::replace` - // above; leaving it as-is is the exit. - return effects; - } - editor = *saved; - editor.quit_confirm = false; - } - Command::EditorSaveFailed { message } => { - editor.save_error = Some(message); - editor.quit_confirm = false; - } - - Command::EditorRequestQuit => { - if editor.is_modified() { - editor.quit_confirm = true; - } else { - // Unmodified: exits directly (builtin-editor "Quitting an - // unmodified buffer exits directly") — phase stays `Panels`. - return effects; - } - } - Command::EditorConfirmQuitSave => { - effects.push(Effect::SaveEditor { editor: Box::new(editor.clone()), then_quit: true }); - } - Command::EditorConfirmQuitDiscard => return effects, - Command::EditorCancelQuit => editor.quit_confirm = false, - - _ => {} - } - editor.ensure_caret_visible(rows, cols); - state.phase = UiPhase::Editor(editor); - effects -} - -// --------------------------------------------------------------------- -// Drive select -// --------------------------------------------------------------------- - -fn is_drive_select_command(cmd: &Command) -> bool { - matches!(cmd, Command::DriveSelectMove(_) | Command::DriveSelectConfirm | Command::DriveSelectCancel) -} - -fn handle_drive_select(state: &mut State, cmd: Command) -> Vec { - match cmd { - Command::DriveSelectMove(delta) => { - if let Some(dialog) = &mut state.drive_select { - dialog.move_selection(delta); - } - vec![] - } - Command::DriveSelectCancel => { - // The target panel keeps its current directory. - state.drive_select = None; - vec![] - } - Command::DriveSelectConfirm => { - let Some(dialog) = state.drive_select.take() else { return vec![] }; - let Some(letter) = dialog.selected_letter() else { return vec![] }; - // An unavailable drive is not special-cased here: the listing - // read fails on the worker thread and surfaces through - // `ListingFailed` as the panel's inline error state. - let path = drives::drive_root(letter); - begin_listing(state, dialog.target, path) - } - _ => vec![], - } -} - -// --------------------------------------------------------------------- -// F9 menu -// --------------------------------------------------------------------- - -fn is_menu_command(cmd: &Command) -> bool { - matches!( - cmd, - Command::MenuOpen - | Command::MenuClose - | Command::MenuCollapse - | Command::MenuSelectPrev - | Command::MenuSelectNext - | Command::MenuPrevMenu - | Command::MenuNextMenu - | Command::MenuHotkey(_) - | Command::MenuActivate - ) -} - -/// Drive the menu state machine. Returns the command an activated item -/// stands for, which the caller re-enters `update` with. -fn handle_menu(state: &mut State, cmd: Command) -> Option { - let active_side = state.active; - let menu = state.menu.as_mut()?; - match cmd { - Command::MenuOpen | Command::MenuClose => state.menu = None, - Command::MenuCollapse => { - // Esc closes the pull-down first, leaving the bar open with its - // title still highlighted; a second Esc closes the bar. - if menu.pulldown_open { - menu.pulldown_open = false; - } else { - state.menu = None; - } - } - Command::MenuSelectPrev => menu.move_selection(-1), - Command::MenuSelectNext => menu.move_selection(1), - Command::MenuPrevMenu => { - let target = menu.active.prev(); - menu.go_to(target); - } - Command::MenuNextMenu => { - let target = menu.active.next(); - menu.go_to(target); - } - Command::MenuHotkey(c) => { - if let Some(id) = MenuId::from_hotkey(c) { - menu.go_to(id); - } - } - Command::MenuActivate => { - let item = menu.selected_item()?; - let side = menu.active.target_side(active_side); - let action = item.action; - // Activating an item closes the whole overlay, restoring the top - // row and the clock, before the action runs. - state.menu = None; - return menu_action_command(action, side); - } - _ => {} - } - None -} - -/// The command a menu item stands for. Disabled items never reach here, so -/// `Unimplemented` maps to nothing. -pub fn menu_action_command(action: MenuAction, side: PanelSide) -> Option { - match action { - MenuAction::ToggleInfoMode => Some(Command::ToggleInfoMode(side)), - MenuAction::SetDisplayMode(mode) => Some(Command::SetDisplayMode { side, mode }), - MenuAction::EnterTree => Some(Command::EnterTreeMode(side)), - MenuAction::SortBy(mode) => Some(Command::SetSortMode { side, mode }), - MenuAction::Reread => Some(Command::RereadPanel(side)), - MenuAction::DriveSelect => Some(Command::OpenDriveSelect(side)), - MenuAction::Copy => Some(Command::RequestCopy), - MenuAction::Move => Some(Command::RequestMove), - MenuAction::Mkdir => Some(Command::RequestMkdir), - MenuAction::Delete => Some(Command::RequestDelete), - MenuAction::ClipboardFiles => Some(Command::CopyToClipboard(ClipboardPayloadKind::Files)), - MenuAction::ClipboardPaths => Some(Command::CopyToClipboard(ClipboardPayloadKind::Paths)), - MenuAction::ClipboardNames => Some(Command::CopyToClipboard(ClipboardPayloadKind::Names)), - MenuAction::SelectGroup => Some(Command::GroupSelectAll), - MenuAction::DeselectGroup => Some(Command::GroupDeselectAll), - MenuAction::InvertSelection => Some(Command::InvertSelection), - MenuAction::PanelsOnOff => Some(Command::ShowScrollback), - MenuAction::FindFile => Some(Command::FindFileOpen), - MenuAction::FuzzyJump => Some(Command::FuzzyJumpOpen), - MenuAction::Quit => Some(Command::RequestQuit), - MenuAction::OpenThemes => Some(Command::ThemePickerOpen), - MenuAction::Unimplemented => None, - } -} - -// --------------------------------------------------------------------- -// Clipboard export (clipboard-export) -// --------------------------------------------------------------------- - -/// Ctrl+C/Ctrl+Ins, Ctrl+Shift+Ins, and the Files pull-down's clipboard -/// group: resolve the F5 scope and either request the write (leaving -/// feedback to `apply_clipboard_result` once `Effect::SetClipboard` -/// replies) or, when the scope is empty, report that directly — the parent -/// pseudo-entry is never included, since `active_selection_sources` already -/// excludes it (clipboard-export "Clipboard payloads and scope": "Parent -/// entry is never copied"). -fn handle_copy_to_clipboard(state: &mut State, kind: ClipboardPayloadKind) -> Vec { - let sources = active_selection_sources(state); - if sources.is_empty() { - set_clipboard_feedback(state, "Nothing to copy".to_string(), false); - return vec![]; - } - let items = sources.into_iter().map(|s| s.path).collect(); - vec![Effect::SetClipboard(ClipboardPayload { kind, items })] -} - -/// The file-action menu's `Send to clipboard`: always `Files`, scoped to -/// whatever `selection_or_single_source` resolved — the menu's single -/// target entry, or the whole selection set for a selection-scoped -/// invocation (design D3 of `file-action-menu`, D4 of `mouse-basics`, -/// mirrored here from `activate_file_action`'s existing Copy/Move/Delete -/// handling). A no-op if the target has already vanished from the panel. -fn handle_send_target_to_clipboard(sources: Vec) -> Vec { - if sources.is_empty() { - return vec![]; - } - let items = sources.into_iter().map(|s| s.path).collect(); - vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items })] -} - -/// Reply to `Effect::SetClipboard`: render the outcome into the active -/// panel's mini-status feedback (clipboard-export "Clipboard feedback", -/// "Non-Windows fallback"). -fn apply_clipboard_result(state: &mut State, payload: ClipboardPayload, fell_back_to_paths: bool, result: Result<(), String>) { - let (message, is_error) = match result { - Err(message) => (message, true), - Ok(()) => (clipboard_success_message(&payload, fell_back_to_paths), false), - }; - set_clipboard_feedback(state, message, is_error); -} - -/// The success feedback template for a completed `ClipboardPayload` -/// (clipboard-export "Clipboard feedback"). `Paths` gets a singular -/// variant naming the one path copied; `Files`/`Names` always use the -/// plural template, matching the literal message set the requirement -/// enumerates. A `Files` request downgraded to a `Paths` text write on a -/// non-Windows host reports that explicitly instead (clipboard-export -/// "Non-Windows fallback"). -fn clipboard_success_message(payload: &ClipboardPayload, fell_back_to_paths: bool) -> String { - if fell_back_to_paths { - return "Paths copied (file objects unsupported here)".to_string(); - } - let n = payload.items.len(); - match payload.kind { - ClipboardPayloadKind::Files => format!("{n} files copied to clipboard"), - ClipboardPayloadKind::Paths if n == 1 => format!("Path copied: {}", payload.items[0].display()), - ClipboardPayloadKind::Paths => format!("{n} paths copied"), - ClipboardPayloadKind::Names => format!("{n} names copied"), - } -} - -/// Show `message` in the active panel's mini-status until the next key -/// press or `CLIPBOARD_FEEDBACK_MS` elapses, whichever comes first -/// (clipboard-export "Clipboard feedback"). -fn set_clipboard_feedback(state: &mut State, message: String, is_error: bool) { - let side = state.active; - let expires_at_ms = state.clock_ms.saturating_add(CLIPBOARD_FEEDBACK_MS); - state.panel_mut(side).clipboard_feedback = Some(ClipboardFeedback { message, is_error, expires_at_ms }); -} - -// --------------------------------------------------------------------- -// File operations (M2) -// --------------------------------------------------------------------- - -/// Which selectable entry(ies) an F5/F6/F8 request applies to: the explicit -/// selection if non-empty, else the single entry under the cursor. -fn active_selection_sources(state: &State) -> Vec { - let panel = state.active_panel(); - if !panel.selected.is_empty() { - return panel - .entries - .iter() - .filter(|e| panel.selected.contains(&e.name)) - .map(|e| SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }) - .collect(); - } - match panel.selected() { - Some(e) if e.kind != EntryKind::ParentDir => { - vec![SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }] - } - _ => vec![], - } -} - -/// F5/F6/F7: enter the destination/name-input setup dialog. A no-op for -/// Copy/Move when there is nothing selected; Mkdir is always available. -fn enter_file_op_setup(state: &mut State, kind: JobKind) { - if kind == JobKind::Mkdir { - let side = state.active; - let source_dir = state.panel(side).cwd.clone(); - state.phase = - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources: vec![], source_dir, input: String::new(), buttons: None }); - return; - } - let sources = active_selection_sources(state); - if sources.is_empty() { - return; - } - let side = state.active; - let prefill = state.panel(side.toggle()).cwd.display().to_string(); - enter_file_op_setup_for_sources(state, kind, sources, side, prefill, None); -} - -/// Shared by `enter_file_op_setup` (F5/F6, selection- or cursor-scoped), the -/// file-action menu's Copy/Move (cursor-entry-only, D3), and `Command:: -/// DragDrop` (mouse-panel-drag) — the setup dialog itself doesn't care which -/// chose its `sources`. `source_side`/`prefill` are explicit rather than -/// derived from `state.active`/its opposite panel internally, so a caller -/// acting on a side other than the active panel (a drag begun on the -/// inactive panel, which a press never focuses before the drag completes) -/// gets the right source directory and the right prefill without the dialog -/// silently assuming the active panel (operation-dialogs design D3). -/// `buttons` is `None` for every keyboard-reached caller (byte-identical to -/// the pre-`mouse-panel-drag` dialog) and `Some` only from `DragDrop`, which -/// also passes the exact drop path as `prefill` rather than the opposite -/// panel's cwd (operation-dialogs "Drop-initiated destination dialog"). -fn enter_file_op_setup_for_sources( - state: &mut State, - kind: JobKind, - sources: Vec, - source_side: PanelSide, - prefill: String, - buttons: Option, -) { - let source_dir = state.panel(source_side).cwd.clone(); - state.phase = UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input: prefill, buttons }); -} - -/// F8: enter the delete-confirmation dialog. A no-op when there is nothing -/// selected. -fn enter_delete_confirm(state: &mut State) { - let sources = active_selection_sources(state); - if sources.is_empty() { - return; - } - enter_delete_confirm_for_sources(state, sources); -} - -/// Shared by `enter_delete_confirm` (F8) and the file-action menu's Delete -/// (cursor-entry-only, D3). -fn enter_delete_confirm_for_sources(state: &mut State, sources: Vec) { - let side = state.active; - let source_dir = state.panel(side).cwd.clone(); - let needs_second_confirm = sources.iter().any(|s| s.is_dir); - state.phase = UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once: false }); -} - -/// The file-action menu's Rename: enter the in-place rename input dialog, -/// pre-filled with the target's current name (file-action-menu "In-place -/// Rename": "pre-filled with the target entry's current name"). -fn enter_rename_input(state: &mut State, original_name: OsString, is_dir: bool) { - let side = state.active; - let source_dir = state.panel(side).cwd.clone(); - let input = original_name.to_string_lossy().into_owned(); - state.phase = UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); -} - -// --------------------------------------------------------------------- -// Enter-on-file action menu (file-action-menu) -// --------------------------------------------------------------------- - -fn is_file_action_menu_command(cmd: &Command) -> bool { - matches!( - cmd, - Command::FileActionMenuMove(_) - | Command::FileActionMenuConfirm - | Command::FileActionMenuCancel - | Command::FileActionMenuHotkey(_) - | Command::FileActionMenuItemClick(_) - ) -} - -/// Drive the Enter-on-file action menu (gated in [`update`] by -/// `state.file_action_menu`) — navigation, dismissal, and activation -/// (file-action-menu "Menu contents, ordering, and navigation"). -fn handle_file_action_menu(state: &mut State, cmd: Command) -> Vec { - if state.file_action_menu.is_none() { - return vec![]; - } - match cmd { - Command::FileActionMenuMove(delta) => { - state.file_action_menu.as_mut().unwrap().move_cursor(delta); - vec![] - } - Command::FileActionMenuCancel => { - state.file_action_menu = None; - vec![] - } - Command::FileActionMenuConfirm => { - let menu = state.file_action_menu.take().unwrap(); - let target_name = menu.target_name.clone(); - activate_file_action(state, menu.selected(), target_name, menu.selection_scoped) - } - Command::FileActionMenuItemClick(index) => { - let menu = state.file_action_menu.as_ref().unwrap(); - if index >= menu.entries.len() { - // Shouldn't normally happen given hit-testing (the hit map - // only ever records rects for real entries), but a stale - // hit map click — same reasoning as - // `click_entry_on_a_vanished_name_is_a_no_op` — is safer - // treated as a no-op than an out-of-bounds panic. - return vec![]; - } - state.file_action_menu.as_mut().unwrap().cursor = index; - let menu = state.file_action_menu.take().unwrap(); - let target_name = menu.target_name.clone(); - activate_file_action(state, menu.selected(), target_name, menu.selection_scoped) - } - Command::FileActionMenuHotkey(c) => { - let menu = state.file_action_menu.as_ref().unwrap(); - let action = menu.hotkey_action(c); - match action { - Some(action) => { - let target_name = menu.target_name.clone(); - let selection_scoped = menu.selection_scoped; - state.file_action_menu = None; - activate_file_action(state, action, target_name, selection_scoped) - } - None => vec![], - } - } - _ => vec![], - } -} - -/// Route an activated menu entry into its existing capability flow, applied -/// to `target_name` — the entry the menu was opened on, captured in -/// `FileActionMenuState` at open time and threaded through here rather than -/// re-read from the panel's live cursor, which can drift while the menu (a -/// modal overlay) is open: a background `ListingChunk`/`ListingComplete` is -/// applied unconditionally regardless of any open modal and resets the -/// cursor to row 0 whenever the panel hasn't seen a user-driven move since -/// the listing began (file-action-menu "Menu actions route to existing -/// flows"). `selection_scoped` — also captured at open time — widens -/// Copy/Move/Delete/Send to clipboard to the whole selection set when the -/// menu was opened on an already-selected entry (mouse-basics design D4; -/// file-action-menu "Directory targets and selection-scoped invocation"). -fn activate_file_action(state: &mut State, action: FileActionMenuEntry, target_name: OsString, selection_scoped: bool) -> Vec { - let side = state.active; - match action { - // Run: the same suspended-shell spawn path a typed command line - // uses, and the only way to reach it now that Enter no longer - // spawns an executable directly (command-line: "Enter on an - // executable opens the menu instead of spawning"). Always - // single-target — Run has no selection-scoped meaning. - FileActionMenuEntry::Run => { - let Some(_) = named_entry_source(state, side, &target_name) else { return vec![] }; - let name = target_name.to_string_lossy().into_owned(); - let cwd = state.panel(side).cwd.clone(); - // Quoted so a name with spaces reaches the shell as one token. - let text = format!("\"{name}\""); - vec![Effect::RunShellCommand(shell::build_command(state.shell.shell.as_deref(), &text, &cwd), side)] - } - // View/Edit: the exact F3/F4 core paths, unchanged. - FileActionMenuEntry::View => handle_request_viewer(state), - FileActionMenuEntry::Edit => handle_request_editor(state), - // Copy/Move/Delete: the existing F5/F6/F8 setup dialogs, scoped to - // the whole selection when `selection_scoped`, else to the menu's - // target entry only (design D3/D4) — `enter_file_op_setup_for_sources`/ - // `enter_delete_confirm_for_sources` already title their dialog with - // `sources.len()`, so a selection-scoped invocation gets the count - // for free. - FileActionMenuEntry::Copy => { - let sources = selection_or_single_source(state, side, &target_name, selection_scoped); - if !sources.is_empty() { - let prefill = state.panel(side.toggle()).cwd.display().to_string(); - enter_file_op_setup_for_sources(state, JobKind::Copy, sources, side, prefill, None); - } - vec![] - } - FileActionMenuEntry::Move => { - let sources = selection_or_single_source(state, side, &target_name, selection_scoped); - if !sources.is_empty() { - let prefill = state.panel(side.toggle()).cwd.display().to_string(); - enter_file_op_setup_for_sources(state, JobKind::Move, sources, side, prefill, None); - } - vec![] - } - FileActionMenuEntry::Delete => { - let sources = selection_or_single_source(state, side, &target_name, selection_scoped); - if !sources.is_empty() { - enter_delete_confirm_for_sources(state, sources); - } - vec![] - } - // Rename: new in-place variant (file-action-menu "In-place Rename"). - // Always single-target — an in-place rename of several entries at - // once has no meaning, and the requirement only lists - // Copy/Move/Delete/Send to clipboard as selection-scoped. - FileActionMenuEntry::Rename => { - if let Some(source) = named_entry_source(state, side, &target_name) { - enter_rename_input(state, source.original_name, source.is_dir); - } - vec![] - } - // Send to clipboard: the `clipboard-export` Files action, scoped the - // same way Copy/Move/Delete are above — never mutates the filesystem - // (file-action-menu "Menu actions route to existing flows", "No - // mutation without an intervening dialog"). - FileActionMenuEntry::SendToClipboard => { - let sources = selection_or_single_source(state, side, &target_name, selection_scoped); - handle_send_target_to_clipboard(sources) - } - } -} - -/// Copy/Move/Delete/Send to clipboard's shared scope resolution: the whole -/// selection set via the same `active_selection_sources` F5/F6/Ctrl+C -/// already use when `selection_scoped`, else `target_name` alone — falling -/// back to the single target if the selection somehow emptied out while the -/// menu was open (mouse-basics design D4; file-action-menu "Directory -/// targets and selection-scoped invocation"). -fn selection_or_single_source(state: &State, side: PanelSide, target_name: &OsStr, selection_scoped: bool) -> Vec { - if selection_scoped { - let sources = active_selection_sources(state); - if !sources.is_empty() { - return sources; - } - } - named_entry_source(state, side, target_name).into_iter().collect() -} - -/// The panel entry on `side` named `name` as a single-item `SourceItem`, -/// ignoring the panel's cursor position and selection entirely — used only -/// by the file-action menu, which targets the entry it was opened on -/// (captured by name in `FileActionMenuState`) regardless of any cursor -/// drift from a background listing refresh or any multi-entry selection -/// (design D3; file-action-menu "Enter on a file opens the action menu": -/// "SHALL NOT consume or alter the multi-entry selection"). Returns `None` -/// if the named entry is no longer present (e.g. deleted or renamed away by -/// something else while the menu was open) or is a directory-like pseudo -/// entry, matching the existing not-found-is-a-no-op behavior of every -/// other dialog-open path. -fn named_entry_source(state: &State, side: PanelSide, name: &OsStr) -> Option { - let panel = state.panel(side); - let entry = panel.entries.iter().find(|e| e.name == name)?; - if entry.kind == EntryKind::ParentDir { - return None; - } - Some(SourceItem { original_name: entry.name.clone(), path: panel.cwd.join(&entry.name), is_dir: entry.is_dir_like() }) -} - -// --------------------------------------------------------------------- -// Mouse drag-and-drop (mouse-panel-drag) -// --------------------------------------------------------------------- - -/// The items a drag beginning on `side`'s entry named `name` freezes at -/// `DragBegin` (mouse-drag "Drag lifecycle"; design D4): the panel's -/// selection set if `name` is a member of it, else `name` alone. Empty (and -/// so a no-op for the caller) if `name` no longer names a selectable entry — -/// `named_entry_source` already excludes the `..` pseudo-entry, matching -/// "the parent-directory pseudo-entry SHALL never be dragged". Unlike -/// `selection_or_single_source` (used by the file-action menu, always -/// scoped to `state.active`), this is keyed by the drag's own source `side`: -/// a press-drag never changes `state.active` before the drag completes, so -/// the source panel need not be the active one at all. -fn drag_selection_sources(state: &State, side: PanelSide, name: &OsStr) -> Vec { - let panel = state.panel(side); - if panel.selected.contains(name) { - panel - .entries - .iter() - .filter(|e| panel.selected.contains(&e.name)) - .map(|e| SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }) - .collect() - } else { - named_entry_source(state, side, name).into_iter().collect() - } -} - -/// The directory `target` currently names, re-resolved live against -/// whichever panel it points into — `None` if it no longer resolves to a -/// directory (mouse-drag "Robust against listing changes": "the target row -/// no longer resolves to a directory"). Never touches the filesystem: a -/// renamed-away or deleted row is indistinguishable here from one that never -/// existed, and both are simply not found. -fn drop_target_path(state: &State, target: &DropTarget) -> Option { - match target { - DropTarget::PanelDir(side) => Some(state.panel(*side).cwd.clone()), - DropTarget::SubDir { side, name } => { - let panel = state.panel(*side); - let entry = panel.entries.iter().find(|e| &e.name == name)?; - match entry.kind { - // The `..` row: the panel's own parent, exactly like - // `Command::ParentDir` resolves it. - EntryKind::ParentDir => crate::panel::parent_path(&panel.cwd), - EntryKind::Directory => Some(panel.cwd.join(name)), - EntryKind::File => None, - } - } - DropTarget::TreeNode { path, .. } => Some(path.clone()), - DropTarget::Tab { side, index } => state.panel(*side).tab_dirs().get(*index).cloned(), - } -} - -/// Whether `candidate` is a valid drop target for `drag`'s frozen items -/// right now (mouse-drag "Valid drop targets"): the region it names must -/// still make sense for the panel's current display mode (Info/Quick View -/// panels, and a Tree node/subdirectory row that no longer matches the -/// panel's actual mode, are never targets), the target must still resolve -/// to a live directory ([`drop_target_path`]), and it must be neither the -/// items' own source directory nor equal to/inside a directory being -/// dragged. Shared by `DragOver` (validating the TUI's raw geometric hit -/// before it is allowed onto `state.drag.target`) and `DragDrop` -/// (re-validating at release, since a listing can change between the last -/// `DragOver` and the button-up — mouse-drag "Robust against listing -/// changes"). -fn valid_drop_target(state: &State, drag: &DragState, candidate: &DropTarget) -> bool { - let region_ok = match candidate { - DropTarget::PanelDir(side) => !matches!(state.panel(*side).display_mode, DisplayMode::Info | DisplayMode::QuickView), - DropTarget::SubDir { side, .. } => matches!(state.panel(*side).display_mode, DisplayMode::Full | DisplayMode::Brief), - DropTarget::TreeNode { side, .. } => state.panel(*side).display_mode == DisplayMode::Tree, - // A tab always stands for its own directory, independent of - // whichever mode its panel's *active* tab currently renders in - // (design D7: "a tab in the strip stands for its directory"). - DropTarget::Tab { .. } => true, - }; - if !region_ok { - return false; - } - let Some(target_path) = drop_target_path(state, candidate) else { return false }; - // "A target equal to the items' own directory ... SHALL be invalid" - // (mouse-drag "Valid drop targets"). - if target_path == drag.source_dir { - return false; - } - // "... or equal to or inside a dragged directory, SHALL be invalid." - // Only dragged directories can have descendants to protect; a dragged - // file has none. - drag.items.iter().filter(|item| item.is_dir).all(|item| target_path != item.path && !target_path.starts_with(&item.path)) -} - -fn panels_matching(state: &State, dirs: &[&PathBuf]) -> Vec { - let mut out = Vec::new(); - for side in [PanelSide::Left, PanelSide::Right] { - if dirs.iter().any(|d| &state.panel(side).cwd == *d) { - out.push(side); - } - } - out -} - -fn handle_file_op(state: &mut State, cmd: Command) -> Vec { - let mut effects = Vec::new(); - match std::mem::replace(&mut state.phase, UiPhase::Panels) { - UiPhase::FileOpSetup(setup) => { - state.phase = handle_file_op_setup(setup, cmd, &mut effects); - } - UiPhase::FileOpRunning { source_dir, dest_dir, dialog } => { - state.phase = handle_file_op_running(state, source_dir, dest_dir, dialog, cmd, &mut effects); - } - UiPhase::FileOpSummary(skipped) => { - state.phase = match cmd { - Command::FileOpConfirm | Command::FileOpCancel => UiPhase::Panels, - _ => UiPhase::FileOpSummary(skipped), - }; - } - // A Job* event arrived outside FileOpRunning — shouldn't happen - // given the single-active-job design, but drop it defensively - // rather than lose the phase we were actually in. - other => state.phase = other, - } - effects -} - -/// Shared by `Command::FileOpConfirm` (Enter — confirms with whatever `kind` -/// the dialog opened with) and `Command::FileOpConfirmAs` (the drop -/// dialog's `[ Copy ]`/`[ Move ]` buttons — confirms with an explicit `kind` -/// override instead): an empty/whitespace destination re-shows the same -/// dialog unchanged, exactly as before this was split out; otherwise builds -/// and dispatches the job. `buttons` only ever rides along unchanged — it -/// has no bearing on which job runs. -fn confirm_destination_input( - kind: JobKind, - sources: Vec, - source_dir: PathBuf, - input: String, - buttons: Option, - effects: &mut Vec, -) -> UiPhase { - let trimmed = input.trim(); - if trimmed.is_empty() { - return UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }); - } - let job = match kind { - JobKind::Mkdir => { - Job { kind, sources: vec![], source_dir: source_dir.clone(), dest_dir: source_dir.clone(), new_dir_name: Some(OsString::from(trimmed)) } - } - _ => Job { kind, sources, source_dir: source_dir.clone(), dest_dir: PathBuf::from(trimmed), new_dir_name: None }, - }; - let running = UiPhase::FileOpRunning { - source_dir: job.source_dir.clone(), - dest_dir: job.dest_dir.clone(), - dialog: RunningDialog::Progress { kind: job.kind, progress: ProgressInfo::starting(0, 0) }, - }; - effects.push(Effect::RunJob(job)); - running -} - -fn handle_file_op_setup(setup: FileOpSetup, cmd: Command, effects: &mut Vec) -> UiPhase { - match setup { - FileOpSetup::DestinationInput { kind, sources, source_dir, mut input, buttons } => match cmd { - Command::FileOpInputChar(c) => { - input.push(c); - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }) - } - Command::FileOpInputBackspace => { - input.pop(); - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }) - } - Command::FileOpCancel => UiPhase::Panels, - // Enter: confirm with whatever `kind` the dialog opened with — - // the drop dialog's initially proposed verb, or the plain - // keyboard dialog's fixed Copy/Move/Mkdir. - Command::FileOpConfirm => confirm_destination_input(kind, sources, source_dir, input, buttons, effects), - // The drop dialog's `[ Copy ]`/`[ Move ]` buttons: confirm with - // an explicit verb instead, overriding `kind` (operation-dialogs - // "Switching the verb in the dialog"). - Command::FileOpConfirmAs(explicit_kind) => confirm_destination_input(explicit_kind, sources, source_dir, input, buttons, effects), - _ => UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }), - }, - FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once } => match cmd { - Command::FileOpCancel => UiPhase::Panels, - Command::FileOpConfirm => { - if needs_second_confirm && !confirmed_once { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once: true }) - } else { - let job = - Job { kind: JobKind::Delete, sources, source_dir: source_dir.clone(), dest_dir: source_dir.clone(), new_dir_name: None }; - let running = UiPhase::FileOpRunning { - source_dir: job.source_dir.clone(), - dest_dir: job.dest_dir.clone(), - dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(0, 0) }, - }; - effects.push(Effect::RunJob(job)); - running - } - } - _ => UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once }), - }, - FileOpSetup::RenameInput { source_dir, original_name, is_dir, mut input } => match cmd { - Command::FileOpInputChar(c) => { - input.push(c); - UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }) - } - Command::FileOpInputBackspace => { - input.pop(); - UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }) - } - Command::FileOpCancel => UiPhase::Panels, - Command::FileOpConfirm => { - let trimmed = input.trim(); - if trimmed.is_empty() { - return UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); - } - let source = SourceItem { original_name: original_name.clone(), path: source_dir.join(&original_name), is_dir }; - let job = Job { - kind: JobKind::Rename, - sources: vec![source], - source_dir: source_dir.clone(), - dest_dir: source_dir.clone(), - new_dir_name: Some(OsString::from(trimmed)), - }; - let running = UiPhase::FileOpRunning { - source_dir: job.source_dir.clone(), - dest_dir: job.dest_dir.clone(), - dialog: RunningDialog::Progress { kind: JobKind::Rename, progress: ProgressInfo::starting(0, 0) }, - }; - effects.push(Effect::RunJob(job)); - running - } - _ => UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }), - }, - } -} - -#[allow(clippy::too_many_arguments)] -fn handle_file_op_running( - state: &mut State, - source_dir: PathBuf, - dest_dir: PathBuf, - dialog: RunningDialog, - cmd: Command, - effects: &mut Vec, -) -> UiPhase { - match cmd { - Command::FileOpCancelJob => { - effects.push(Effect::CancelJob); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog } - } - Command::FileOpConflictChoice(choice) => match dialog { - RunningDialog::Conflict { kind, progress, .. } => { - effects.push(Effect::SendConflictReply(choice)); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpBeginRename => match dialog { - RunningDialog::Conflict { kind, info, progress, .. } => UiPhase::FileOpRunning { - source_dir, - dest_dir, - dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(String::new()) }, - }, - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpInputChar(c) => match dialog { - RunningDialog::Conflict { kind, info, progress, rename_input: Some(mut s) } => { - s.push(c); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(s) } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpInputBackspace => match dialog { - RunningDialog::Conflict { kind, info, progress, rename_input: Some(mut s) } => { - s.pop(); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(s) } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpConfirm => match dialog { - RunningDialog::Conflict { kind, progress, rename_input: Some(name), .. } if !name.trim().is_empty() => { - effects.push(Effect::SendConflictReply(ConflictChoice::Rename(OsString::from(name)))); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpCancel => match dialog { - RunningDialog::Conflict { kind, info, progress, rename_input: Some(_) } => UiPhase::FileOpRunning { - source_dir, - dest_dir, - dialog: RunningDialog::Conflict { kind, info, progress, rename_input: None }, - }, - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::FileOpErrorChoice(choice) => match dialog { - RunningDialog::Error { kind, progress, .. } => { - effects.push(Effect::SendErrorReply(choice)); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::JobProgress(info) => match dialog { - RunningDialog::Progress { kind, .. } => { - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress: info } } - } - other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, - }, - Command::JobConflict(info) => { - let kind = dialog.kind(); - let progress = dialog.progress().clone(); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: None } } - } - Command::JobError(info) => { - let kind = dialog.kind(); - let progress = dialog.progress().clone(); - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Error { kind, info, progress } } - } - Command::JobDone { outcome, .. } => { - let skipped = match outcome { - JobOutcome::Completed { skipped } => skipped, - JobOutcome::Cancelled { skipped } => skipped, - }; - for side in panels_matching(state, &[&source_dir, &dest_dir]) { - let path = state.panel(side).cwd.clone(); - effects.extend(begin_listing(state, side, path)); - } - // Background tabs (not currently displayed on either side) - // don't get the immediate re-read above — their cached listing - // would otherwise go silently stale. Mark any whose directory - // was touched by this job so activating them later (Alt+`n`, - // or the neighbor a Ctrl+W close falls back to) triggers a - // fresh read instead (file-operations "Automatic panel re-read - // on completion"; panel-tabs "Stale background tab refresh on - // activation"). `dest_dir == source_dir` for Delete/Rename, so - // this is a harmless duplicate match in that case, not a bug. - for side in [PanelSide::Left, PanelSide::Right] { - let panel = state.panel_mut(side); - panel.mark_background_tabs_stale(&source_dir); - panel.mark_background_tabs_stale(&dest_dir); - } - if skipped.is_empty() { - UiPhase::Panels - } else { - UiPhase::FileOpSummary(skipped) - } - } - _ => UiPhase::FileOpRunning { source_dir, dest_dir, dialog }, - } -} - -/// Enter: run the typed command line if there is one, otherwise act on the -/// entry under the cursor — descend into a directory, or open the -/// file-action menu for a file (file-action-menu "Enter on a file opens the -/// action menu"). Spawning an executable directly on Enter is gone; the -/// menu's Run entry is now the only way to reach the suspended-shell spawn -/// path (command-line: "Enter on an executable opens the menu instead of -/// spawning"). -fn handle_enter(state: &mut State) -> Vec { - if !state.command_line.trim().is_empty() { - return run_command_line(state); - } - let side = state.active; - if state.panel(side).display_mode == DisplayMode::Tree { - return handle_tree_enter(state, side); - } - let Some(entry) = state.panel(side).selected() else { return vec![] }; - match entry.kind { - EntryKind::File => { - let name = entry.name.clone(); - let executable = shell::is_executable_name(&name.to_string_lossy(), &state.shell.pathext); - state.file_action_menu = Some(FileActionMenuState::new(name, executable)); - vec![] - } - EntryKind::ParentDir => handle_parent(state, side), - EntryKind::Directory => { - let new_dir = state.panel(side).cwd.join(&entry.name); - begin_listing(state, side, new_dir) - } - } -} - -fn handle_parent(state: &mut State, side: PanelSide) -> Vec { - match crate::panel::parent_path(&state.panel(side).cwd) { - Some(parent) => begin_listing(state, side, parent), - None => vec![], - } -} - -// --------------------------------------------------------------------- -// Tree display mode (M5, design D7) -// --------------------------------------------------------------------- - -/// Enter Tree mode on `side`: record the panel's current display mode so -/// Enter can restore it, root the tree at the panel's drive, and kick off -/// the drive root's immediate-children read — the only I/O Tree mode -/// performs up front (additional-panel-modes "No up-front full-drive -/// scan"). -fn enter_tree_mode(state: &mut State, side: PanelSide) -> Vec { - let panel = state.panel(side); - let prior_mode = panel.display_mode; - let letter = drives::drive_letter_of(&panel.cwd).unwrap_or('C'); - let root = drives::drive_root(letter); - let p = state.panel_mut(side); - p.display_mode = DisplayMode::Tree; - p.tree = Some(TreeState::new(root.clone(), prior_mode)); - // Same reasoning as `SetDisplayMode`: a quick filter narrowing the - // prior list mode has no meaning in Tree mode and must not linger - // invisibly (quick-filter "Substring narrowing as the pattern is - // typed"). - p.clear_quick_filter(); - // A brand-new tree is a single root node, so this is a no-op today, but - // it keeps the freshly entered tree's viewport state consistent with - // every other tree-mutating path rather than relying on it happening to - // already be zeroed. - reconcile_panel_viewport(state, side); - vec![Effect::ExpandTreeNode { panel: side, path: root }] -} - -/// Move `side`'s tree cursor, then re-list the opposite panel at whatever -/// directory is now highlighted (additional-panel-modes "Tree mode drives -/// the opposite panel"), expanding the newly highlighted node if it hasn't -/// been already (additional-panel-modes "Children read on expand"). -fn handle_tree_cursor_move(state: &mut State, side: PanelSide, m: CursorMove) -> Vec { - let (target, needs_expand) = { - let Some(tree) = state.panel_mut(side).tree.as_mut() else { return vec![] }; - tree.move_cursor(m); - let Some(node) = tree.selected() else { return vec![] }; - (node.path.clone(), !node.expanded) - }; - // Keep the tree cursor inside its own scrolled window (additional- - // panel-modes "Tree mode scrolling") before the opposite panel's - // re-listing kicks off below. - reconcile_panel_viewport(state, side); - let mut effects = begin_listing_inner(state, side.toggle(), target.clone()); - if needs_expand { - effects.push(Effect::ExpandTreeNode { panel: side, path: target }); - } - effects -} - -/// Enter on a tree node: leave Tree mode, restoring the panel's prior -/// display mode, and navigate this panel (not the opposite one) to the -/// highlighted directory (additional-panel-modes "Enter returns to prior -/// list mode at chosen directory"). -fn handle_tree_enter(state: &mut State, side: PanelSide) -> Vec { - let Some(tree) = &state.panel(side).tree else { return vec![] }; - let Some(node) = tree.selected() else { return vec![] }; - let target = node.path.clone(); - let prior_mode = tree.prior_mode; - let p = state.panel_mut(side); - p.tree = None; - p.display_mode = prior_mode; - begin_listing(state, side, target) -} - -// --------------------------------------------------------------------- -// Ctrl+J fuzzy jump (M5) -// --------------------------------------------------------------------- - -fn is_fuzzy_jump_command(cmd: &Command) -> bool { - matches!( - cmd, - Command::FuzzyJumpChar(_) | Command::FuzzyJumpBackspace | Command::FuzzyJumpMove(_) | Command::FuzzyJumpConfirm | Command::FuzzyJumpCancel - ) -} - -/// Drive the fuzzy-jump dialog (gated in [`update`] by `state.fuzzy_jump`). -/// The ranked/filtered list itself is never stored on the dialog — it is -/// recomputed from `state.dir_history` each time via -/// [`quicksearch::rank_directories`], which is why every arm re-derives it -/// rather than caching (fuzzy-jump "Fuzzy matching of visited directories", -/// "Frecency ranking"). -fn handle_fuzzy_jump(state: &mut State, cmd: Command) -> Vec { - if state.fuzzy_jump.is_none() { - return vec![]; - } - match cmd { - Command::FuzzyJumpChar(c) => state.fuzzy_jump.as_mut().unwrap().push(c), - Command::FuzzyJumpBackspace => state.fuzzy_jump.as_mut().unwrap().backspace(), - Command::FuzzyJumpMove(delta) => { - let pattern = state.fuzzy_jump.as_ref().unwrap().pattern.clone(); - let len = quicksearch::rank_directories(&state.dir_history, &pattern, state.clock_ms).len(); - state.fuzzy_jump.as_mut().unwrap().move_cursor(delta, len); - } - Command::FuzzyJumpCancel => state.fuzzy_jump = None, - Command::FuzzyJumpConfirm => { - let fj = state.fuzzy_jump.take().unwrap(); - let ranked = quicksearch::rank_directories(&state.dir_history, &fj.pattern, state.clock_ms); - if let Some(path) = ranked.get(fj.cursor).map(|e| e.path.clone()) { - let side = state.active; - return begin_listing(state, side, path); - } - } - _ => {} - } - vec![] -} - -// --------------------------------------------------------------------- -// Alt+F7 find file (M5) -// --------------------------------------------------------------------- - -fn is_find_file_command(cmd: &Command) -> bool { - matches!( - cmd, - Command::FindFileChar(_) - | Command::FindFileBackspace - | Command::FindFileSubmit - | Command::FindFileMatch { .. } - | Command::FindFileSearchDone { .. } - | Command::FindFileMove(_) - | Command::FindFileConfirm - | Command::FindFileCancel - ) -} - -/// Drive the find-file dialog (gated in [`update`] by `state.find_file`). -fn handle_find_file(state: &mut State, cmd: Command) -> Vec { - if state.find_file.is_none() { - return vec![]; - } - match cmd { - Command::FindFileChar(c) => state.find_file.as_mut().unwrap().push(c), - Command::FindFileBackspace => state.find_file.as_mut().unwrap().backspace(), - Command::FindFileSubmit => { - let request = state.next_request_id(); - let ff = state.find_file.as_mut().unwrap(); - ff.submit(request); - let root = ff.root.clone(); - let pattern = ff.pattern.clone(); - return vec![Effect::FindInSubtree { root, pattern, request }]; - } - Command::FindFileMatch { request, m } => state.find_file.as_mut().unwrap().push_match(request, m), - Command::FindFileSearchDone { request } => state.find_file.as_mut().unwrap().mark_done(request), - Command::FindFileMove(delta) => state.find_file.as_mut().unwrap().move_cursor(delta), - // Dismissing abandons any in-progress search: the walk itself has - // no cancel signal (like `git_info::query`, it simply finishes and - // its late `FindFileMatch`/`FindFileSearchDone` replies find - // `state.find_file` already `None` and are silently dropped by the - // `state.find_file.is_some()` gate in `update`) (find-file "Esc - // cancels"). - Command::FindFileCancel => state.find_file = None, - Command::FindFileConfirm => { - let ff = state.find_file.take().unwrap(); - let Some(m) = ff.selected().cloned() else { return vec![] }; - let target_dir = match m.relative_path.parent() { - Some(parent) if !parent.as_os_str().is_empty() => ff.root.join(parent), - _ => ff.root.clone(), - }; - let side = state.active; - let effects = begin_listing(state, side, target_dir); - state.panel_mut(side).pending_cursor_target = Some(m.entry.name); - return effects; - } - _ => {} - } - vec![] -} - -// --------------------------------------------------------------------- -// F2 user menu (M5) -// --------------------------------------------------------------------- - -fn is_user_menu_command(cmd: &Command) -> bool { - matches!(cmd, Command::UserMenuMove(_) | Command::UserMenuConfirm | Command::UserMenuCancel) -} - -/// Drive the F2 user menu (gated in [`update`] by `state.user_menu`). -/// Entries themselves live in `state.user_menu_entries`, loaded once at -/// startup — the menu overlay only tracks the cursor (user-menu "Open the -/// F2 user menu", "Navigate and dismiss the user menu"). The cursor domain -/// is `0..=state.user_menu_entries.len()`: the last index is the -/// compiled-in "Themes" slot, not a config entry (design D3). -fn handle_user_menu(state: &mut State, cmd: Command) -> Vec { - if state.user_menu.is_none() { - return vec![]; - } - match cmd { - Command::UserMenuMove(delta) => { - // Cursor domain is the user entries plus one compiled-in - // built-in slot at index `entries.len()` for "Themes" (design - // D3) — not an entry appended to `state.user_menu_entries`. - let len = state.user_menu_entries.len() + 1; - state.user_menu.as_mut().unwrap().move_cursor(delta, len); - } - Command::UserMenuCancel => state.user_menu = None, - Command::UserMenuConfirm => { - let menu = state.user_menu.take().unwrap(); - if let Some(entry) = state.user_menu_entries.get(menu.cursor).cloned() { - let side = state.active; - let cwd = state.panel(side).cwd.clone(); - return vec![Effect::RunShellCommand(shell::build_command(state.shell.shell.as_deref(), &entry.command, &cwd), side)]; - } - // Cursor is at the built-in Themes slot (`entries.len()`): the - // user menu is already closed above (design D4); open the - // theme picker via the exact same path as - // `MenuAction::OpenThemes`, pre-highlighting the active theme. - // No shell effect. - state.theme_picker = Some(ThemePickerState::open(&state.theme.name)); - } - _ => {} - } - vec![] -} - -// --------------------------------------------------------------------- -// Options -> Themes picker (visual-themes) -// --------------------------------------------------------------------- - -fn is_theme_picker_command(cmd: &Command) -> bool { - matches!(cmd, Command::ThemePickerMove(_) | Command::ThemePickerConfirm | Command::ThemePickerCancel) -} - -/// Drive the Options → Themes picker (gated in [`update`] by -/// `state.theme_picker`). Enter applies the highlighted theme immediately — -/// swapping `state.theme` in this same reducer step, so the very next frame -/// renders with it — and returns `Effect::PersistTheme` so the TUI event -/// loop writes it to `config.toml`; Esc closes the dialog leaving both the -/// active theme and the config file untouched (theme-selection "Picker -/// navigation, apply, and cancel"). -fn handle_theme_picker(state: &mut State, cmd: Command) -> Vec { - if state.theme_picker.is_none() { - return vec![]; - } - match cmd { - Command::ThemePickerMove(delta) => { - state.theme_picker.as_mut().unwrap().move_cursor(delta); - } - Command::ThemePickerCancel => state.theme_picker = None, - Command::ThemePickerConfirm => { - let picker = state.theme_picker.take().unwrap(); - if let Some(name) = crate::theme::BUILTIN_THEME_NAMES.get(picker.highlight) { - if let Some(theme) = Theme::by_name(name) { - state.theme = theme; - } - return vec![Effect::PersistTheme((*name).to_string())]; - } - } - _ => {} - } - vec![] -} - -// --------------------------------------------------------------------- -// F1 Help window + About dialog (M5) -// --------------------------------------------------------------------- - -fn is_help_command(cmd: &Command) -> bool { - matches!(cmd, Command::HelpMove(_) | Command::HelpActivate | Command::HelpCancel) -} - -/// Drive the F1 Help window (gated in [`update`] by `state.help`). -fn handle_help(state: &mut State, cmd: Command) -> Vec { - if state.help.is_none() { - return vec![]; - } - match cmd { - Command::HelpMove(delta) => { - let visible = crate::dialogs::help_topic_visible_rows(crate::dialogs::help_window_height(state.term_size)); - state.help.as_mut().unwrap().move_cursor(delta, visible); - } - Command::HelpActivate => state.help.as_mut().unwrap().activate(), - Command::HelpCancel => { - let mut h = state.help.take().unwrap(); - // `back` returns `true` when it stepped down one level (About - // -> list, or page -> list) rather than closing the window - // outright; only then is the window kept open. - if h.back() { - state.help = Some(h); - } - } - _ => {} - } - vec![] -} - -/// The choke point every deliberate navigation of `side`'s directory flows -/// through — Enter into a directory, `..`, a typed `cd`, drive select, -/// Tree's own Enter-to-return, and the M5 fuzzy-jump/find-file dialogs — so -/// it is also where the Ctrl+J frecency history is recorded and persisted -/// (fuzzy-jump "Navigation records history"; design D6). Re-reads that are -/// not "the user navigating the active panel into a directory" bypass this -/// via [`begin_listing_inner`] directly — see its doc comment for the two -/// non-navigation callers. -fn begin_listing(state: &mut State, side: PanelSide, path: PathBuf) -> Vec { - quicksearch::record_visit(&mut state.dir_history, &path, state.clock_ms); - let mut effects = begin_listing_inner(state, side, path); - effects.push(Effect::PersistHistory(config::HistoryFile { commands: state.history.clone(), directories: state.dir_history.clone() })); - effects -} - -/// Everything [`begin_listing`] does except the navigation bookkeeping (the -/// frecency visit and the history persist) — the entry point for re-reads -/// that are not "the user navigating the active panel into a directory". -/// Two callers qualify today: Tree mode's cursor-move preview of the -/// *opposite* panel (that is the tree being browsed, not a navigation), and -/// the stale-tab refresh on `SwitchTab`/`CloseTab` activation (the user -/// changed tabs, not directories — panel-tabs "Stale background tab refresh -/// on activation"). A third caller should prompt extracting a purpose-named -/// helper rather than diluting this contract. -fn begin_listing_inner(state: &mut State, side: PanelSide, path: PathBuf) -> Vec { - state.panel_mut(side).begin_new_listing(path.clone()); - let mut effects = vec![Effect::StartListing { panel: side, path: path.clone() }]; - // A panel sitting in Info mode needs its drive/directory figures - // re-gathered for wherever it just landed. A fresh request id is - // minted even when the path is unchanged (e.g. a re-read), so an - // answer to a since-superseded query for the same path is still - // recognized as stale and dropped. - if state.panel(side).display_mode == DisplayMode::Info { - let request = state.next_request_id(); - state.panel_mut(side).info_request = Some(request); - effects.push(Effect::QueryInfo { panel: side, path: path.clone(), request }); - } - // Every navigation (and re-read) re-issues the git-info query for - // wherever the panel just landed, regardless of display mode — the - // branch suffix and marker column are independent of Info mode (git-info - // "Query re-issued on navigation"). - effects.push(git_info_query_effect(state, side, path)); - effects -} - -/// Mint a fresh generation id, record it as `side`'s outstanding git-info -/// request, and build the effect that runs the query on a worker thread -/// (git-info "Background repository detection"; design D3). -fn git_info_query_effect(state: &mut State, side: PanelSide, path: PathBuf) -> Effect { - let request = state.next_request_id(); - state.panel_mut(side).git_request = Some(request); - Effect::QueryGitInfo { panel: side, path, request } -} - -fn apply_listing_event(state: &mut State, cmd: Command) { - match cmd { - Command::ListingChunk { panel, entries } => { - let p = state.panel_mut(panel); - for e in entries { - p.insert_streamed(e); - } - if let crate::panel::ListingProgress::Streaming { count } = &mut p.progress { - *count = p.entries.len(); - } - // Keeps the pinned-to-top offset (0) while the user hasn't moved - // the cursor, exactly as `insert_streamed` already pins the - // cursor itself; re-clamps if the user had already moved it - // (panel-navigation "Streamed listing keeps the top pinned until - // the user moves"). - reconcile_panel_viewport(state, panel); - } - Command::ListingComplete { panel, total } => { - let p = state.panel_mut(panel); - p.progress = crate::panel::ListingProgress::Complete { count: total }; - p.clamp_cursor(); - p.reconcile_selection(); - // A find-file navigation seeds this with the matched entry's - // name (`update::handle_find_file`'s `FindFileConfirm`); once - // this directory's listing has actually landed, settle the - // cursor on it (find-file "Navigate to a chosen result" — - // "cursor positioned on the matched entry"). - if let Some(name) = p.pending_cursor_target.take() { - if let Some(idx) = p.entries.iter().position(|e| e.name == name) { - p.cursor = idx; - p.cursor_user_moved = true; - } - } - reconcile_panel_viewport(state, panel); - } - Command::ListingFailed { panel, message } => { - let p = state.panel_mut(panel); - p.progress = crate::panel::ListingProgress::Complete { count: p.entries.len() }; - p.last_error = Some(message); - } - _ => unreachable!("apply_listing_event only called for listing events"), - } -} - -// --------------------------------------------------------------------- -// Mouse (mouse-basics, design D2) -// --------------------------------------------------------------------- - -/// The command a dialog button click stands for — the exact command the -/// equivalent keypress already issues, so a click is never a new way to do -/// something, only a new way to reach an existing one. `None` for a button -/// that has no reachable meaning outside its own gated context (there is -/// none today — kept `Option` for symmetry with [`menu_action_command`] and -/// so a future button can be added without an infallible-match refactor). -pub fn button_command(id: ButtonId) -> Option { - match id { - ButtonId::DeleteConfirmYes => Some(Command::FileOpConfirm), - ButtonId::DeleteConfirmNo => Some(Command::FileOpCancel), - ButtonId::ConflictOverwrite => Some(Command::FileOpConflictChoice(ConflictChoice::Overwrite)), - ButtonId::ConflictOverwriteAll => Some(Command::FileOpConflictChoice(ConflictChoice::OverwriteAll)), - ButtonId::ConflictSkip => Some(Command::FileOpConflictChoice(ConflictChoice::Skip)), - ButtonId::ConflictSkipAll => Some(Command::FileOpConflictChoice(ConflictChoice::SkipAll)), - ButtonId::ConflictRename => Some(Command::FileOpBeginRename), - ButtonId::ErrorRetry => Some(Command::FileOpErrorChoice(ErrorChoice::Retry)), - ButtonId::ErrorSkip => Some(Command::FileOpErrorChoice(ErrorChoice::Skip)), - ButtonId::ErrorSkipAll => Some(Command::FileOpErrorChoice(ErrorChoice::SkipAll)), - ButtonId::ErrorAbort => Some(Command::FileOpErrorChoice(ErrorChoice::Abort)), - ButtonId::ProgressCancel => Some(Command::FileOpCancelJob), - ButtonId::SummaryContinue => Some(Command::FileOpConfirm), - ButtonId::QuitYes => Some(Command::ConfirmQuit), - ButtonId::QuitNo => Some(Command::CancelQuit), - ButtonId::DropDialogCopy => Some(Command::FileOpConfirmAs(JobKind::Copy)), - ButtonId::DropDialogMove => Some(Command::FileOpConfirmAs(JobKind::Move)), - ButtonId::DropDialogCancel => Some(Command::FileOpCancel), - } -} - -/// `Command::ClickEntry`: focus `side`, and either move the cursor to the -/// named entry (`Plain`/`Shift`) or toggle its selection in place without -/// advancing the cursor from wherever it lands (`Ctrl`) — mirroring `Ins`'s -/// `toggle_selection_and_advance` except the cursor's destination is the -/// clicked entry, not "one past wherever it already was" (mouse-input -/// "Click focuses and places the cursor", "Ctrl+click toggles selection"). A -/// name the panel no longer lists (e.g. a listing that changed between the -/// hit map being built and the click landing) is a silent no-op rather than -/// a panic. The parent `..` pseudo-entry is never selectable, matching -/// `toggle_selection_and_advance`'s own guard (mouse-input "Parent entry -/// ignored"). -fn handle_click_entry(state: &mut State, side: PanelSide, name: OsString, mods: ClickMods) -> Vec { - state.active = side; - let panel = state.panel_mut(side); - let Some(idx) = panel.entries.iter().position(|e| e.name == name) else { return vec![] }; - panel.cursor = idx; - panel.cursor_user_moved = true; - if mods == ClickMods::Ctrl && panel.entries[idx].kind != EntryKind::ParentDir { - if !panel.selected.remove(&name) { - panel.selected.insert(name); - } - } - reconcile_panel_viewport(state, side); - vec![] -} - -/// `Command::ScrollPanel`: move `side`'s cursor by `delta` rows and let the -/// existing scroll-offset clamp bring the viewport along, so the -/// cursor-in-window invariant holds exactly as it does for a keyboard -/// Up/Down (mouse-input "Wheel moves the cursor of the panel under the -/// pointer"; design D6). Tree mode has no flat `entries` list to move a -/// bare `CursorMove` over, so it reuses the same tree-cursor path the -/// keyboard's Up/Down already takes there. -fn handle_scroll_panel(state: &mut State, side: PanelSide, delta: isize) -> Vec { - let m = if delta < 0 { CursorMove::Up(delta.unsigned_abs()) } else { CursorMove::Down(delta as usize) }; - if state.panel(side).display_mode == DisplayMode::Tree { - return handle_tree_cursor_move(state, side, m); - } - state.panel_mut(side).move_cursor(m); - reconcile_panel_viewport(state, side); - vec![] -} - -/// `Command::KeybarPress`: the command each function-key-bar slot stands -/// for, exactly mirroring `input::map_panel_key`'s own F-key arms (mouse- -/// input "Key bar, menu bar, pull-down items, and dialog buttons are -/// clickable"). `None` for a slot number the bar never actually draws. -fn keybar_command(slot: u8) -> Option { - match slot { - 1 => Some(Command::HelpOpen), - 2 => Some(Command::UserMenuOpen), - 3 => Some(Command::RequestViewer), - 4 => Some(Command::RequestEditor), - 5 => Some(Command::RequestCopy), - 6 => Some(Command::RequestMove), - 7 => Some(Command::RequestMkdir), - 8 => Some(Command::RequestDelete), - 9 => Some(Command::MenuOpen), - 10 => Some(Command::RequestQuit), - _ => None, - } -} - -/// `Command::MenuItemClick`: highlight `index` (only if it names an enabled -/// item) and return the command activating it stands for, exactly like -/// `handle_menu`'s own `Command::MenuActivate` arm — the caller re-enters -/// `update` with it. `None` (no-op) for an index that is out of range, a -/// separator, or disabled, and whenever no menu is open at all. -fn handle_menu_item_click(state: &mut State, index: usize) -> Option { - let active_side = state.active; - let menu = state.menu.as_mut()?; - match menu.items().get(index) { - Some(MenuEntry::Item(item)) if item.is_enabled() => { - menu.selected = index; - let side = menu.active.target_side(active_side); - let action = item.action; - state.menu = None; - menu_action_command(action, side) - } - _ => None, - } -} - -/// `Command::OpenActionMenuAt`: move `side`'s cursor to the named entry and -/// open the file-action menu for it — for a file, the same shape -/// `handle_enter`'s `EntryKind::File` arm builds; for a directory, the -/// `View`/`Edit`/`Run`-less menu `file-action-menu`'s "Directory targets and -/// selection-scoped invocation" requirement adds (mouse-input "Right-click -/// opens the action menu"; design D4). The menu is also opened -/// selection-scoped whenever `name` is already a member of the panel's -/// selection set, so `activate_file_action` later acts on the whole -/// selection rather than `name` alone. A `..` target, or a name the panel no -/// longer lists, is a no-op — `..` is never a valid action-menu target, -/// mirroring Ctrl+click's own "Parent entry ignored" guard. -fn handle_open_action_menu_at(state: &mut State, side: PanelSide, name: OsString) -> Vec { - state.active = side; - let panel = state.panel_mut(side); - let Some(idx) = panel.entries.iter().position(|e| e.name == name) else { return vec![] }; - panel.cursor = idx; - panel.cursor_user_moved = true; - reconcile_panel_viewport(state, side); - let panel = state.panel(side); - let kind = panel.entries[idx].kind; - if kind == EntryKind::ParentDir { - return vec![]; - } - let is_dir = kind == EntryKind::Directory; - let executable = !is_dir && shell::is_executable_name(&name.to_string_lossy(), &state.shell.pathext); - let selection_scoped = panel.selected.contains(&name); - state.file_action_menu = Some(FileActionMenuState::open(name, is_dir, executable, selection_scoped)); - vec![] -} - #[cfg(test)] mod tests; diff --git a/crates/filecommand-core/src/update/command.rs b/crates/filecommand-core/src/update/command.rs new file mode 100644 index 0000000..d134f68 --- /dev/null +++ b/crates/filecommand-core/src/update/command.rs @@ -0,0 +1,1060 @@ +//! The reducer's vocabulary: [`Command`], [`Effect`], and the value types they +//! carry (`ButtonId`, `ClickMods`, `DropTarget`, `DragState`, the pending- +//! request records). Re-exported from `update` so every existing +//! `filecommand_core::update::…` path still resolves. + +use super::*; + +/// A mouse click's modifier state, as `input::map_mouse` resolves it from +/// the raw `crossterm::event::KeyModifiers` on a `MouseEvent` — never +/// `crossterm::event::KeyModifiers` itself, which must never cross into this +/// crate (mouse-input "Hit-testing stays in the TUI"; design D2). `Shift` is +/// carried for completeness even though Shift+click range selection is +/// deliberately unspecified (design D3) — most terminal emulators intercept +/// it for native text selection before it ever reaches crossterm. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClickMods { + Plain, + Ctrl, + Shift, +} + +/// Where a drag-and-drop is currently hovering, already validated against +/// mouse-drag's "Valid drop targets" (design D4) — never a raw, unvalidated +/// coordinate hit. `PanelDir`'s `side` stands for that whole panel as a +/// target — its title, blank body area, or any non-directory row all +/// resolve to this same variant, since they all mean "drop into this +/// panel's current directory" (the TUI's hit-testing doesn't need to +/// distinguish them). `SubDir`'s `name` covers the `..` row exactly like an +/// ordinary subdirectory row. `TreeNode` is a Tree-mode panel's node. +/// `Tab`'s `index` is a position in that panel's full ordered tab list +/// (`PanelState::tab_dirs`), standing for that tab's directory. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DropTarget { + PanelDir(PanelSide), + SubDir { side: PanelSide, name: OsString }, + TreeNode { side: PanelSide, path: PathBuf }, + Tab { side: PanelSide, index: usize }, +} + +/// An in-progress mouse drag's frozen identity plus its live target and +/// proposed verb (mouse-drag "Drag lifecycle"; design D4). `source`, +/// `source_dir`, and `items` are captured once at `DragBegin` and never +/// change for the life of the drag — a streamed listing chunk, a re-sort, or +/// even the source panel navigating away changes nothing here (mouse-drag +/// "Robust against listing changes"); `op` and `target` are instead updated +/// by every later `DragOver`/`DragDrop`, since the proposed verb is +/// recomputed from the modifiers of each of those events (design D2) and the +/// hovered target can change on every pointer move. Lives beside `UiPhase`, +/// like the overlay slot `State::overlay`, rather than inside it — a drag +/// overlays the live panels, it is not itself a phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DragState { + pub source: PanelSide, + pub source_dir: PathBuf, + pub items: Vec, + pub op: JobKind, + pub target: Option, +} + +/// Which dialog button a click landed on, resolved by the TUI's hit map +/// (design D2: "Dialog buttons" includes the hotkey text spans of dialogs +/// with no framed buttons, e.g. the conflict dialog's `(O)verwrite (S)kip +/// …` row). `Command::DialogButtonClick` maps one of these straight onto the +/// exact command the equivalent keypress already issues, via +/// [`button_command`] — a dialog button is never a new way to do something, +/// only a new way to reach an existing one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonId { + DeleteConfirmYes, + DeleteConfirmNo, + ConflictOverwrite, + ConflictOverwriteAll, + ConflictSkip, + ConflictSkipAll, + ConflictRename, + /// The overwrite-conflict dialog's `(C)ancel` control — the same job + /// abort Esc and `C` issue there (operation-dialogs "Overwrite-conflict + /// dialog"; process-lifecycle-hardening design D7). + ConflictCancel, + ErrorRetry, + ErrorSkip, + ErrorSkipAll, + ErrorAbort, + ProgressCancel, + /// The skipped-items summary's "Press any key to continue" footer — + /// there is no literal button glyph, but design D5 gates this dialog as + /// buttons-only, so the whole footer row is recorded as one clickable + /// span (mirroring how `Command::FileOpConfirm` is what any key already + /// does here). + SummaryContinue, + QuitYes, + QuitNo, + /// The drop-initiated destination dialog's `[ Copy ]` button + /// (operation-dialogs "Drop-initiated destination dialog"). + DropDialogCopy, + /// Its `[ Move ]` button. + DropDialogMove, + /// Its `[ Cancel ]` button — routes to exactly the same command as the + /// keyboard dialog's Esc. + DropDialogCancel, +} + +/// What `Effect::ProbePath` found at the probed path (offload-blocking-io +/// design D2): a directory (links followed, so a junction to a directory is +/// `Dir`), some other kind of entry, or nothing — any error at all maps to +/// `Missing`, exactly as the old synchronous `metadata(..).ok()?` did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeKind { + Dir, + File, + Missing, +} + +/// Which command-line builtin an outstanding `Effect::ProbePath` completes +/// (design D1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProbeIntent { + /// `cd`: on `Dir`, push `text` to command history and navigate. + Cd { text: String }, + /// `del` (`want_dir: false`) / `rmdir` (`want_dir: true`): on a type + /// match, open the delete-confirmation dialog. + Delete { want_dir: bool }, +} + +/// The one command-line target check that may be outstanding (design D1). +/// `cwd` is the panel's directory when the command was entered: a reply is +/// applied only while the panel still shows it, so navigating away +/// invalidates the probe without any explicit cancel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingProbe { + pub request: u64, + pub side: PanelSide, + pub cwd: PathBuf, + pub path: PathBuf, + pub intent: ProbeIntent, +} + +/// Which kind of open `Effect::OpenViewer`/`OpenEditor` was requested for +/// (design D4). Recorded for diagnostics and symmetry with the effect pair; +/// the reply gate itself keys on the request id and the panel phase. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingOpenKind { + Viewer, + Editor, +} + +/// The one F3/F4 open that may be outstanding (design D4). A newer request +/// replaces it, which is what makes the older reply stale. `side` is the +/// panel the open was requested from, so a late failure lands there and not +/// on whichever panel is active when the reply arrives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingOpen { + pub request: u64, + pub side: PanelSide, + pub kind: PendingOpenKind, +} + +/// Key-derived and worker-produced inputs to `update`. Both flow through the +/// same path — worker events are not special-cased at the top level. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + MoveCursor(CursorMove), + ToggleActivePanel, + Enter, + ParentDir, + RequestQuit, + ConfirmQuit, + CancelQuit, + Resize(u16, u16), + /// The current time, supplied by the TUI's injected clocks: `now_ms` + /// from the monotonic `Clock` (animations, every in-session timing) and + /// `epoch_ms` from the `WallClock` (Unix-epoch milliseconds; frecency + /// stamps only — history-persistence-hardening design D1). + Tick { + now_ms: u64, + epoch_ms: u64, + }, + + // Adjustable panel split (Ctrl+Left/Ctrl+Right/Ctrl+=; panel-split + // "Adjust and reset the panel split"). + /// Ctrl+Right: move the divider `panel_split::SPLIT_STEP` columns + /// right (grows the left panel). A no-op at the right panel's minimum. + SplitGrow, + /// Ctrl+Left: move the divider `panel_split::SPLIT_STEP` columns left + /// (shrinks the left panel). A no-op at the left panel's minimum. + SplitShrink, + /// Ctrl+=: reset the split to 50/50, unconditionally. + SplitReset, + /// Re-read a panel's directory (Ctrl+R). Also the recovery action + /// offered after a listing failure, but available at any time. + RereadPanel(PanelSide), + + // Selection (Ins/+/-/*). + ToggleSelectAtCursor, + GroupSelectAll, + GroupDeselectAll, + InvertSelection, + + // F5/F6/F7/F8: enter the corresponding file-op setup dialog. No-ops if + // there is nothing selected (Mkdir is always available). + RequestCopy, + RequestMove, + RequestMkdir, + RequestDelete, + + // FileOpSetup dialog interaction. + FileOpConfirm, + FileOpCancel, + /// The drop-initiated destination dialog's `[ Copy ]`/`[ Move ]` buttons: + /// confirms exactly as `Command::FileOpConfirm` does, but commits the + /// given verb regardless of whichever `kind` the dialog opened with — + /// the only way to switch the verb without reopening the dialog + /// (operation-dialogs "Switching the verb in the dialog"). A no-op + /// outside `FileOpSetup::DestinationInput` (`DeleteConfirm`/ + /// `RenameInput` never carry a button row that could produce this). + FileOpConfirmAs(JobKind), + + // FileOpRunning dialog interaction. + FileOpConflictChoice(ConflictChoice), + FileOpBeginRename, + FileOpErrorChoice(ErrorChoice), + FileOpCancelJob, + + // Command line. + /// Esc: clears the buffer, which is the explicit mechanism that hands + /// Up/Down back to the panel cursor. + CommandLineClear, + CommandLineHistoryPrev, + CommandLineHistoryNext, + /// Ctrl+Enter — paste the cursor entry's file name. + PasteCursorName, + /// Ctrl+] — paste the cursor entry's full path. + PasteCursorPath, + /// Ctrl+O — drop to the host terminal's scrollback. + ShowScrollback, + + // Shared single-line text-field editing (text-field-editing; design D2). + /// One editing operation routed to whichever field owns the keyboard: + /// the destination/name or Rename input in `FileOpSetup`, the conflict + /// dialog's rename input in `FileOpRunning`, the find-file pattern, the + /// fuzzy-jump pattern, else the command line. Replaces the six private + /// per-field char/backspace command pairs those fields used to have. + TextEdit(TextEditOp), + /// Shift+Ins/Ctrl+V in a dialog field, Ctrl+V over the panels: read the + /// system clipboard's text (via `Effect::ReadClipboardText`) and feed it + /// back as `TextEdit(TextEditOp::Paste)` (design D4). + RequestPaste, + + // Type-ahead quick search (§4.7), which competes with the command line + // for printable keys. + QuickSearchStart(char), + QuickSearchChar(char), + QuickSearchBackspace, + QuickSearchEnd, + + // Sort modes (Ctrl+F3..Ctrl+F7). + SetSortMode { + side: PanelSide, + mode: SortMode, + }, + + // Ctrl+P inline quick filter (§4.7). `QuickFilterStart` carries the + // panel side so the Left/Right pull-down's Filter item can start the + // filter on its own panel (design D1 of pulldown-menu-completion); the + // keymap passes the active side. Starting the filter also makes `side` + // the active panel, so typed characters reach it. + QuickFilterStart(PanelSide), + QuickFilterChar(char), + QuickFilterBackspace, + QuickFilterEnd, + + // Panel tabs (§4.1/§4.7). `OpenTab`/`CloseTab` carry the panel side for + // the same Left/Right-menu reason as `QuickFilterStart` (but never move + // focus — design D1); the keymap passes the active side. + /// Ctrl+T. + OpenTab(PanelSide), + /// Ctrl+W. + CloseTab(PanelSide), + /// Alt+1..9 (one-based). + SwitchTab(usize), + + // Tree display mode (§4.2), scoped to a panel side. + /// Enter Tree mode on `side`, recording its current display mode so + /// Enter can restore it, and kicking off the drive root's immediate- + /// children read (additional-panel-modes "No up-front full-drive + /// scan"; design D7). + EnterTreeMode(PanelSide), + /// Reply to `Effect::ExpandTreeNode`: `path`'s immediate child + /// directories (empty on a read failure — skipped rather than + /// aborting, matching `find_in_subtree`'s precedent). Applied only if + /// `path` still names a not-yet-expanded node in `panel`'s tree, so a + /// reply for a node the tree no longer has (a since-superseded Tree + /// session) is silently dropped (additional-panel-modes "Children read + /// on expand"). + TreeNodeExpanded { + panel: PanelSide, + path: PathBuf, + children: Vec, + }, + + // F9 menu overlay. + MenuOpen, + MenuClose, + /// Esc: closes the pull-down but leaves the bar open; a second Esc + /// closes the bar. + MenuCollapse, + MenuSelectPrev, + MenuSelectNext, + MenuPrevMenu, + MenuNextMenu, + MenuHotkey(char), + MenuActivate, + + // Drive select (Alt+F1/F2). + OpenDriveSelect(PanelSide), + DriveListReady { + target: PanelSide, + drives: Vec, + }, + DriveSelectMove(isize), + DriveSelectConfirm, + DriveSelectCancel, + + // Info display mode (Ctrl+L). + ToggleInfoMode(PanelSide), + + // F3 viewer (M4). + /// Open the viewer on the file under the active panel's cursor. + RequestViewer, + /// F10 in the viewer: close it and return focus to the panels. + ViewerClose, + /// F4 in the viewer: toggle text/hex mode. + ViewerToggleMode, + /// F2 in the viewer: toggle wrap/unwrap. + ViewerToggleWrap, + /// Move the top-of-screen anchor to this byte offset (clamped to the + /// file length). Used for both forward paging and the backward-scan + /// result, which the caller computes via [`crate::viewer::backward`] + /// before issuing this command, keeping `update` itself I/O-free. + ViewerSetTop(u64), + /// Set the unwrap-mode horizontal scroll, in display columns. + ViewerSetHScroll(usize), + /// F7 in the viewer: open the search prompt. + ViewerSearchStart, + ViewerSearchChar(char), + ViewerSearchBackspace, + ViewerSearchCancel, + /// Enter on the search prompt: run the search (via + /// `Effect::RunViewerSearch`) for the typed pattern. + ViewerSearchConfirm, + + // F4 external editor (M4). + /// Launch the configured external editor on the file under the active + /// panel's cursor. + RequestExternalEditor, + + // F4 built-in editor (M5). `RequestEditor` is the actual F4 keybinding + // target from a panel: it resolves the external-editor/built-in/size-cap + // precedence (builtin-editor "Editor invocation and size cap", "External + // editor takes precedence") and, when it wins, dispatches to the same + // `handle_request_external_editor` path `RequestExternalEditor` already + // exercises. + RequestEditor, + /// Reply to `Effect::OpenEditor`: the file loaded under the 10 MB cap + /// (builtin-editor "Small file opens in the editor"). Every + /// `Effect::OpenEditor` reply echoes the effect's `request`, which is + /// matched against `State::pending_open` so a reply the user has since + /// superseded — a newer F3/F4, a menu or dialog opened meanwhile — is + /// discarded instead of pulling them out of what they are doing + /// (offload-blocking-io design D4). + EditorOpened { + request: u64, + editor: Box, + }, + /// Reply to `Effect::OpenEditor`: the file is `size` bytes, at or above + /// the cap — redirected to the F3 viewer with a notice (builtin-editor + /// "Large file redirects to the viewer"). + EditorTooLarge { + request: u64, + path: PathBuf, + size: u64, + }, + /// Reply to `Effect::OpenEditor`: the file is under the cap but is not + /// UTF-8 text the editor can write back byte-exactly (UTF-16, NUL + /// bytes, or invalid UTF-8) — redirected to the F3 viewer with a + /// reason-specific notice, exactly like `EditorTooLarge` (builtin-editor + /// "Non-UTF-8 file redirects to the viewer"; editor-encoding-safety + /// design D2). + EditorNotEditable { + request: u64, + path: PathBuf, + reason: NotEditableReason, + }, + /// Reply to `Effect::OpenEditor` when the file could not be opened. + EditorOpenFailed { + request: u64, + message: String, + }, + + // Editor keymap, while `UiPhase::Editor` owns focus. + EditorChar(char), + EditorNewline, + EditorBackspace, + EditorMove(EditorMove), + /// Insert key: toggle insert/overwrite text-entry mode. + EditorToggleMode, + /// F3: anchor a line selection at the caret. + EditorMark, + EditorCut, + EditorCopy, + EditorPaste, + EditorUndo, + /// F7: open the literal-search prompt. + EditorSearchStart, + EditorSearchChar(char), + EditorSearchBackspace, + EditorSearchCancel, + /// Enter on the search prompt: run `EditorState::find_next` for the + /// typed pattern. + EditorSearchConfirm, + /// F4 (while the editor, not a panel, owns focus): open the + /// search-and-replace prompt's first (pattern) stage. + EditorReplaceStart, + EditorReplaceChar(char), + EditorReplaceBackspace, + EditorReplaceCancel, + /// Enter on the replace prompt: advances pattern -> replacement, or — + /// from the replacement stage — runs `EditorState::replace_first`. + EditorReplaceConfirm, + /// F2: save in place. + EditorSave, + /// Reply to `Effect::SaveEditor`: the write succeeded. `then_quit` + /// echoes back whether this save was requested by the save-on-exit + /// confirm's "Y", in which case the editor closes once applied. + EditorSaved { + editor: Box, + then_quit: bool, + }, + /// Reply to `Effect::SaveEditor`: the write failed — aborts a save-on- + /// exit quit attempt and surfaces the message inline rather than losing + /// the buffer (builtin-editor "Modified indicator and save-on-exit + /// prompt"). + EditorSaveFailed { + message: String, + }, + /// F10: quit if unmodified, else raise the save-on-exit confirm + /// (builtin-editor "Quitting with unsaved changes prompts"). + EditorRequestQuit, + /// "Y" on the save-on-exit confirm: save, then close the editor. + EditorConfirmQuitSave, + /// "N" on the save-on-exit confirm: close the editor without saving. + EditorConfirmQuitDiscard, + /// Esc on the save-on-exit confirm: dismiss it and keep editing. + EditorCancelQuit, + + // Worker-produced events, re-entering through the same `update` path. + /// Replies to `Effect::StartListing`. `request` echoes the id that + /// effect was issued with and is matched against the panel's + /// `PanelState::listing_request`; a reply from a listing the panel has + /// since superseded (a navigation, re-read, drive change, or tab switch + /// landed first) is dropped before it touches any panel state, so a + /// slow directory's entries never pour into whatever the panel shows + /// now (panel-navigation "Superseded directory listings are discarded + /// and cancelled"; listing-request-guard design D1/D2). + ListingChunk { + panel: PanelSide, + entries: Vec, + request: u64, + }, + /// `unreadable` is how many entries the reader skipped because their own + /// metadata could not be read; when non-zero the panel's mini-status + /// reports the count on completion (panel-navigation "One unreadable + /// entry does not fail a listing"; link-aware-listing design D6/D7). + ListingComplete { + panel: PanelSide, + total: usize, + unreadable: usize, + request: u64, + }, + ListingFailed { + panel: PanelSide, + message: String, + request: u64, + }, + JobProgress(ProgressInfo), + JobConflict(ConflictInfo), + JobError(ErrorInfo), + JobDone { + outcome: JobOutcome, + source_dir: PathBuf, + dest_dir: PathBuf, + }, + DriveLabelResolved { + target: PanelSide, + letter: char, + label: Option, + generation: u64, + }, + InfoResolved { + panel: PanelSide, + path: PathBuf, + request: u64, + values: InfoValues, + }, + /// Reply to `Effect::QueryGitInfo`: either a resolved repository's + /// branch and per-entry statuses, or `GitInfo::none()` for "outside any + /// repository" and for a timed-out query (git-info "Background + /// repository detection", "Silent absence on timeout and stale-result + /// discarding"). `request` is matched against + /// `PanelState::git_request` the same way `InfoResolved`'s `request` is + /// matched against `info_request`, so a reply for a directory/generation + /// the panel has since moved past is dropped. `timed_out_root` is `Some` + /// only when the query hit its timeout: the repository's + /// working-directory root, or the queried directory itself when the + /// repository could not even be located in time. It is recorded in + /// `State::git_no_info_roots` even when the reply is otherwise stale — + /// a timeout is evidence about the repository, not about the panel's + /// current directory (git-info "Timeout recorded after the panel moved + /// on"; git-info-timeout-memory design D1). + GitInfoResolved { + panel: PanelSide, + path: PathBuf, + request: u64, + info: GitInfo, + timed_out_root: Option, + }, + /// Reply to `Effect::OpenViewer`: the file opened at `file_len` bytes + /// (viewer: Instant open). `request` echoes the effect's id and is + /// matched against `State::pending_open` exactly as the editor replies + /// are (offload-blocking-io design D4). + ViewerOpened { + request: u64, + path: PathBuf, + file_len: u64, + }, + /// Reply to `Effect::OpenViewer`: the file could not be opened or + /// mapped (§7 error policy — surfaced as an inline error, never a + /// panic). + ViewerOpenFailed { + request: u64, + message: String, + }, + /// Reply to `Effect::ProbePath`: what the worker found at the probed + /// command-line target. Matched against `State::command_probe` by + /// `request`; a stale or superseded reply changes nothing + /// (application-shell "The reducer performs no filesystem I/O"; + /// offload-blocking-io design D1). + PathProbed { + request: u64, + kind: ProbeKind, + }, + /// Reply to `Effect::RunViewerSearch` (viewer: F7 streaming search). + /// `request` echoes the id `Effect::RunViewerSearch` was issued with, so + /// a reply from a search superseded by a since-closed/reopened viewer + /// session (`ViewerState::search_request`) is recognized as stale and + /// dropped rather than applied to whatever viewer session happens to be + /// open when it arrives. + ViewerSearchResult { + offset: Option, + match_range: Option<(u64, u64)>, + request: u64, + }, + /// Reply to `Effect::RunExternalEditor` when the editor could not be + /// spawned (external-editor: Editor spawn errors do not crash the app). + ExternalEditorSpawnFailed { + message: String, + }, + + // Ctrl+J fuzzy jump (M5). + /// Open the dialog (default binding, overridable via `config.toml`). + FuzzyJumpOpen, + FuzzyJumpMove(isize), + /// Enter: navigate the active panel to the highlighted directory and + /// close the dialog (fuzzy-jump "Enter navigates the active panel"). + FuzzyJumpConfirm, + /// Esc: close without navigating. + FuzzyJumpCancel, + + // Alt+F7 find file (M5). + /// Open the dialog, rooted at the active panel's current directory + /// (find-file "Find-file invocation"). + FindFileOpen, + /// Enter on the pattern input: kick off `Effect::FindInSubtree`. + FindFileSubmit, + /// Reply to `Effect::FindInSubtree`: one matched entry, streamed as the + /// walk finds it. `request` is matched against `FindFileState::request` + /// so a reply from an abandoned/superseded search is dropped (find-file + /// "Non-blocking search with static progress"). + FindFileMatch { + request: u64, + m: FindMatch, + }, + /// Reply to `Effect::FindInSubtree`: the walk has finished. + FindFileSearchDone { + request: u64, + }, + FindFileMove(isize), + /// Enter on a result: navigate the active panel's current tab in place + /// and close the dialog (find-file "Navigate to a chosen result"). + FindFileConfirm, + /// Esc: close without navigating, abandoning any in-progress search + /// (find-file "Dismiss the find-file dialog"). + FindFileCancel, + + // F2 user menu (M5). + UserMenuOpen, + UserMenuMove(isize), + /// Enter: on a user entry, run its command via the shell passthrough in + /// the active panel's directory (user-menu "Run the selected entry's + /// command via the shell in the active panel directory"); on the + /// compiled-in built-in Themes slot (cursor at `entries.len()`), open + /// the theme picker instead — no shell effect (user-menu "Built-in + /// Themes entry opens the theme selector"). Either way the user menu + /// closes first. + UserMenuConfirm, + /// Esc: close without running anything. + UserMenuCancel, + + // Alt+F8 command-history dialog (pulldown-menu-completion; command-line + // "Command history dialog (Alt+F8)"). Opened by Alt+F8 or Commands → + // History; a list overlay over `State::history`, newest first. + HistoryDialogOpen, + /// Up/Down/Home/End/PgUp/PgDn (the last four arrive as huge deltas, the + /// same convention as `DriveSelectMove`). + HistoryDialogMove(isize), + /// Enter: replace the command-line buffer with the highlighted entry + /// (caret at the end, no history recall in progress) and close — nothing + /// runs until the user presses Enter again. A no-op on an empty history, + /// leaving the dialog open. + HistoryDialogConfirm, + /// Esc: close, leaving the command line unchanged. + HistoryDialogCancel, + + /// Ctrl+U / Commands → Swap panels: exchange the two panels' entire + /// state, keeping the active side and split unchanged (panel-navigation + /// "Swap panels (Ctrl+U)"; design D3). + SwapPanels, + + // Commands → Menu file edit (user-menu "Edit the user menu file from the + // Commands menu"; design D4). + /// Open `State::user_menu_path` via the F4 editor path: the configured + /// external editor when `editor =` is set, else the built-in editor. + EditUserMenuFile, + /// Sent by the TUI once the editor that had the user-menu file open has + /// closed/returned, carrying the freshly re-loaded parse result. Applies + /// the entries, or — on malformed content — raises the warning dialog + /// and keeps the previous entries (never overwriting the file). + UserMenuReloaded(config::UserMenuLoadResult), + + // Options -> Themes picker (visual-themes). + /// Opens the picker with the active theme's row pre-highlighted. + /// Reached only via `MenuAction::OpenThemes` (Options → Themes), like + /// `UserMenuOpen`/`HelpOpen` above. + ThemePickerOpen, + ThemePickerMove(isize), + /// Enter: apply the highlighted theme immediately (switches + /// `state.theme` in the same reducer step), persist it to + /// `config.toml`, and close the dialog (theme-selection "Picker + /// navigation, apply, and cancel"). + ThemePickerConfirm, + /// Esc: close without changing the active theme or touching + /// `config.toml`. + ThemePickerCancel, + + // F1 Help window + About dialog (M5). + HelpOpen, + HelpMove(isize), + /// Enter / the `Help` button: opens the highlighted topic's page, or + /// the About dialog for the special first entry. + HelpActivate, + /// Esc / the `Cancel` button: dismisses the About dialog back to the + /// list, or a topic page back to the list, or closes the window + /// entirely from the list. + HelpCancel, + + // Panel display-mode switches reachable from the Left/Right menu (M5; + // design D7). `Info` and `Tree` keep their own dedicated commands + // (`ToggleInfoMode`, `EnterTreeMode`) since each carries extra + // bookkeeping (an Info query, a Tree session) this generic command + // deliberately does not. + SetDisplayMode { + side: PanelSide, + mode: DisplayMode, + }, + + /// Dismiss the startup-warning modal (any key, per the one-shot dialogs' + /// existing Esc/Enter-dismiss convention) (user-menu "Malformed file + /// warns and falls back without overwriting"). + DismissStartupWarning, + + // Enter-on-file action menu (file-action-menu). + /// Up/Down: move the highlight, clamped at both ends. + FileActionMenuMove(isize), + /// Enter: activate the highlighted entry and close the menu. + FileActionMenuConfirm, + /// Esc: close the menu with no action taken. + FileActionMenuCancel, + /// A first-letter hotkey: activate the matching entry directly, exactly + /// as if it had been highlighted and confirmed (file-action-menu + /// "First-letter hotkey activates directly"). + FileActionMenuHotkey(char), + /// A left-click on the open file-action menu's `index`'th row: activates + /// it exactly as `Command::FileActionMenuConfirm` would once the + /// highlight is there (mouse-input "File-action menu entries are + /// clickable"). `input::map_mouse` is the only source of this. + FileActionMenuItemClick(usize), + /// Reply to `Effect::OpenWithAssociation` when the launch failed: the + /// message lands on the status line (`last_error`) of `side`, the panel + /// the Open action was invoked on — not whichever panel is active when + /// the late reply arrives — exactly as `Command::EditorOpenFailed` + /// reports a failed editor open (file-action-menu "Open with the + /// associated application": "No association is reported inline"; + /// offload-blocking-io design D7). + OpenWithAssociationFailed { + side: PanelSide, + message: String, + }, + + // Clipboard export (clipboard-export). Reachable from Ctrl+C/Ctrl+Ins + // and Ctrl+Shift+Ins over the panels, the Files pull-down's three-item + // group, and the file-action menu's `Send to clipboard` (which always + // requests `Files`, scoped to its single target entry rather than + // `active_selection_sources` — see `activate_file_action`). + /// Places the F5-scope entries (`active_selection_sources`) on the OS + /// clipboard per `kind` (clipboard-export "Clipboard payloads and + /// scope"). A no-op, reported in the mini-status, when the scope is + /// empty (nothing selected and the cursor is on `..` or off the list). + CopyToClipboard(ClipboardPayloadKind), + /// Reply to `Effect::SetClipboard` once the TUI's `Clipboard` trait has + /// run. `payload` echoes back the request (the resolved paths are + /// needed to render the singular "Path copied: ``" message without + /// re-deriving scope); `fell_back_to_paths` is set only when a `Files` + /// request was carried out as a plain-text `Paths` write because file + /// objects aren't supported on this platform (clipboard-export + /// "Non-Windows fallback"). `Ok(())` shows the per-kind success + /// message; `Err(message)` shows `message` verbatim in the error role + /// (clipboard-export "Clipboard busy retry" — the TUI retries before + /// giving up, so by the time this arrives the failure is final). + ClipboardResult { + payload: ClipboardPayload, + fell_back_to_paths: bool, + result: Result<(), String>, + }, + + // Mouse (mouse-basics). `input::map_mouse` is the only source of these — + // raw coordinates and `crossterm::event::KeyModifiers` never reach this + // crate (mouse-input "Hit-testing stays in the TUI"; design D2). + /// A left-click on an entry row: focuses `side` and moves its cursor to + /// the named entry, without changing the selection set (`Plain`), or + /// toggles that entry's selection in place (`Ctrl`) — never both + /// (mouse-input "Click focuses and places the cursor", "Ctrl+click + /// toggles selection"). `input::map_mouse` also turns a same-row + /// double-click into a plain `Command::Enter` instead of this (mouse- + /// input "Double-click acts as Enter"). + ClickEntry { + side: PanelSide, + name: OsString, + mods: ClickMods, + }, + /// A left-click on a panel's title or blank body area: focuses `side` + /// without moving its cursor (mouse-input "Click focuses and places the + /// cursor"). + FocusPanel(PanelSide), + /// A wheel notch (or several, coalesced — design D7) over `side`'s + /// panel: move its cursor by `delta` rows (positive down, negative up), + /// independent of which panel is active (mouse-input "Wheel moves the + /// cursor of the panel under the pointer"). The viewer/editor have their + /// own wheel handling that never reaches this command (design D6). + ScrollPanel { + side: PanelSide, + delta: isize, + }, + /// A left-click on a function-key-bar slot (1..=10, `10` for F10): + /// dispatches exactly what that F-key would (mouse-input "Key bar, menu + /// bar, pull-down items, and dialog buttons are clickable"). + KeybarPress(u8), + /// A left-click on a menu-bar title: opens that pull-down (or switches + /// to it, if a different one is already open). + MenuTitleClick(MenuId), + /// A left-click on an open pull-down's `index`'th row: activates it + /// exactly as `Command::MenuActivate` would once the highlight is there. + MenuItemClick(usize), + /// A left-click on a dialog button (or a buttonless dialog's hotkey text + /// span — design D2): activates it exactly as the equivalent key would, + /// via [`button_command`]. + DialogButtonClick(ButtonId), + /// A right-click on an entry row: moves the cursor to the named entry + /// and opens the file-action menu for it (mouse-input "Right-click opens + /// the action menu"). `input::map_mouse` reaches this only for files + /// until the directory-target support of `file-action-menu` "Directory + /// targets and selection-scoped invocation" lands (mouse-basics section + /// 3). + OpenActionMenuAt { + side: PanelSide, + name: OsString, + }, + + // Mouse drag-and-drop (mouse-panel-drag; design D2/D4). `op` on each of + // these is the verb the drag's button/modifiers propose right now, + // recomputed by the TUI on every drag/release event and carried fresh + // rather than derived once and cached (design D2: "recomputed ... on + // every drag event"). `target`/`candidate` are the TUI's raw geometric + // hit-test result — a `DropTarget` or `None` for "off any potential + // target region" — which `update` is responsible for validating (mouse- + // drag "Valid drop targets"); core never receives raw coordinates. + /// A press on an entry row moved at least one cell (mouse-basics' + /// `press.moved`, mouse-panel-drag's territory): freezes the drag's + /// items — `name`'s panel's selection set if `name` is a member of it, + /// else `name` alone, never `..` — and its initial proposed verb + /// (mouse-drag "Drag lifecycle"). A no-op if `name` no longer names a + /// selectable entry. + DragBegin { + side: PanelSide, + name: OsString, + op: JobKind, + }, + /// The drag's pointer resolved to a new de-duplicated position (the + /// TUI's `MouseTracker` owns de-duplication, so core only sees actual + /// changes — design D4). A no-op if no drag is in progress. + DragOver { + op: JobKind, + target: Option, + }, + /// The button released. Opens the drop-initiated destination dialog if + /// the drag's target is (still) valid and the source panel still shows + /// the captured directory; otherwise ends the drag with no effect + /// (mouse-drag "Release on an invalid spot", "Robust against listing + /// changes"). A no-op if no drag is in progress. + DragDrop { + op: JobKind, + }, + /// Esc mid-drag: ends the drag with no effect (mouse-drag "Esc + /// cancels"). + DragCancel, +} + +/// Which of the three clipboard actions a `Command::CopyToClipboard` / +/// `Effect::SetClipboard` targets (clipboard-export "Clipboard payloads and +/// scope"). `Names` is menu-only — reachable only through the Files +/// pull-down, never a key binding (design D2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClipboardPayloadKind { + /// `CF_HDROP` file objects on Windows; falls back to `Paths`-as-text + /// elsewhere (clipboard-export "Windows file-object payload", + /// "Non-Windows fallback"). + Files, + /// One absolute path per line, plain text. + Paths, + /// One file name per line, plain text. + Names, +} + +/// The resolved payload for an `Effect::SetClipboard`: the action kind and +/// the absolute, `..`-excluded paths of the F5-scope entries +/// (`active_selection_sources`/`named_entry_source`) it was built from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipboardPayload { + pub kind: ClipboardPayloadKind, + pub items: Vec, +} + +/// A side-effect request. `update` only ever returns these; it never +/// performs them. The TUI event loop executes them (spawning worker +/// threads, suspending the terminal, exiting the process, ...). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Effect { + /// Read `path` on a worker thread and stream it back as + /// `Command::ListingChunk`/`ListingComplete`/`ListingFailed`, each + /// echoing `request` — the id minted for this listing and recorded as + /// the panel's `PanelState::listing_request` (listing-request-guard + /// design D1). The TUI registers a cancel flag under `request` so a + /// later `CancelRequest(request)` can stop the read. + StartListing { + panel: PanelSide, + path: PathBuf, + request: u64, + }, + /// Stop the background work issued under this request id, if it is + /// still running: a directory listing (`StartListing`), a find-file + /// subtree walk (`FindInSubtree`), or a viewer F7 search + /// (`RunViewerSearch`). The reducer emits it whenever it supersedes or + /// abandons one of those requests; the TUI flips the matching cancel + /// flag and the worker goes silent — it sends nothing further, not even + /// a terminal reply. An id with no running worker (already finished, + /// or never cancellable) is a silent no-op, so the reducer never needs + /// to know whether the worker is still alive (listing-request-guard + /// design D3). + CancelRequest(u64), + Quit, + RunJob(Job), + CancelJob, + SendConflictReply(ConflictChoice), + SendErrorReply(ErrorChoice), + /// Suspend the TUI, run this invocation on the real terminal, wait for a + /// keypress, then restore and redraw. + /// The invocation to run, plus the panel side to re-read once the TUI + /// resumes — the shell spawn is inherently a TUI-side effect, so the + /// re-read command has to travel with it rather than reaching `update` + /// on its own (§ "Run command" — "the active panel is re-read"). + RunShellCommand(shell::Invocation, PanelSide), + /// Launch `path` through the platform's file association — + /// `ShellExecuteW` with the `open` verb on Windows, detached + /// `xdg-open`/`open` elsewhere — with `cwd` (the menu's panel directory) + /// as the working directory. Fire-and-forget: the TUI is never + /// suspended, the launched program is never waited on, and no panel + /// re-read is scheduled (open-with-associated-app design D1/D3; + /// file-action-menu "Open with the associated application"). Runs on a + /// worker thread — with COM initialized apartment-threaded on Windows — + /// so a slow handler never freezes the panels; a launch failure comes + /// back as `Command::OpenWithAssociationFailed` for `side`, the panel + /// the menu acted on (offload-blocking-io design D7). + OpenWithAssociation { + side: PanelSide, + path: PathBuf, + cwd: PathBuf, + }, + /// Check what exists at `path` — the resolved target of a command-line + /// `cd`/`del`/`rmdir` that is not already listed in the panel — on a + /// worker thread, through the `\\?\` long-path abstraction, and reply + /// with `Command::PathProbed { request, .. }`. The reducer never queries + /// the filesystem itself, so an unreachable share blocks only the worker + /// (application-shell "The reducer performs no filesystem I/O"; + /// offload-blocking-io design D1/D2). + ProbePath { + request: u64, + path: PathBuf, + }, + /// Leave the alternate screen to expose the host terminal's scrollback + /// until any key is pressed. + ShowScrollback, + /// Rewrite `history.json` atomically with the command history and + /// directory frecency together (fuzzy-jump "Directory history + /// persistence"; design D6). + PersistHistory(config::HistoryFile), + /// Rewrite `config.toml`'s `theme =` key atomically after the Options → + /// Themes picker applies a theme (theme-selection "Applied theme + /// persists to configuration"). + PersistTheme(String), + /// Rewrite `config.toml`'s `panel_split =` key atomically after a + /// successful split adjustment or reset (panel-split "Split + /// persistence to configuration"). + PersistPanelSplit(u16), + /// Read the logical-drive bitmask (cheap, synchronous) and feed the + /// letters back as `DriveListReady` before the next paint. + EnumerateDrives(PanelSide), + /// Fetch one drive's volume label on a worker thread. `generation` + /// travels with the result so a reply from a since-superseded dialog + /// session can be told apart from the current one. + FetchDriveLabel { + target: PanelSide, + letter: char, + generation: u64, + }, + /// Gather the Info panel's async values on a worker thread. `request` + /// travels with the result so a reply from a since-superseded query can + /// be told apart from the current one. + QueryInfo { + panel: PanelSide, + path: PathBuf, + request: u64, + }, + /// Run `git_info::query` for `path` on a dedicated worker thread — + /// never the shared Info worker, since a slow status call on one panel + /// must not head-of-line-block the other (design D3) — and report the + /// result back via `Command::GitInfoResolved`. `request` travels with + /// the result so a stale or timed-out reply can be told apart from the + /// query that's still current (git-info "Background repository + /// detection", "Pathspec-scoped status queries"). + QueryGitInfo { + panel: PanelSide, + path: PathBuf, + request: u64, + }, + /// Read `path`'s immediate child directories for Tree-mode expansion + /// (`listing::list_child_dirs`) on a worker thread, reporting them back + /// via `Command::TreeNodeExpanded` — one directory's worth of I/O per + /// call, never a recursive scan (additional-panel-modes "Children read + /// on expand"; design D7). + ExpandTreeNode { + panel: PanelSide, + path: PathBuf, + }, + /// Open `path` for the F3 viewer on a worker thread and report back its + /// length via `Command::ViewerOpened`, or the failure via + /// `Command::ViewerOpenFailed`, each echoing `request`. The open is O(1) + /// in work but not in latency — a dropped SMB session blocks the open + /// for the redirector timeout — so it runs off the event loop and the + /// panel shows `Opening …` meanwhile (viewer: Instant open; + /// application-shell "Slow storage never blocks the event loop"; + /// offload-blocking-io design D4/D5). + OpenViewer { + request: u64, + path: PathBuf, + }, + /// Run the F7 streaming search from `start_offset` for `pattern` + /// against `path`, reporting the match back via + /// `Command::ViewerSearchResult` (viewer: F7 streaming search). + /// `request` must be echoed back unchanged in the `ViewerSearchResult` + /// reply so a stale reply can be told apart from the current search. + RunViewerSearch { + path: PathBuf, + start_offset: u64, + pattern: Vec, + request: u64, + }, + /// Suspend the TUI, launch the external editor per `EditorInvocation`, + /// wait for it to exit, then restore and re-read the panel side that + /// owned the cursor entry — the same suspend/restore seam + /// `RunShellCommand` uses (design D7; external-editor: TUI suspend and + /// restore, Synchronous wait and panel re-read). + RunExternalEditor(EditorInvocation, PanelSide), + /// Open `path` for the F4 built-in editor on a worker thread: stat it + /// and, under the 10 MB cap, read and decode it in full, reporting back + /// via `Command::EditorOpened`/`EditorTooLarge`/`EditorNotEditable`/ + /// `EditorOpenFailed`, each echoing `request`. The panel shows + /// `Opening …` while the read is outstanding (application-shell + /// "Slow storage never blocks the event loop"; offload-blocking-io + /// design D4). + OpenEditor { + request: u64, + path: PathBuf, + }, + /// Write `editor`'s buffer to disk via `EditorState::save` on a worker + /// thread, reporting the post-save state back via `Command::EditorSaved` + /// (or the failure via `Command::EditorSaveFailed`). `then_quit` travels + /// through unchanged so the reply knows whether to close the editor once + /// the write lands (builtin-editor "Save in place …", design D9). Only + /// one save is ever in flight — `EditorState::save_pending` gates the + /// editor until the reply — so no request id is needed (offload- + /// blocking-io design D6). + SaveEditor { + editor: Box, + then_quit: bool, + }, + /// Walk `root`'s subtree for entries whose name contains `pattern` on a + /// worker thread, streaming each match back via `Command::FindFileMatch` + /// as it's found and finishing with `Command::FindFileSearchDone` — + /// never blocking the UI event loop (find-file "Non-blocking search + /// with static progress"). `request` travels with every reply so a + /// stale/abandoned search's results are dropped once the dialog has + /// moved on. + FindInSubtree { + root: PathBuf, + pattern: String, + request: u64, + }, + /// Write `payload` to the OS clipboard via the TUI's `Clipboard` trait + /// and report the outcome back via `Command::ClipboardResult`. Executed + /// synchronously, like `EnumerateDrives` — `OpenClipboard` binds to the + /// calling thread and set-and-close is sub-millisecond, so no worker + /// thread is needed (design D3; clipboard-export "Clipboard payloads + /// and scope"). + SetClipboard(ClipboardPayload), + /// Read the OS clipboard's text via the TUI's `Clipboard` trait and feed + /// it back as `Command::TextEdit(TextEditOp::Paste)` for whichever field + /// owns the keyboard when it arrives (text-field-editing "Paste into a + /// text field"; design D4). A clipboard holding no text feeds nothing + /// back — the key is then a no-op. Synchronous like `SetClipboard`. + ReadClipboardText, +} diff --git a/crates/filecommand-core/src/update/editor.rs b/crates/filecommand-core/src/update/editor.rs new file mode 100644 index 0000000..30a432c --- /dev/null +++ b/crates/filecommand-core/src/update/editor.rs @@ -0,0 +1,268 @@ +//! The F4 editors: the external editor spawn, the built-in editor's open and +//! its command handling, and the user-menu file edit. + +use super::*; + +// --------------------------------------------------------------------- +// F4 external editor (M4) +// --------------------------------------------------------------------- + +/// F4 from a panel: resolve the cursor entry against the configured +/// `editor =` command and either dispatch the spawn effect or surface why +/// not (external-editor: F4 launches the editor, Config-driven external +/// editor command). +pub(super) fn handle_request_external_editor(state: &mut State) -> Vec { + let side = state.active; + let panel = state.panel(side); + let Some(entry) = panel.selected() else { return vec![] }; + let is_dir = entry.is_dir_like(); + let name = entry.name.clone(); + let cwd = panel.cwd.clone(); + match external_editor::resolve(state.editor.as_deref(), &cwd, &name, is_dir) { + Ok(invocation) => { + // Mirrors the `Command::ViewerOpened` clear: a prior failed F3/F4 + // attempt may have left `last_error` set, and successfully + // dispatching the editor spawn has clearly moved past it — the + // mini-status line must not keep showing the stale message while + // the editor runs (§7 error policy). The eventual successful + // return also clears it again via `Command::RereadPanel` -> + // `begin_new_listing`, but that round trip only happens once the + // editor process exits, so this clears it immediately rather + // than leaving the stale message up for the whole edit session. + state.panel_mut(side).last_error = None; + vec![Effect::RunExternalEditor(invocation, side)] + } + Err(external_editor::TargetError::Unconfigured) => { + state.panel_mut(side).last_error = Some(external_editor::NO_EDITOR_CONFIGURED_MESSAGE.to_string()); + vec![] + } + // The directory case is silently ignored, matching how F5/F6/F8 + // are no-ops with nothing eligible selected. + Err(external_editor::TargetError::IsDirectory) => vec![], + } +} + +// --------------------------------------------------------------------- +// F4 built-in editor (M5) +// --------------------------------------------------------------------- + +/// F4 from a panel: the external editor takes precedence when configured +/// (builtin-editor "External editor takes precedence") — reusing +/// `handle_request_external_editor`'s own entry/directory resolution rather +/// than duplicating it. Otherwise, on a file (not `..`, not a directory), +/// kick off the size-gated open; the size cap itself is enforced by +/// `EditorState::open` on the TUI side once `Effect::OpenEditor` runs +/// (builtin-editor "Editor invocation and size cap"). +pub(super) fn handle_request_editor(state: &mut State) -> Vec { + if state.editor.is_some() { + return handle_request_external_editor(state); + } + let side = state.active; + let panel = state.panel(side); + let Some(entry) = panel.selected() else { return vec![] }; + if entry.is_dir_like() { + return vec![]; + } + let path = panel.cwd.join(&entry.name); + vec![request_open(state, side, PendingOpenKind::Editor, path)] +} + +/// The editor body's visible row count for a given terminal height: the +/// header and F-key bar each reserve one row, exactly like the viewer's +/// `body_rows` (kept as a one-line duplicate here rather than a cross-crate +/// dependency, since `core::update` needs it for `ensure_caret_visible` and +/// cannot depend on `filecommand-tui`). +pub(super) fn editor_viewport(term_size: (u16, u16)) -> (usize, usize) { + (term_size.1.saturating_sub(2).max(1) as usize, term_size.0.max(1) as usize) +} + +/// Drive every command while the editor is open (gated in [`update`] by +/// `UiPhase::Editor`). Saving is the one operation here that needs I/O +/// (`EditorState::save` writes to disk), so it is dispatched as +/// `Effect::SaveEditor` and applied only once `Command::EditorSaved`/ +/// `EditorSaveFailed` reply — mirroring how `handle_viewer` defers the +/// viewer's own I/O to effects and re-enters through their replies (design +/// D1/D2). +pub(super) fn handle_editor(state: &mut State, cmd: Command) -> Vec { + let mut effects = Vec::new(); + let UiPhase::Editor(mut editor) = std::mem::replace(&mut state.phase, UiPhase::Panels) else { + unreachable!("handle_editor only called when phase is Editor"); + }; + let (rows, cols) = editor_viewport(state.term_size); + // While a save is in flight on the worker thread, only its own reply + // gets through: edits, caret moves and further save/quit commands are + // ignored, so the buffer the worker is writing is exactly the buffer + // the user sees marked clean when `EditorSaved` lands (application- + // shell "Saving to a slow share shows progress and accepts no edits"; + // offload-blocking-io design D6). + if editor.save_pending && !matches!(cmd, Command::EditorSaved { .. } | Command::EditorSaveFailed { .. }) { + state.phase = UiPhase::Editor(editor); + return effects; + } + match cmd { + Command::EditorChar(c) => editor.type_char(c), + Command::EditorNewline => editor.insert_newline(), + Command::EditorBackspace => editor.backspace(), + Command::EditorMove(m) => match m { + EditorMove::Left => editor.move_left(), + EditorMove::Right => editor.move_right(), + EditorMove::Up => editor.move_up(), + EditorMove::Down => editor.move_down(), + EditorMove::Home => editor.move_home(), + EditorMove::End => editor.move_end(), + EditorMove::PageUp(rows) => editor.move_page_up(rows), + EditorMove::PageDown(rows) => editor.move_page_down(rows), + }, + Command::EditorToggleMode => editor.toggle_mode(), + Command::EditorMark => editor.start_mark(), + Command::EditorCut => editor.cut_selection(), + Command::EditorCopy => editor.copy_selection(), + Command::EditorPaste => editor.paste(), + Command::EditorUndo => editor.undo(), + + Command::EditorSearchStart => editor.search_prompt = Some(String::new()), + Command::EditorSearchChar(c) => { + if let Some(p) = &mut editor.search_prompt { + p.push(c); + } + } + Command::EditorSearchBackspace => { + if let Some(p) = &mut editor.search_prompt { + p.pop(); + } + } + Command::EditorSearchCancel => editor.search_prompt = None, + Command::EditorSearchConfirm => { + if let Some(pattern) = editor.search_prompt.take() { + if !pattern.is_empty() { + editor.find_next(&pattern); + } + } + } + + Command::EditorReplaceStart => editor.replace_prompt = Some(ReplacePrompt::Pattern(String::new())), + Command::EditorReplaceChar(c) => { + if let Some(prompt) = &mut editor.replace_prompt { + match prompt { + ReplacePrompt::Pattern(p) => p.push(c), + ReplacePrompt::Replacement { replacement, .. } => replacement.push(c), + } + } + } + Command::EditorReplaceBackspace => { + if let Some(prompt) = &mut editor.replace_prompt { + match prompt { + ReplacePrompt::Pattern(p) => { + p.pop(); + } + ReplacePrompt::Replacement { replacement, .. } => { + replacement.pop(); + } + } + } + } + Command::EditorReplaceCancel => editor.replace_prompt = None, + // Enter: the pattern stage advances to the replacement stage (an + // empty pattern instead cancels the prompt outright — nothing to + // search for); the replacement stage performs the replacement + // (builtin-editor "Replace substitutes a match"). + Command::EditorReplaceConfirm => { + if let Some(prompt) = editor.replace_prompt.take() { + match prompt { + ReplacePrompt::Pattern(pattern) if !pattern.is_empty() => { + editor.replace_prompt = Some(ReplacePrompt::Replacement { pattern, replacement: String::new() }); + } + ReplacePrompt::Pattern(_) => {} + ReplacePrompt::Replacement { pattern, replacement } => { + editor.replace_first(&pattern, &replacement); + } + } + } + } + + Command::EditorSave => { + effects.push(Effect::SaveEditor { editor: Box::new(editor.clone()), then_quit: false }); + editor.save_pending = true; + } + Command::EditorSaved { editor: saved, then_quit } => { + if then_quit { + // Phase was already reset to `Panels` by the `mem::replace` + // above; leaving it as-is is the exit. + return effects; + } + editor = *saved; + editor.save_pending = false; + editor.quit_confirm = false; + } + Command::EditorSaveFailed { message } => { + editor.save_pending = false; + editor.save_error = Some(message); + editor.quit_confirm = false; + } + + Command::EditorRequestQuit => { + if editor.is_modified() { + editor.quit_confirm = true; + } else { + // Unmodified: exits directly (builtin-editor "Quitting an + // unmodified buffer exits directly") — phase stays `Panels`. + return effects; + } + } + Command::EditorConfirmQuitSave => { + effects.push(Effect::SaveEditor { editor: Box::new(editor.clone()), then_quit: true }); + editor.save_pending = true; + } + Command::EditorConfirmQuitDiscard => return effects, + Command::EditorCancelQuit => editor.quit_confirm = false, + + _ => {} + } + // While the placeholder hides the editor, `term_size` is a one- or + // two-row window; re-scrolling to it would wreck the viewport for a + // reply (`EditorSaved`/`EditorSaveFailed`) the user never sees. The + // `Resize` arm re-runs this once the terminal is back above the floor + // (design D6). + if !state.undersized { + editor.ensure_caret_visible(rows, cols); + } + state.phase = UiPhase::Editor(editor); + effects +} + +// --------------------------------------------------------------------- +// Commands → Menu file edit (user-menu "Edit the user menu file from the +// Commands menu"; design D4) +// --------------------------------------------------------------------- + +/// Open `usermenu.toml` (the path the app resolved at startup, kept in +/// `State::user_menu_path`) via the F4 editor path: the configured external +/// editor when `editor =` is set, else the built-in editor — the same branch +/// [`handle_request_editor`] takes, with the menu file supplied instead of +/// the cursor entry. The TUI reloads the menu when the editor returns and +/// feeds the result back as `Command::UserMenuReloaded`. +pub(super) fn handle_edit_user_menu_file(state: &mut State) -> Vec { + let path = state.user_menu_path.clone(); + if state.editor.is_some() { + let side = state.active; + let cwd = state.panel(side).cwd.clone(); + let file_arg = path.clone().into_os_string(); + return match external_editor::resolve(state.editor.as_deref(), &cwd, &file_arg, false) { + Ok(invocation) => { + // Mirrors `handle_request_external_editor`'s clear: a stale + // mini-status must not outlive a successful dispatch. + state.panel_mut(side).last_error = None; + vec![Effect::RunExternalEditor(invocation, side)] + } + Err(external_editor::TargetError::Unconfigured) => { + state.panel_mut(side).last_error = Some(external_editor::NO_EDITOR_CONFIGURED_MESSAGE.to_string()); + vec![] + } + // Unreachable (`is_dir` is `false`), kept explicit rather than + // `unreachable!` so the wildcard stays a silent no-op. + Err(external_editor::TargetError::IsDirectory) => vec![], + }; + } + let side = state.active; + vec![request_open(state, side, PendingOpenKind::Editor, path)] +} diff --git a/crates/filecommand-core/src/update/file_op.rs b/crates/filecommand-core/src/update/file_op.rs new file mode 100644 index 0000000..758a31a --- /dev/null +++ b/crates/filecommand-core/src/update/file_op.rs @@ -0,0 +1,728 @@ +//! File operations: the F5/F6/F7/F8 setup dialogs, the running-job dialogs +//! and their worker replies, clipboard export, the file-action menu's action +//! routing, and the source/selection and drag-and-drop helpers. + +use super::*; + +// --------------------------------------------------------------------- +// Clipboard export (clipboard-export) +// --------------------------------------------------------------------- + +/// Ctrl+C/Ctrl+Ins, Ctrl+Shift+Ins, and the Files pull-down's clipboard +/// group: resolve the F5 scope and either request the write (leaving +/// feedback to `apply_clipboard_result` once `Effect::SetClipboard` +/// replies) or, when the scope is empty, report that directly — the parent +/// pseudo-entry is never included, since `active_selection_sources` already +/// excludes it (clipboard-export "Clipboard payloads and scope": "Parent +/// entry is never copied"). +pub(super) fn handle_copy_to_clipboard(state: &mut State, kind: ClipboardPayloadKind) -> Vec { + let sources = active_selection_sources(state); + if sources.is_empty() { + set_clipboard_feedback(state, "Nothing to copy".to_string(), false); + return vec![]; + } + let items = sources.into_iter().map(|s| s.path).collect(); + vec![Effect::SetClipboard(ClipboardPayload { kind, items })] +} + +/// The file-action menu's `Send to clipboard`: always `Files`, scoped to +/// whatever `selection_or_single_source` resolved — the menu's single +/// target entry, or the whole selection set for a selection-scoped +/// invocation (design D3 of `file-action-menu`, D4 of `mouse-basics`, +/// mirrored here from `activate_file_action`'s existing Copy/Move/Delete +/// handling). A no-op if the target has already vanished from the panel. +pub(super) fn handle_send_target_to_clipboard(sources: Vec) -> Vec { + if sources.is_empty() { + return vec![]; + } + let items = sources.into_iter().map(|s| s.path).collect(); + vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items })] +} + +/// Reply to `Effect::SetClipboard`: render the outcome into the active +/// panel's mini-status feedback (clipboard-export "Clipboard feedback", +/// "Non-Windows fallback"). +pub(super) fn apply_clipboard_result(state: &mut State, payload: ClipboardPayload, fell_back_to_paths: bool, result: Result<(), String>) { + let (message, is_error) = match result { + Err(message) => (message, true), + Ok(()) => (clipboard_success_message(&payload, fell_back_to_paths), false), + }; + set_clipboard_feedback(state, message, is_error); +} + +/// The success feedback template for a completed `ClipboardPayload` +/// (clipboard-export "Clipboard feedback"). `Paths` gets a singular +/// variant naming the one path copied; `Files`/`Names` always use the +/// plural template, matching the literal message set the requirement +/// enumerates. A `Files` request downgraded to a `Paths` text write on a +/// non-Windows host reports that explicitly instead (clipboard-export +/// "Non-Windows fallback"). +pub(super) fn clipboard_success_message(payload: &ClipboardPayload, fell_back_to_paths: bool) -> String { + if fell_back_to_paths { + return "Paths copied (file objects unsupported here)".to_string(); + } + let n = payload.items.len(); + match payload.kind { + ClipboardPayloadKind::Files => format!("{n} files copied to clipboard"), + ClipboardPayloadKind::Paths if n == 1 => format!("Path copied: {}", payload.items[0].display()), + ClipboardPayloadKind::Paths => format!("{n} paths copied"), + ClipboardPayloadKind::Names => format!("{n} names copied"), + } +} + +/// Show `message` in the active panel's mini-status until the next key +/// press or `CLIPBOARD_FEEDBACK_MS` elapses, whichever comes first +/// (clipboard-export "Clipboard feedback"). +pub(super) fn set_clipboard_feedback(state: &mut State, message: String, is_error: bool) { + let side = state.active; + let expires_at_ms = state.clock_ms.saturating_add(CLIPBOARD_FEEDBACK_MS); + state.panel_mut(side).clipboard_feedback = Some(ClipboardFeedback { message, is_error, expires_at_ms }); +} + +// --------------------------------------------------------------------- +// File operations (M2) +// --------------------------------------------------------------------- + +/// Which selectable entry(ies) an F5/F6/F8 request applies to: the explicit +/// selection if non-empty, else the single entry under the cursor. +pub(super) fn active_selection_sources(state: &State) -> Vec { + let panel = state.active_panel(); + if !panel.selected.is_empty() { + return panel + .entries + .iter() + .filter(|e| panel.selected.contains(&e.name)) + .map(|e| SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }) + .collect(); + } + match panel.selected() { + Some(e) if e.kind != EntryKind::ParentDir => { + vec![SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }] + } + _ => vec![], + } +} + +/// F5/F6/F7: enter the destination/name-input setup dialog. A no-op for +/// Copy/Move when there is nothing selected; Mkdir is always available. +pub(super) fn enter_file_op_setup(state: &mut State, kind: JobKind) { + if kind == JobKind::Mkdir { + let side = state.active; + let source_dir = state.panel(side).cwd.clone(); + state.phase = UiPhase::FileOpSetup(FileOpSetup::DestinationInput { + kind, + sources: vec![], + source_dir, + input: LineEdit::default(), + buttons: None, + hidden_by_filter: 0, + }); + return; + } + let sources = active_selection_sources(state); + if sources.is_empty() { + return; + } + let side = state.active; + let prefill = state.panel(side.toggle()).cwd.display().to_string(); + enter_file_op_setup_for_sources(state, kind, sources, side, prefill, None); +} + +/// Shared by `enter_file_op_setup` (F5/F6, selection- or cursor-scoped), the +/// file-action menu's Copy/Move (cursor-entry-only, D3), and `Command:: +/// DragDrop` (mouse-panel-drag) — the setup dialog itself doesn't care which +/// chose its `sources`. `source_side`/`prefill` are explicit rather than +/// derived from `state.active`/its opposite panel internally, so a caller +/// acting on a side other than the active panel (a drag begun on the +/// inactive panel, which a press never focuses before the drag completes) +/// gets the right source directory and the right prefill without the dialog +/// silently assuming the active panel (operation-dialogs design D3). +/// `buttons` is `None` for every keyboard-reached caller (byte-identical to +/// the pre-`mouse-panel-drag` dialog) and `Some` only from `DragDrop`, which +/// also passes the exact drop path as `prefill` rather than the opposite +/// panel's cwd (operation-dialogs "Drop-initiated destination dialog"). +/// `hidden_by_filter` is computed from the *source* panel — for a drag begun +/// on the inactive panel, that panel's own filter and selection decide the +/// count (selection "Operations disclose selected entries hidden by the +/// quick filter"; filter-selection-safety design D2). +pub(super) fn enter_file_op_setup_for_sources( + state: &mut State, + kind: JobKind, + sources: Vec, + source_side: PanelSide, + prefill: String, + buttons: Option, +) { + let source_dir = state.panel(source_side).cwd.clone(); + let hidden_by_filter = state.panel(source_side).hidden_selected_count(&sources); + // A pre-filled destination opens with the caret at the end of the text, + // so Enter-to-accept is unchanged and editing starts from the tail + // (text-field-editing "Pre-filled field accepts unchanged with Enter"). + state.phase = + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input: LineEdit::from(prefill), buttons, hidden_by_filter }); +} + +/// F8: enter the delete-confirmation dialog. A no-op when there is nothing +/// selected. +pub(super) fn enter_delete_confirm(state: &mut State) { + let sources = active_selection_sources(state); + if sources.is_empty() { + return; + } + enter_delete_confirm_for_sources(state, sources); +} + +/// Shared by `enter_delete_confirm` (F8) and the file-action menu's Delete +/// (cursor-entry-only, D3). `hidden_by_filter` counts the sources that are +/// selected entries of the source (active) panel hidden by its quick filter +/// (selection "Operations disclose selected entries hidden by the quick +/// filter"; filter-selection-safety design D2). +pub(super) fn enter_delete_confirm_for_sources(state: &mut State, sources: Vec) { + let side = state.active; + let panel = state.panel(side); + let source_dir = panel.cwd.clone(); + let hidden_by_filter = panel.hidden_selected_count(&sources); + // A directory *link* listed by this panel looks like a non-empty + // directory but Delete removes only the link (file-operations "Delete + // removes the link, not the target"), so it does not trigger the + // "contains a directory — delete its entire contents?" second prompt + // (panel-navigation "Deleting a directory link asks once"; link-aware- + // listing design D5). Usually an empty set. + let listed_links: HashSet<&OsString> = panel.entries.iter().filter(|e| e.link).map(|e| &e.name).collect(); + let is_listed_link = |s: &SourceItem| s.path.parent() == Some(source_dir.as_path()) && listed_links.contains(&s.original_name); + let needs_second_confirm = sources.iter().any(|s| s.is_dir && !is_listed_link(s)); + state.phase = + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once: false, hidden_by_filter }); +} + +/// The file-action menu's Rename: enter the in-place rename input dialog, +/// pre-filled with the target's current name and the caret at the end of +/// the field (file-action-menu "In-place Rename": "an input dialog +/// pre-filled with the target entry's current name, with the caret at the +/// end of the field"). +pub(super) fn enter_rename_input(state: &mut State, original_name: OsString, is_dir: bool) { + let side = state.active; + let source_dir = state.panel(side).cwd.clone(); + let input = LineEdit::from(original_name.to_string_lossy().into_owned()); + state.phase = UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); +} + +/// Route an activated menu entry into its existing capability flow, applied +/// to `target_name` — the entry the menu was opened on, captured in +/// `FileActionMenuState` at open time and threaded through here rather than +/// re-read from the panel's live cursor, which can drift while the menu (a +/// modal overlay) is open: a background `ListingChunk`/`ListingComplete` is +/// applied unconditionally regardless of any open modal and resets the +/// cursor to row 0 whenever the panel hasn't seen a user-driven move since +/// the listing began (file-action-menu "Menu actions route to existing +/// flows"). `selection_scoped` — also captured at open time — widens +/// Copy/Move/Delete/Send to clipboard to the whole selection set when the +/// menu was opened on an already-selected entry (mouse-basics design D4; +/// file-action-menu "Directory targets and selection-scoped invocation"). +pub(super) fn activate_file_action(state: &mut State, action: FileActionMenuEntry, target_name: OsString, selection_scoped: bool) -> Vec { + let side = state.active; + match action { + // Open: hand the target to the platform's file association, + // fire-and-forget — the TUI stays live and nothing is waited on or + // re-read (open-with-associated-app design D1/D3; file-action-menu + // "Open with the associated application"). Always single-target, + // even when the target is selected: Open never widens to the + // selection (file-action-menu "Open ignores the selection"). + FileActionMenuEntry::Open => { + let Some(source) = named_entry_source(state, side, &target_name) else { return vec![] }; + let cwd = state.panel(side).cwd.clone(); + vec![Effect::OpenWithAssociation { side, path: source.path, cwd }] + } + // Run: the same suspended-shell spawn path a typed command line + // uses, and the only way to reach it now that Enter no longer + // spawns an executable directly (command-line: "Enter on an + // executable opens the menu instead of spawning"). Always + // single-target — Run has no selection-scoped meaning. + FileActionMenuEntry::Run => { + let Some(_) = named_entry_source(state, side, &target_name) else { return vec![] }; + let name = target_name.to_string_lossy().into_owned(); + let cwd = state.panel(side).cwd.clone(); + // Quoted so a name with spaces reaches the shell as one token. + let text = format!("\"{name}\""); + vec![Effect::RunShellCommand(shell::build_command(state.shell.shell.as_deref(), &text, &cwd), side)] + } + // View/Edit: the exact F3/F4 core paths, unchanged. + FileActionMenuEntry::View => handle_request_viewer(state), + FileActionMenuEntry::Edit => handle_request_editor(state), + // Copy/Move/Delete: the existing F5/F6/F8 setup dialogs, scoped to + // the whole selection when `selection_scoped`, else to the menu's + // target entry only (design D3/D4) — `enter_file_op_setup_for_sources`/ + // `enter_delete_confirm_for_sources` already title their dialog with + // `sources.len()`, so a selection-scoped invocation gets the count + // for free. + FileActionMenuEntry::Copy => { + let sources = selection_or_single_source(state, side, &target_name, selection_scoped); + if !sources.is_empty() { + let prefill = state.panel(side.toggle()).cwd.display().to_string(); + enter_file_op_setup_for_sources(state, JobKind::Copy, sources, side, prefill, None); + } + vec![] + } + FileActionMenuEntry::Move => { + let sources = selection_or_single_source(state, side, &target_name, selection_scoped); + if !sources.is_empty() { + let prefill = state.panel(side.toggle()).cwd.display().to_string(); + enter_file_op_setup_for_sources(state, JobKind::Move, sources, side, prefill, None); + } + vec![] + } + FileActionMenuEntry::Delete => { + let sources = selection_or_single_source(state, side, &target_name, selection_scoped); + if !sources.is_empty() { + enter_delete_confirm_for_sources(state, sources); + } + vec![] + } + // Rename: new in-place variant (file-action-menu "In-place Rename"). + // Always single-target — an in-place rename of several entries at + // once has no meaning, and the requirement only lists + // Copy/Move/Delete/Send to clipboard as selection-scoped. + FileActionMenuEntry::Rename => { + if let Some(source) = named_entry_source(state, side, &target_name) { + enter_rename_input(state, source.original_name, source.is_dir); + } + vec![] + } + // Send to clipboard: the `clipboard-export` Files action, scoped the + // same way Copy/Move/Delete are above — never mutates the filesystem + // (file-action-menu "Menu actions route to existing flows", "No + // mutation without an intervening dialog"). + FileActionMenuEntry::SendToClipboard => { + let sources = selection_or_single_source(state, side, &target_name, selection_scoped); + handle_send_target_to_clipboard(sources) + } + } +} + +/// Copy/Move/Delete/Send to clipboard's shared scope resolution: the whole +/// selection set via the same `active_selection_sources` F5/F6/Ctrl+C +/// already use when `selection_scoped`, else `target_name` alone — falling +/// back to the single target if the selection somehow emptied out while the +/// menu was open (mouse-basics design D4; file-action-menu "Directory +/// targets and selection-scoped invocation"). +pub(super) fn selection_or_single_source(state: &State, side: PanelSide, target_name: &OsStr, selection_scoped: bool) -> Vec { + if selection_scoped { + let sources = active_selection_sources(state); + if !sources.is_empty() { + return sources; + } + } + named_entry_source(state, side, target_name).into_iter().collect() +} + +/// The panel entry on `side` named `name` as a single-item `SourceItem`, +/// ignoring the panel's cursor position and selection entirely — used only +/// by the file-action menu, which targets the entry it was opened on +/// (captured by name in `FileActionMenuState`) regardless of any cursor +/// drift from a background listing refresh or any multi-entry selection +/// (design D3; file-action-menu "Enter on a file opens the action menu": +/// "SHALL NOT consume or alter the multi-entry selection"). Returns `None` +/// if the named entry is no longer present (e.g. deleted or renamed away by +/// something else while the menu was open) or is a directory-like pseudo +/// entry, matching the existing not-found-is-a-no-op behavior of every +/// other dialog-open path. +pub(super) fn named_entry_source(state: &State, side: PanelSide, name: &OsStr) -> Option { + let panel = state.panel(side); + let entry = panel.entries.iter().find(|e| e.name == name)?; + if entry.kind == EntryKind::ParentDir { + return None; + } + Some(SourceItem { original_name: entry.name.clone(), path: panel.cwd.join(&entry.name), is_dir: entry.is_dir_like() }) +} + +// --------------------------------------------------------------------- +// Mouse drag-and-drop (mouse-panel-drag) +// --------------------------------------------------------------------- + +/// The items a drag beginning on `side`'s entry named `name` freezes at +/// `DragBegin` (mouse-drag "Drag lifecycle"; design D4): the panel's +/// selection set if `name` is a member of it, else `name` alone. Empty (and +/// so a no-op for the caller) if `name` no longer names a selectable entry — +/// `named_entry_source` already excludes the `..` pseudo-entry, matching +/// "the parent-directory pseudo-entry SHALL never be dragged". Unlike +/// `selection_or_single_source` (used by the file-action menu, always +/// scoped to `state.active`), this is keyed by the drag's own source `side`: +/// a press-drag never changes `state.active` before the drag completes, so +/// the source panel need not be the active one at all. +pub(super) fn drag_selection_sources(state: &State, side: PanelSide, name: &OsStr) -> Vec { + let panel = state.panel(side); + if panel.selected.contains(name) { + panel + .entries + .iter() + .filter(|e| panel.selected.contains(&e.name)) + .map(|e| SourceItem { original_name: e.name.clone(), path: panel.cwd.join(&e.name), is_dir: e.is_dir_like() }) + .collect() + } else { + named_entry_source(state, side, name).into_iter().collect() + } +} + +/// The directory `target` currently names, re-resolved live against +/// whichever panel it points into — `None` if it no longer resolves to a +/// directory (mouse-drag "Robust against listing changes": "the target row +/// no longer resolves to a directory"). Never touches the filesystem: a +/// renamed-away or deleted row is indistinguishable here from one that never +/// existed, and both are simply not found. +pub(super) fn drop_target_path(state: &State, target: &DropTarget) -> Option { + match target { + DropTarget::PanelDir(side) => Some(state.panel(*side).cwd.clone()), + DropTarget::SubDir { side, name } => { + let panel = state.panel(*side); + let entry = panel.entries.iter().find(|e| &e.name == name)?; + match entry.kind { + // The `..` row: the panel's own parent, exactly like + // `Command::ParentDir` resolves it. + EntryKind::ParentDir => crate::panel::parent_path(&panel.cwd), + EntryKind::Directory => Some(panel.cwd.join(name)), + EntryKind::File => None, + } + } + DropTarget::TreeNode { path, .. } => Some(path.clone()), + DropTarget::Tab { side, index } => state.panel(*side).tab_dirs().get(*index).cloned(), + } +} + +/// Whether `candidate` is a valid drop target for `drag`'s frozen items +/// right now (mouse-drag "Valid drop targets"): the region it names must +/// still make sense for the panel's current display mode (Info/Quick View +/// panels, and a Tree node/subdirectory row that no longer matches the +/// panel's actual mode, are never targets), the target must still resolve +/// to a live directory ([`drop_target_path`]), and it must be neither the +/// items' own source directory nor equal to/inside a directory being +/// dragged. Shared by `DragOver` (validating the TUI's raw geometric hit +/// before it is allowed onto `state.drag.target`) and `DragDrop` +/// (re-validating at release, since a listing can change between the last +/// `DragOver` and the button-up — mouse-drag "Robust against listing +/// changes"). +pub(super) fn valid_drop_target(state: &State, drag: &DragState, candidate: &DropTarget) -> bool { + let region_ok = match candidate { + DropTarget::PanelDir(side) => !matches!(state.panel(*side).display_mode, DisplayMode::Info | DisplayMode::QuickView), + DropTarget::SubDir { side, .. } => matches!(state.panel(*side).display_mode, DisplayMode::Full | DisplayMode::Brief), + DropTarget::TreeNode { side, .. } => state.panel(*side).display_mode == DisplayMode::Tree, + // A tab always stands for its own directory, independent of + // whichever mode its panel's *active* tab currently renders in + // (design D7: "a tab in the strip stands for its directory"). + DropTarget::Tab { .. } => true, + }; + if !region_ok { + return false; + } + let Some(target_path) = drop_target_path(state, candidate) else { return false }; + // "A target equal to the items' own directory ... SHALL be invalid" + // (mouse-drag "Valid drop targets"). + if target_path == drag.source_dir { + return false; + } + // "... or equal to or inside a dragged directory, SHALL be invalid." + // Only dragged directories can have descendants to protect; a dragged + // file has none. + drag.items.iter().filter(|item| item.is_dir).all(|item| target_path != item.path && !target_path.starts_with(&item.path)) +} + +pub(super) fn panels_matching(state: &State, dirs: &[&PathBuf]) -> Vec { + let mut out = Vec::new(); + for side in [PanelSide::Left, PanelSide::Right] { + if dirs.iter().any(|d| &state.panel(side).cwd == *d) { + out.push(side); + } + } + out +} + +pub(super) fn handle_file_op(state: &mut State, cmd: Command) -> Vec { + let mut effects = Vec::new(); + match std::mem::replace(&mut state.phase, UiPhase::Panels) { + UiPhase::FileOpSetup(setup) => { + state.phase = handle_file_op_setup(setup, cmd, &mut effects); + } + UiPhase::FileOpRunning { source_dir, dest_dir, dialog } => { + state.phase = handle_file_op_running(state, source_dir, dest_dir, dialog, cmd, &mut effects); + } + UiPhase::FileOpSummary(skipped) => { + state.phase = match cmd { + Command::FileOpConfirm | Command::FileOpCancel => UiPhase::Panels, + _ => UiPhase::FileOpSummary(skipped), + }; + } + // A Job* event arrived outside FileOpRunning. Purely defensive: a + // resize can no longer bring this about (the running phase survives + // the placeholder — design D1), and the single-active-job design + // rules out a stray event otherwise; drop it rather than lose the + // phase we were actually in. + other => state.phase = other, + } + effects +} + +/// Shared by `Command::FileOpConfirm` (Enter — confirms with whatever `kind` +/// the dialog opened with) and `Command::FileOpConfirmAs` (the drop +/// dialog's `[ Copy ]`/`[ Move ]` buttons — confirms with an explicit `kind` +/// override instead): an empty/whitespace destination re-shows the same +/// dialog unchanged, exactly as before this was split out; otherwise builds +/// and dispatches the job. `buttons` and `hidden_by_filter` only ever ride +/// along unchanged — neither has any bearing on which job runs. +/// +/// The entered text resolves against the source panel's directory through +/// the same rules `cd` uses (`resolve_cd_target`), never against the +/// process's launch or per-drive current directory (operation-dialogs +/// "Relative destinations resolve against the source panel directory"; +/// design D1/D3). A destination that resolves to nothing (`..` at a drive +/// or UNC root) re-shows the dialog with its text intact, exactly like an +/// empty field. +pub(super) fn confirm_destination_input( + kind: JobKind, + sources: Vec, + source_dir: PathBuf, + input: LineEdit, + buttons: Option, + hidden_by_filter: usize, + effects: &mut Vec, +) -> UiPhase { + let trimmed = input.text().trim(); + if trimmed.is_empty() { + return UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }); + } + let Some(target) = resolve_cd_target(&source_dir, trimmed) else { + return UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }); + }; + let job = match kind { + // F7 takes a *name*, but the field is free-form, so it resolves the + // same way: the job then carries the resolved parent directory and + // the bare new name — a plain name resolves to `source_dir` + that + // name, exactly the job this arm has always built — and a panel + // showing that parent re-reads on completion. A result with no + // parent or file name (a drive or UNC root) names nothing to + // create, so the dialog stays open, like an empty field. + JobKind::Mkdir => { + let (Some(parent), Some(name)) = (target.parent(), target.file_name()) else { + return UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }); + }; + Job { kind, sources: vec![], source_dir: source_dir.clone(), dest_dir: parent.to_path_buf(), new_dir_name: Some(name.to_os_string()) } + } + _ => Job { kind, sources, source_dir: source_dir.clone(), dest_dir: target, new_dir_name: None }, + }; + let running = UiPhase::FileOpRunning { + source_dir: job.source_dir.clone(), + dest_dir: job.dest_dir.clone(), + dialog: RunningDialog::Progress { kind: job.kind, progress: ProgressInfo::starting(0, 0) }, + }; + effects.push(Effect::RunJob(job)); + running +} + +pub(super) fn handle_file_op_setup(setup: FileOpSetup, cmd: Command, effects: &mut Vec) -> UiPhase { + match setup { + FileOpSetup::DestinationInput { kind, sources, source_dir, mut input, buttons, hidden_by_filter } => match cmd { + Command::TextEdit(op) => { + input.apply(&op); + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }) + } + Command::RequestPaste => { + effects.push(Effect::ReadClipboardText); + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }) + } + Command::FileOpCancel => UiPhase::Panels, + // Enter: confirm with whatever `kind` the dialog opened with — + // the drop dialog's initially proposed verb, or the plain + // keyboard dialog's fixed Copy/Move/Mkdir. + Command::FileOpConfirm => confirm_destination_input(kind, sources, source_dir, input, buttons, hidden_by_filter, effects), + // The drop dialog's `[ Copy ]`/`[ Move ]` buttons: confirm with + // an explicit verb instead, overriding `kind` (operation-dialogs + // "Switching the verb in the dialog"). + Command::FileOpConfirmAs(explicit_kind) => { + confirm_destination_input(explicit_kind, sources, source_dir, input, buttons, hidden_by_filter, effects) + } + _ => UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons, hidden_by_filter }), + }, + FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once, hidden_by_filter } => match cmd { + Command::FileOpCancel => UiPhase::Panels, + Command::FileOpConfirm => { + if needs_second_confirm && !confirmed_once { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources, + source_dir, + needs_second_confirm, + confirmed_once: true, + hidden_by_filter, + }) + } else { + let job = + Job { kind: JobKind::Delete, sources, source_dir: source_dir.clone(), dest_dir: source_dir.clone(), new_dir_name: None }; + let running = UiPhase::FileOpRunning { + source_dir: job.source_dir.clone(), + dest_dir: job.dest_dir.clone(), + dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(0, 0) }, + }; + effects.push(Effect::RunJob(job)); + running + } + } + _ => UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, source_dir, needs_second_confirm, confirmed_once, hidden_by_filter }), + }, + FileOpSetup::RenameInput { source_dir, original_name, is_dir, mut input } => match cmd { + Command::TextEdit(op) => { + input.apply(&op); + UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }) + } + Command::RequestPaste => { + effects.push(Effect::ReadClipboardText); + UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }) + } + Command::FileOpCancel => UiPhase::Panels, + Command::FileOpConfirm => { + let trimmed = input.text().trim(); + if trimmed.is_empty() { + return UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); + } + // The name goes through the same resolver as every other + // destination (design D3): a plain name resolves to + // `source_dir` + that name, yielding exactly the job this + // arm has always built, while a drive-relative or + // `..`-carrying name no longer escapes onto the process's + // per-drive/launch directory. A name that resolves to + // nothing, or to something with no final component (a drive + // or UNC root), keeps the dialog open, like an empty field. + let Some(target) = resolve_cd_target(&source_dir, trimmed) else { + return UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); + }; + let (Some(parent), Some(name)) = (target.parent(), target.file_name()) else { + return UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }); + }; + let source = SourceItem { original_name: original_name.clone(), path: source_dir.join(&original_name), is_dir }; + let job = Job { + kind: JobKind::Rename, + sources: vec![source], + source_dir: source_dir.clone(), + dest_dir: parent.to_path_buf(), + new_dir_name: Some(name.to_os_string()), + }; + let running = UiPhase::FileOpRunning { + source_dir: job.source_dir.clone(), + dest_dir: job.dest_dir.clone(), + dialog: RunningDialog::Progress { kind: JobKind::Rename, progress: ProgressInfo::starting(0, 0) }, + }; + effects.push(Effect::RunJob(job)); + running + } + _ => UiPhase::FileOpSetup(FileOpSetup::RenameInput { source_dir, original_name, is_dir, input }), + }, + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn handle_file_op_running( + state: &mut State, + source_dir: PathBuf, + dest_dir: PathBuf, + dialog: RunningDialog, + cmd: Command, + effects: &mut Vec, +) -> UiPhase { + match cmd { + Command::FileOpCancelJob => { + effects.push(Effect::CancelJob); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog } + } + Command::FileOpConflictChoice(choice) => match dialog { + RunningDialog::Conflict { kind, progress, .. } => { + effects.push(Effect::SendConflictReply(choice)); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::FileOpBeginRename => match dialog { + RunningDialog::Conflict { kind, info, progress, .. } => UiPhase::FileOpRunning { + source_dir, + dest_dir, + dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(LineEdit::default()) }, + }, + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::TextEdit(op) => match dialog { + RunningDialog::Conflict { kind, info, progress, rename_input: Some(mut edit) } => { + edit.apply(&op); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(edit) } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::RequestPaste => match dialog { + RunningDialog::Conflict { kind, info, progress, rename_input: Some(edit) } => { + effects.push(Effect::ReadClipboardText); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: Some(edit) } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::FileOpConfirm => match dialog { + RunningDialog::Conflict { kind, progress, rename_input: Some(name), .. } if !name.text().trim().is_empty() => { + effects.push(Effect::SendConflictReply(ConflictChoice::Rename(OsString::from(name.text())))); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::FileOpCancel => match dialog { + RunningDialog::Conflict { kind, info, progress, rename_input: Some(_) } => { + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: None } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::FileOpErrorChoice(choice) => match dialog { + RunningDialog::Error { kind, progress, .. } => { + effects.push(Effect::SendErrorReply(choice)); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::JobProgress(info) => match dialog { + RunningDialog::Progress { kind, .. } => { + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, progress: info } } + } + other => UiPhase::FileOpRunning { source_dir, dest_dir, dialog: other }, + }, + Command::JobConflict(info) => { + let kind = dialog.kind(); + let progress = dialog.progress().clone(); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Conflict { kind, info, progress, rename_input: None } } + } + Command::JobError(info) => { + let kind = dialog.kind(); + let progress = dialog.progress().clone(); + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Error { kind, info, progress } } + } + Command::JobDone { outcome, .. } => { + let skipped = match outcome { + JobOutcome::Completed { skipped } => skipped, + JobOutcome::Cancelled { skipped } => skipped, + }; + for side in panels_matching(state, &[&source_dir, &dest_dir]) { + let path = state.panel(side).cwd.clone(); + effects.extend(begin_listing(state, side, path)); + } + // Background tabs (not currently displayed on either side) + // don't get the immediate re-read above — their cached listing + // would otherwise go silently stale. Mark any whose directory + // was touched by this job so activating them later (Alt+`n`, + // or the neighbor a Ctrl+W close falls back to) triggers a + // fresh read instead (file-operations "Automatic panel re-read + // on completion"; panel-tabs "Stale background tab refresh on + // activation"). `dest_dir == source_dir` for Delete/Rename, so + // this is a harmless duplicate match in that case, not a bug. + for side in [PanelSide::Left, PanelSide::Right] { + let panel = state.panel_mut(side); + panel.mark_background_tabs_stale(&source_dir); + panel.mark_background_tabs_stale(&dest_dir); + } + if skipped.is_empty() { + UiPhase::Panels + } else { + UiPhase::FileOpSummary(skipped) + } + } + _ => UiPhase::FileOpRunning { source_dir, dest_dir, dialog }, + } +} diff --git a/crates/filecommand-core/src/update/listing.rs b/crates/filecommand-core/src/update/listing.rs new file mode 100644 index 0000000..5526a72 --- /dev/null +++ b/crates/filecommand-core/src/update/listing.rs @@ -0,0 +1,338 @@ +//! Directory listings: starting one, folding its streamed replies into the +//! panel, git-info queries, and the viewport reconciliation every cursor- or +//! list-mutating path funnels through. + +use super::*; + +/// A panel's body entry-row count for a given terminal height, display mode, +/// and tab count — the same shape as `editor_viewport`, kept as a +/// duplicate of `filecommand-tui`'s `layout::compute` + +/// `views::panel::render` geometry rather than a cross-crate dependency, so +/// `core::update` can call [`PanelState::ensure_cursor_visible`] without +/// depending on `filecommand-tui` (design D2). Only the terminal height +/// matters here — the command-line and F-key bar rows are shared by both +/// panels regardless of the panel split, and `split_percent` only ever +/// changes each panel's *width*, which entry-row counting doesn't depend on +/// (Brief mode's column count, a width-derived quantity, is the +/// `additional-panel-modes` follow-up group's concern, not this one's). +/// +/// Mirrors, in order: `layout::compute`'s `panels_h` (terminal height minus +/// the one-row command line and one-row F-key bar), `views::panel::render`'s +/// `has_strip`/`reserved` (a tab strip, shown at 2+ tabs, costs a row beyond +/// the top+bottom border), and its `rows_h` per display mode — Full and Tree +/// both reserve a header row on top of the body, Brief does not. +/// +/// `pub` so `filecommand-tui` can also derive the PgUp/PgDn paging step from +/// it directly, in place of the layout-level `entries_visible` (which +/// over-counts by one when the tab strip is visible and mismatches Brief) — +/// one source of truth for both the viewport clamp and the paging step +/// (design D2's risk note). +pub fn panel_viewport_rows(term_size: (u16, u16), display_mode: DisplayMode, tab_count: usize) -> usize { + let panels_h = term_size.1.saturating_sub(2); + let has_strip = panels_h >= 4 && tab_count >= 2; + let reserved: u16 = if has_strip { 3 } else { 2 }; + let body_h = panels_h.saturating_sub(reserved); + let rows = match display_mode { + DisplayMode::Full | DisplayMode::Tree => body_h.saturating_sub(1), + _ => body_h, + }; + rows.max(1) as usize +} + +/// `side`'s panel interior width (the panel's rendered width, border +/// columns subtracted) for the current terminal width and split — mirrors +/// `layout::compute`'s `left_w`/`right_w` (via +/// `panel_split::effective_left_width`) and `views::panel::render`'s +/// `w.saturating_sub(2)`, kept as a duplicate here for the same reason +/// [`panel_viewport_rows`] is: `core::update` needs it (for Brief mode's +/// column count) without a cross-crate dependency on `filecommand-tui`. +pub(super) fn panel_interior_width(term_size: (u16, u16), split_percent: u16, side: PanelSide) -> u16 { + let left_w = panel_split::effective_left_width(split_percent, term_size.0); + let panel_w = match side { + PanelSide::Left => left_w, + PanelSide::Right => term_size.0.saturating_sub(left_w), + }; + panel_w.saturating_sub(2) +} + +/// Brief mode's column count for a given interior width: `max(1, +/// floor(interior_w / 12))`, byte-identical to `filecommand-tui`'s +/// `brief_column_widths`/`render_brief_body` formula (design D4; +/// additional-panel-modes "Brief mode column scrolling"). +pub(super) fn brief_column_count(interior_w: u16) -> usize { + ((interior_w / 12).max(1)) as usize +} + +/// Re-clamp `side`'s scroll offset against its current viewport — the one +/// call site every cursor-moving or list-mutating path funnels through +/// (design D6), so the renderer never has to reason about scrolling itself. +/// Dispatches to the mode-appropriate clamp: Brief's column-window +/// reconciliation, Tree's over its own `TreeState::scroll_offset` (a no-op +/// if Tree mode was already left before this ran), or Full/Info/QuickView's +/// line-wise `PanelState::ensure_cursor_visible`. +pub(super) fn reconcile_panel_viewport(state: &mut State, side: PanelSide) { + let panel = state.panel(side); + let display_mode = panel.display_mode; + let rows = panel_viewport_rows(state.term_size, display_mode, panel.tab_count()); + match display_mode { + DisplayMode::Brief => { + let interior_w = panel_interior_width(state.term_size, state.split_percent, side); + let cols = brief_column_count(interior_w); + state.panel_mut(side).ensure_cursor_visible_brief(rows, cols); + } + DisplayMode::Tree => { + if let Some(tree) = state.panel_mut(side).tree.as_mut() { + tree.ensure_cursor_visible(rows); + } + } + _ => { + state.panel_mut(side).ensure_cursor_visible(rows); + } + } +} + +/// The choke point every deliberate navigation of `side`'s directory flows +/// through — Enter into a directory, `..`, a typed `cd`, drive select, +/// Tree's own Enter-to-return, and the M5 fuzzy-jump/find-file dialogs — so +/// it is also where the Ctrl+J frecency history is recorded and persisted +/// (fuzzy-jump "Navigation records history"; design D6). Re-reads that are +/// not "the user navigating the active panel into a directory" bypass this +/// via [`begin_listing_inner`] directly — see its doc comment for the two +/// non-navigation callers. +pub(super) fn begin_listing(state: &mut State, side: PanelSide, path: PathBuf) -> Vec { + // Visits are stamped with wall-clock time so recency survives a restart + // (fuzzy-jump "Frecency ranking"; history-persistence-hardening D1). + quicksearch::record_visit(&mut state.dir_history, &path, state.epoch_ms); + let mut effects = begin_listing_inner(state, side, path, true); + effects.push(Effect::PersistHistory(config::HistoryFile { commands: state.history.clone(), directories: state.dir_history.clone() })); + effects +} + +/// Everything [`begin_listing`] does except the navigation bookkeeping (the +/// frecency visit and the history persist) — the entry point for re-reads +/// that are not "the user navigating the active panel into a directory". +/// Two callers qualify today: Tree mode's cursor-move preview of the +/// *opposite* panel (that is the tree being browsed, not a navigation), and +/// the stale-tab refresh on `SwitchTab`/`CloseTab` activation (the user +/// changed tabs, not directories — panel-tabs "Stale background tab refresh +/// on activation"). A third caller should prompt extracting a purpose-named +/// helper rather than diluting this contract. +/// +/// `capture_anchor` asks for the panel's cursor entry and scroll offset to +/// be snapshotted before the listing resets — which happens only when +/// `path` is the directory the panel is already showing, so the streamed +/// re-listing can restore the user's place (panel-navigation "Same- +/// directory re-read preserves cursor and scroll position"; reread- +/// preserves-cursor design D5). Every caller passes `true` except the +/// Tree-mode preview, whose opposite-panel re-listing is a navigation of +/// that panel, not a re-read the user asked for. +pub(super) fn begin_listing_inner(state: &mut State, side: PanelSide, path: PathBuf, capture_anchor: bool) -> Vec { + let mut effects = Vec::new(); + // A listing still being read is superseded by this one: its reader is + // told to stop, and its remaining replies — tagged with the id it was + // issued under — will fail the `listing_request` check below (panel- + // navigation "Superseded directory listings are discarded and + // cancelled"; listing-request-guard design D4). A completed listing has + // no worker left to stop. + if state.panel(side).progress.is_streaming() { + if let Some(previous) = state.panel(side).listing_request { + effects.push(Effect::CancelRequest(previous)); + } + } + if capture_anchor && path == state.panel(side).cwd { + state.panel_mut(side).capture_reread_anchor(); + } + state.panel_mut(side).begin_new_listing(path.clone()); + // A fresh id per listing, minted even for a re-read of the same path, so + // two quick Ctrl+Rs are two distinguishable generations (design D1). + let request = state.next_request_id(); + state.panel_mut(side).listing_request = Some(request); + effects.push(Effect::StartListing { panel: side, path: path.clone(), request }); + // A panel sitting in Info mode needs its drive/directory figures + // re-gathered for wherever it just landed. A fresh request id is + // minted even when the path is unchanged (e.g. a re-read), so an + // answer to a since-superseded query for the same path is still + // recognized as stale and dropped. + if state.panel(side).display_mode == DisplayMode::Info { + let request = state.next_request_id(); + state.panel_mut(side).info_request = Some(request); + effects.push(Effect::QueryInfo { panel: side, path: path.clone(), request }); + } + // Every navigation (and re-read) re-issues the git-info query for + // wherever the panel just landed, regardless of display mode — the + // branch suffix and marker column are independent of Info mode (git-info + // "Query re-issued on navigation") — unless the path is under a + // repository already marked "no info" this session. + effects.extend(git_info_query_effect(state, side, path)); + effects +} + +/// The `CancelRequest` for `side`'s active tab's listing when that listing +/// is still being read — what `SwitchTab`/`CloseTab` emit once the tab has +/// actually stopped being active (panel-tabs "Switching or closing a tab +/// mid-listing never mixes listings"; listing-request-guard design D4). +/// Empty when the active tab's listing is complete. +pub(super) fn leaving_tab_listing(state: &State, side: PanelSide) -> Option { + let p = state.panel(side); + if p.progress.is_streaming() { + p.listing_request.map(Effect::CancelRequest) + } else { + None + } +} + +/// The fresh read a stale tab gets the moment it becomes active, on +/// `SwitchTab`/`CloseTab`. Not a navigation (the user changed tabs, not +/// directories), so it goes through `begin_listing_inner`: a fresh read +/// without the frecency visit or history persist (panel-tabs "Stale +/// background tab refresh on activation"). It *is* a same-directory re-read +/// of the restored tab, so the anchor capture stays on — except when the +/// tab was left mid-listing: its progress is still `Streaming`, and +/// `capture_reread_anchor` would *clear* the anchor an interrupted re-read +/// stashed with the tab (it only anchors to a `Complete` listing). Skipping +/// the capture lets `begin_new_listing` keep that anchor for the same `cwd`, +/// so the refresh restores the place the interrupted re-read was restoring; +/// a tab interrupted mid-navigation has no anchor and starts from the top +/// (panel-tabs "An interrupted re-read still keeps the user's place"; +/// listing-request-guard design D6). +pub(super) fn refresh_activated_stale_tab(state: &mut State, side: PanelSide) -> Vec { + let path = state.panel(side).cwd.clone(); + let capture_anchor = !state.panel(side).progress.is_streaming(); + begin_listing_inner(state, side, path, capture_anchor) +} + +/// Mint a fresh generation id, record it as `side`'s outstanding git-info +/// request, and build the effect that runs the query on a worker thread +/// (git-info "Background repository detection"; design D3) — or, for a path +/// under a repository a timed-out query marked "no info" this session, +/// `None`: no query is issued there, and the panel's git info is explicitly +/// reset so a panel swapped in cannot keep showing branch/markers it +/// resolved before the timeout (git-info "Timed-out repository is not +/// queried again", "Swapping a panel into a timed-out repository"; +/// git-info-timeout-memory design D1). +pub(super) fn git_info_query_effect(state: &mut State, side: PanelSide, path: PathBuf) -> Option { + if state.git_no_info_roots.iter().any(|root| git_info::is_under_root(&path, root)) { + let p = state.panel_mut(side); + p.git_info = GitInfo::none(); + p.git_request = None; + return None; + } + let request = state.next_request_id(); + state.panel_mut(side).git_request = Some(request); + Some(Effect::QueryGitInfo { panel: side, path, request }) +} + +/// Whether a listing reply belongs to `panel`'s current listing — the one +/// whose id `begin_listing_inner` (or `State::initial`) recorded in +/// `listing_request`. A completed listing keeps its id, so a late reply +/// from an earlier generation still mismatches (listing-request-guard +/// design D2). +pub(super) fn listing_reply_is_current(state: &State, cmd: &Command) -> bool { + match cmd { + Command::ListingChunk { panel, request, .. } + | Command::ListingComplete { panel, request, .. } + | Command::ListingFailed { panel, request, .. } => state.panel(*panel).listing_request == Some(*request), + _ => false, + } +} + +/// The mini-status line for a listing that skipped `n > 0` entries whose +/// metadata could not be read: `1 entry could not be read`, or `N entries +/// could not be read` with N comma-grouped (link-aware-listing design D7). +pub(super) fn unreadable_message(n: usize) -> String { + if n == 1 { + "1 entry could not be read".to_string() + } else { + format!("{} entries could not be read", format_count(n)) + } +} + +pub(super) fn apply_listing_event(state: &mut State, cmd: Command) { + // A reply from a superseded listing is dropped before any mutation — + // entries, progress count, re-read anchor, find-file cursor target, + // selection reconcile — so a directory the user has already left never + // adds to, completes, or fails the listing that replaced it (panel- + // navigation "Superseded directory listings are discarded and + // cancelled"; listing-request-guard design D2). + if !listing_reply_is_current(state, &cmd) { + return; + } + match cmd { + Command::ListingChunk { panel, entries, .. } => { + let term_size = state.term_size; + let p = state.panel_mut(panel); + // One sort-and-merge per batch, never a per-entry insertion + // (panel-navigation "Large directories stream progressively and + // stay responsive"; streaming-listing-performance design D3). + p.merge_streamed(entries); + if let crate::panel::ListingProgress::Streaming { count } = &mut p.progress { + *count = p.entries.len(); + } + // A same-directory re-read re-anchors the cursor and offset as + // soon as a chunk containing the anchored entry lands, so the + // restored position holds while the listing streams instead of + // sitting on row 0 until completion (panel-navigation "No jump + // while the re-read streams"; reread-preserves-cursor design + // D2). A no-op when no anchor was captured. + let rows = panel_viewport_rows(term_size, p.display_mode, p.tab_count()); + p.apply_reread_anchor(rows); + // Keeps the pinned-to-top offset (0) while the user hasn't moved + // the cursor, exactly as `merge_streamed` already pins the + // cursor itself; re-clamps if the user had already moved it + // (panel-navigation "Streamed listing keeps the top pinned until + // the user moves"). In Brief mode this same call routes the + // restored offset through `ensure_cursor_visible_brief`, keeping + // it on a whole-column multiple (reread-preserves-cursor design + // D3). + reconcile_panel_viewport(state, panel); + } + Command::ListingComplete { panel, total, unreadable, .. } => { + let term_size = state.term_size; + let p = state.panel_mut(panel); + p.progress = crate::panel::ListingProgress::Complete { count: total }; + p.release_merge_scratch(); + // Entries the reader skipped are reported through the existing + // inline-message surface, in place of the cursored entry's + // details the mini-status otherwise restores on completion; + // `begin_new_listing` clears it on the panel's next listing + // (panel-navigation "One unreadable entry does not fail a + // listing", "Streaming listing mini-status"; link-aware-listing + // design D7). + if unreadable > 0 { + p.last_error = Some(unreadable_message(unreadable)); + } + p.clamp_cursor(); + p.reconcile_selection(); + // A find-file navigation seeds this with the matched entry's + // name (`update::handle_find_file`'s `FindFileConfirm`); once + // this directory's listing has actually landed, settle the + // cursor on it (find-file "Navigate to a chosen result" — + // "cursor positioned on the matched entry"). + let mut settled_on_target = false; + if let Some(name) = p.pending_cursor_target.take() { + if let Some(idx) = p.entries.iter().position(|e| e.name == name) { + p.cursor = idx; + p.cursor_user_moved = true; + settled_on_target = true; + } + } + // Find-file's deferred settle keeps precedence when both are set + // on the same listing (reread-preserves-cursor design D2); + // either way the anchor is consumed here — until now chunk-time + // applications re-resolved against it. + if !settled_on_target { + let rows = panel_viewport_rows(term_size, p.display_mode, p.tab_count()); + p.apply_reread_anchor(rows); + } + p.reread_anchor = None; + reconcile_panel_viewport(state, panel); + } + Command::ListingFailed { panel, message, .. } => { + let p = state.panel_mut(panel); + p.progress = crate::panel::ListingProgress::Complete { count: p.entries.len() }; + p.release_merge_scratch(); + p.last_error = Some(message); + } + _ => unreachable!("apply_listing_event only called for listing events"), + } +} diff --git a/crates/filecommand-core/src/update/overlays.rs b/crates/filecommand-core/src/update/overlays.rs new file mode 100644 index 0000000..10b8890 --- /dev/null +++ b/crates/filecommand-core/src/update/overlays.rs @@ -0,0 +1,688 @@ +//! The modal overlays that live beside the phase: the F9 menu, drive select, +//! fuzzy jump, find file, the F2 user menu, the Alt+F8 history dialog, the +//! theme picker, the file-action menu, Help, and the startup warning — plus +//! the menu-action, dialog-button and key-bar command tables. +//! +//! Every overlay's state lives in the one [`State::overlay`] slot +//! (reducer-overlay-structure design D1). The reducer takes the open overlay +//! out of the slot, asks [`Overlay::claims`] whether it handles the command, +//! and if so hands the variant's state to its handler here together with +//! `&mut State`; the [`OverlayStep`] the handler returns says whether the +//! overlay stays, closes, is replaced, or closes and re-enters with a +//! follow-up command (design D3). No handler ever runs without its own state. + +use super::*; + +/// The one modal overlay that can be open above the phase, held in +/// [`State::overlay`] (reducer-overlay-structure design D1). One variant per +/// overlay, so at most one of them is ever open by construction — the +/// property every open path used to maintain only by convention. The +/// quit-confirmation dialog is deliberately not a variant: it is a layer +/// above everything, and cancelling it must leave an open overlay exactly as +/// it was (application-shell "Quit request keys and confirmation"). The +/// file-operation dialogs, the viewer, and the editor stay [`UiPhase`] +/// variants, which are already exclusive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Overlay { + /// The F9 menu bar and its pull-down. + Menu(MenuState), + /// The Alt+F1/Alt+F2 drive-select dialog. + DriveSelect(DriveSelect), + /// The Ctrl+J fuzzy-jump dialog. + FuzzyJump(FuzzyJumpState), + /// The Alt+F7 find-file dialog. + FindFile(FindFileState), + /// The F2 user menu. + UserMenu(UserMenuState), + /// The Alt+F8 command-history dialog. + HistoryDialog(HistoryDialogState), + /// The Options → Themes picker. + ThemePicker(ThemePickerState), + /// The Enter-on-file action menu. + FileActionMenu(FileActionMenuState), + /// The F1 Help window, with its nested About dialog. + Help(HelpState), + /// The dismissable startup-warning modal (a malformed `usermenu.toml`, + /// an unknown `--theme`, a configuration-warning summary, …), which must + /// stay visible over whichever phase is showing, the splash included. + StartupWarning(String), +} + +impl Overlay { + /// Whether the open overlay handles `cmd` (design D4). Every variant's + /// command set lives in this one `match`; a command the open overlay does + /// not claim falls through to the layers below it exactly as it always + /// did — listing events, `Tick`, worker replies, and so on. `TextEdit` + /// and `RequestPaste` belong to the two overlays with a text field, and + /// the find-file walk's streamed replies belong to find file alone, so a + /// late result of a dismissed search can never touch another overlay + /// (application-shell "Modal overlays are mutually exclusive by + /// construction"). + pub fn claims(&self, cmd: &Command) -> bool { + match self { + Overlay::Menu(_) => matches!( + cmd, + Command::MenuOpen + | Command::MenuClose + | Command::MenuCollapse + | Command::MenuSelectPrev + | Command::MenuSelectNext + | Command::MenuPrevMenu + | Command::MenuNextMenu + | Command::MenuHotkey(_) + | Command::MenuActivate + | Command::MenuTitleClick(_) + | Command::MenuItemClick(_) + ), + Overlay::DriveSelect(_) => matches!(cmd, Command::DriveSelectMove(_) | Command::DriveSelectConfirm | Command::DriveSelectCancel), + Overlay::FuzzyJump(_) => matches!( + cmd, + Command::TextEdit(_) | Command::RequestPaste | Command::FuzzyJumpMove(_) | Command::FuzzyJumpConfirm | Command::FuzzyJumpCancel + ), + Overlay::FindFile(_) => matches!( + cmd, + Command::TextEdit(_) + | Command::RequestPaste + | Command::FindFileSubmit + | Command::FindFileMatch { .. } + | Command::FindFileSearchDone { .. } + | Command::FindFileMove(_) + | Command::FindFileConfirm + | Command::FindFileCancel + ), + Overlay::UserMenu(_) => matches!(cmd, Command::UserMenuMove(_) | Command::UserMenuConfirm | Command::UserMenuCancel), + Overlay::HistoryDialog(_) => matches!(cmd, Command::HistoryDialogMove(_) | Command::HistoryDialogConfirm | Command::HistoryDialogCancel), + Overlay::ThemePicker(_) => matches!(cmd, Command::ThemePickerMove(_) | Command::ThemePickerConfirm | Command::ThemePickerCancel), + Overlay::FileActionMenu(_) => matches!( + cmd, + Command::FileActionMenuMove(_) + | Command::FileActionMenuConfirm + | Command::FileActionMenuCancel + | Command::FileActionMenuHotkey(_) + | Command::FileActionMenuItemClick(_) + ), + Overlay::Help(_) => matches!(cmd, Command::HelpMove(_) | Command::HelpActivate | Command::HelpCancel), + Overlay::StartupWarning(_) => matches!(cmd, Command::DismissStartupWarning), + } + } + + /// Run the open overlay's handler for a command it [`claims`](Self::claims) + /// (design D3). The reducer has taken `self` out of [`State::overlay`], + /// so the slot is empty while the handler runs — which is what lets a + /// handler open a dialog or request a listing on `state` without a + /// second borrow of the overlay it is driving. + pub(super) fn handle(&mut self, state: &mut State, cmd: Command) -> OverlayStep { + match self { + Overlay::Menu(menu) => handle_menu(menu, state, cmd), + Overlay::DriveSelect(dialog) => handle_drive_select(dialog, state, cmd), + Overlay::FuzzyJump(dialog) => handle_fuzzy_jump(dialog, state, cmd), + Overlay::FindFile(dialog) => handle_find_file(dialog, state, cmd), + Overlay::UserMenu(menu) => handle_user_menu(menu, state, cmd), + Overlay::HistoryDialog(dialog) => handle_history_dialog(dialog, state, cmd), + Overlay::ThemePicker(picker) => handle_theme_picker(picker, state, cmd), + Overlay::FileActionMenu(menu) => handle_file_action_menu(menu, state, cmd), + Overlay::Help(help) => handle_help(help, state, cmd), + // The only command the warning claims is its dismissal (any key, + // per the one-shot dialogs' Esc/Enter-dismiss convention; + // user-menu "Malformed file warns and falls back without + // overwriting"). + Overlay::StartupWarning(_) => OverlayStep::Close(vec![]), + } + } +} + +/// What an overlay handler did with its overlay (design D3). The reducer +/// takes the overlay out of [`State::overlay`], hands the variant's state to +/// its handler together with `&mut State`, and restores the slot from the +/// step it returns — so no handler ever runs without its own state, and none +/// re-checks a gate that lives somewhere else. +#[derive(Debug)] +pub(super) enum OverlayStep { + /// The overlay stays open; put its (possibly updated) state back. + Keep(Vec), + /// The overlay closed (Esc, confirm, cancel); the slot stays empty. + Close(Vec), + /// A different overlay opens in its place: the user menu's Themes slot + /// opening the theme picker, or a menu-bar title click switching the + /// open pull-down. + Replace(Overlay, Vec), + /// The overlay closed and the reducer re-enters with a follow-up + /// command: an activated menu item, exactly as `handle_menu` used to + /// return `Option`. + CloseThen(Command), +} + +// --------------------------------------------------------------------- +// Drive select +// --------------------------------------------------------------------- + +fn handle_drive_select(dialog: &mut DriveSelect, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::DriveSelectMove(delta) => { + dialog.move_selection(delta); + OverlayStep::Keep(vec![]) + } + // The target panel keeps its current directory. + Command::DriveSelectCancel => OverlayStep::Close(vec![]), + Command::DriveSelectConfirm => { + let Some(letter) = dialog.selected_letter() else { return OverlayStep::Close(vec![]) }; + // An unavailable drive is not special-cased here: the listing + // read fails on the worker thread and surfaces through + // `ListingFailed` as the panel's inline error state. + let path = drives::drive_root(letter); + OverlayStep::Close(begin_listing(state, dialog.target, path)) + } + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// F9 menu +// --------------------------------------------------------------------- + +/// Drive the menu state machine. An activated item closes the menu and +/// re-enters the reducer with the command it stands for +/// (`OverlayStep::CloseThen`), so a menu action and its keyboard shortcut +/// share one implementation and the item's dialog opens into the slot the +/// menu just left (application-shell "A menu item opens its dialog in place +/// of the menu"). +fn handle_menu(menu: &mut MenuState, state: &mut State, cmd: Command) -> OverlayStep { + let active_side = state.active; + match cmd { + Command::MenuOpen | Command::MenuClose => OverlayStep::Close(vec![]), + Command::MenuCollapse => { + // Esc closes the pull-down first, leaving the bar open with its + // title still highlighted; a second Esc closes the bar. + if menu.pulldown_open { + menu.pulldown_open = false; + OverlayStep::Keep(vec![]) + } else { + OverlayStep::Close(vec![]) + } + } + Command::MenuSelectPrev => { + menu.move_selection(-1); + OverlayStep::Keep(vec![]) + } + Command::MenuSelectNext => { + menu.move_selection(1); + OverlayStep::Keep(vec![]) + } + Command::MenuPrevMenu => { + let target = menu.active.prev(); + menu.go_to(target); + OverlayStep::Keep(vec![]) + } + Command::MenuNextMenu => { + let target = menu.active.next(); + menu.go_to(target); + OverlayStep::Keep(vec![]) + } + Command::MenuHotkey(c) => { + if let Some(id) = MenuId::from_hotkey(c) { + menu.go_to(id); + } + OverlayStep::Keep(vec![]) + } + Command::MenuActivate => { + let Some(item) = menu.selected_item() else { return OverlayStep::Keep(vec![]) }; + let side = menu.active.target_side(active_side); + // Activating an item closes the whole overlay, restoring the top + // row and the clock, before the action runs. Disabled items never + // reach here, so an action with no command simply closes the bar. + match menu_action_command(item.action, side) { + Some(next) => OverlayStep::CloseThen(next), + None => OverlayStep::Close(vec![]), + } + } + // A click on a menu-bar title while the bar is open switches to that + // pull-down (or re-opens the same one), replacing the menu in place + // (mouse-input "Key bar, menu bar, pull-down items, and dialog + // buttons are clickable"). + Command::MenuTitleClick(id) => OverlayStep::Replace(Overlay::Menu(MenuState::for_menu(id)), vec![]), + // A click on the open pull-down's `index`'th row: highlight it (only + // if it names an enabled item) and activate it exactly like + // `MenuActivate`. An index that is out of range, a separator, or a + // disabled item is a no-op. + Command::MenuItemClick(index) => match menu.items().get(index) { + Some(MenuEntry::Item(item)) if item.is_enabled() => { + menu.selected = index; + let side = menu.active.target_side(active_side); + match menu_action_command(item.action, side) { + Some(next) => OverlayStep::CloseThen(next), + None => OverlayStep::Close(vec![]), + } + } + _ => OverlayStep::Keep(vec![]), + }, + _ => OverlayStep::Keep(vec![]), + } +} + +/// The command a menu item stands for. Disabled items never reach here, so +/// `Unimplemented` maps to nothing. +pub fn menu_action_command(action: MenuAction, side: PanelSide) -> Option { + match action { + MenuAction::ToggleInfoMode => Some(Command::ToggleInfoMode(side)), + MenuAction::SetDisplayMode(mode) => Some(Command::SetDisplayMode { side, mode }), + MenuAction::EnterTree => Some(Command::EnterTreeMode(side)), + MenuAction::SortBy(mode) => Some(Command::SetSortMode { side, mode }), + MenuAction::QuickFilter => Some(Command::QuickFilterStart(side)), + MenuAction::Reread => Some(Command::RereadPanel(side)), + MenuAction::DriveSelect => Some(Command::OpenDriveSelect(side)), + MenuAction::NewTab => Some(Command::OpenTab(side)), + MenuAction::CloseTab => Some(Command::CloseTab(side)), + MenuAction::View => Some(Command::RequestViewer), + MenuAction::Edit => Some(Command::RequestEditor), + MenuAction::Copy => Some(Command::RequestCopy), + MenuAction::Move => Some(Command::RequestMove), + MenuAction::Mkdir => Some(Command::RequestMkdir), + MenuAction::Delete => Some(Command::RequestDelete), + MenuAction::ClipboardFiles => Some(Command::CopyToClipboard(ClipboardPayloadKind::Files)), + MenuAction::ClipboardPaths => Some(Command::CopyToClipboard(ClipboardPayloadKind::Paths)), + MenuAction::ClipboardNames => Some(Command::CopyToClipboard(ClipboardPayloadKind::Names)), + MenuAction::SelectGroup => Some(Command::GroupSelectAll), + MenuAction::DeselectGroup => Some(Command::GroupDeselectAll), + MenuAction::InvertSelection => Some(Command::InvertSelection), + MenuAction::PanelsOnOff => Some(Command::ShowScrollback), + MenuAction::FindFile => Some(Command::FindFileOpen), + MenuAction::History => Some(Command::HistoryDialogOpen), + MenuAction::SwapPanels => Some(Command::SwapPanels), + MenuAction::FuzzyJump => Some(Command::FuzzyJumpOpen), + MenuAction::EditMenuFile => Some(Command::EditUserMenuFile), + MenuAction::Quit => Some(Command::RequestQuit), + MenuAction::OpenThemes => Some(Command::ThemePickerOpen), + MenuAction::Unimplemented => None, + } +} + +// --------------------------------------------------------------------- +// Enter-on-file action menu (file-action-menu) +// --------------------------------------------------------------------- + +/// Drive the Enter-on-file action menu — navigation, dismissal, and +/// activation (file-action-menu "Menu contents, ordering, and navigation"). +/// An activated entry closes the menu first and then runs through +/// `activate_file_action` with the slot already empty, so the dialog or open +/// it leads to finds no overlay in its way. +fn handle_file_action_menu(menu: &mut FileActionMenuState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::FileActionMenuMove(delta) => { + menu.move_cursor(delta); + OverlayStep::Keep(vec![]) + } + Command::FileActionMenuCancel => OverlayStep::Close(vec![]), + Command::FileActionMenuConfirm => { + let target_name = menu.target_name.clone(); + OverlayStep::Close(activate_file_action(state, menu.selected(), target_name, menu.selection_scoped)) + } + Command::FileActionMenuItemClick(index) => { + if index >= menu.entries.len() { + // Shouldn't normally happen given hit-testing (the hit map + // only ever records rects for real entries), but a stale + // hit map click — same reasoning as + // `click_entry_on_a_vanished_name_is_a_no_op` — is safer + // treated as a no-op than an out-of-bounds panic. + return OverlayStep::Keep(vec![]); + } + menu.cursor = index; + let target_name = menu.target_name.clone(); + OverlayStep::Close(activate_file_action(state, menu.selected(), target_name, menu.selection_scoped)) + } + Command::FileActionMenuHotkey(c) => match menu.hotkey_action(c) { + Some(action) => { + let target_name = menu.target_name.clone(); + OverlayStep::Close(activate_file_action(state, action, target_name, menu.selection_scoped)) + } + None => OverlayStep::Keep(vec![]), + }, + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// Ctrl+J fuzzy jump (M5) +// --------------------------------------------------------------------- + +/// Drive the fuzzy-jump dialog. The ranked/filtered list itself is never +/// stored on the dialog — it is recomputed from `state.dir_history` each +/// time via [`quicksearch::rank_directories`], which is why every arm +/// re-derives it rather than caching (fuzzy-jump "Fuzzy matching of visited +/// directories", "Frecency ranking"). +fn handle_fuzzy_jump(dialog: &mut FuzzyJumpState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::TextEdit(op) => { + dialog.apply(&op); + OverlayStep::Keep(vec![]) + } + Command::RequestPaste => OverlayStep::Keep(vec![Effect::ReadClipboardText]), + Command::FuzzyJumpMove(delta) => { + let len = quicksearch::rank_directories(&state.dir_history, dialog.pattern.text(), state.epoch_ms).len(); + dialog.move_cursor(delta, len); + OverlayStep::Keep(vec![]) + } + Command::FuzzyJumpCancel => OverlayStep::Close(vec![]), + Command::FuzzyJumpConfirm => { + // Ranked with the epoch clock, the same one the renderer uses, + // so the row Enter acts on is the row that was drawn (design D1). + let ranked = quicksearch::rank_directories(&state.dir_history, dialog.pattern.text(), state.epoch_ms); + match ranked.get(dialog.cursor).map(|e| e.path.clone()) { + Some(path) => { + let side = state.active; + OverlayStep::Close(begin_listing(state, side, path)) + } + None => OverlayStep::Close(vec![]), + } + } + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// Alt+F7 find file (M5) +// --------------------------------------------------------------------- + +/// Drive the find-file dialog. +fn handle_find_file(dialog: &mut FindFileState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::TextEdit(op) => { + dialog.apply(&op); + OverlayStep::Keep(vec![]) + } + Command::RequestPaste => OverlayStep::Keep(vec![Effect::ReadClipboardText]), + Command::FindFileSubmit => { + let request = state.next_request_id(); + dialog.submit(request); + let root = dialog.root.clone(); + let pattern = dialog.pattern.text().to_string(); + OverlayStep::Keep(vec![Effect::FindInSubtree { root, pattern, request }]) + } + Command::FindFileMatch { request, m } => { + dialog.push_match(request, m); + OverlayStep::Keep(vec![]) + } + Command::FindFileSearchDone { request } => { + dialog.mark_done(request); + OverlayStep::Keep(vec![]) + } + Command::FindFileMove(delta) => { + dialog.move_cursor(delta); + OverlayStep::Keep(vec![]) + } + // Dismissing abandons any in-progress search: the walk is told to + // stop (find-file "Dismissed or superseded searches stop"; + // listing-request-guard design D4), and whatever it already posted + // arrives for a slot that no longer holds find file, so + // `Overlay::claims` lets it fall through and the base layer drops it + // (find-file "Esc cancels"; application-shell "Late results of a + // dismissed search touch no other overlay"). + Command::FindFileCancel => OverlayStep::Close(running_find_file_walk(dialog).map(Effect::CancelRequest).into_iter().collect()), + Command::FindFileConfirm => { + // Confirming a result while the walk is still running stops it + // too (find-file "Confirming a result while the walk is running + // stops it"); the navigation below proceeds either way. + let mut effects: Vec = running_find_file_walk(dialog).map(Effect::CancelRequest).into_iter().collect(); + let Some(m) = dialog.selected().cloned() else { return OverlayStep::Close(effects) }; + let target_dir = match m.relative_path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => dialog.root.join(parent), + _ => dialog.root.clone(), + }; + let side = state.active; + effects.extend(begin_listing(state, side, target_dir)); + state.panel_mut(side).pending_cursor_target = Some(m.entry.name); + OverlayStep::Close(effects) + } + _ => OverlayStep::Keep(vec![]), + } +} + +/// The request id of a find-file walk that was submitted and has not yet +/// reported `FindFileSearchDone` — the one `FindFileCancel`/`FindFileConfirm` +/// tell to stop (listing-request-guard design D4). `None` before Enter +/// submits a search and once the walk has finished. +fn running_find_file_walk(ff: &FindFileState) -> Option { + ff.request.filter(|_| !ff.done) +} + +// --------------------------------------------------------------------- +// F2 user menu (M5) +// --------------------------------------------------------------------- + +/// Drive the F2 user menu. Entries themselves live in +/// `state.user_menu_entries`, loaded once at startup — the menu overlay only +/// tracks the cursor (user-menu "Open the F2 user menu", "Navigate and +/// dismiss the user menu"). The cursor domain is +/// `0..=state.user_menu_entries.len()`: the last index is the compiled-in +/// "Themes" slot, not a config entry (design D3). +fn handle_user_menu(menu: &mut UserMenuState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::UserMenuMove(delta) => { + // Cursor domain is the user entries plus one compiled-in + // built-in slot at index `entries.len()` for "Themes" (design + // D3) — not an entry appended to `state.user_menu_entries`. + let len = state.user_menu_entries.len() + 1; + menu.move_cursor(delta, len); + OverlayStep::Keep(vec![]) + } + Command::UserMenuCancel => OverlayStep::Close(vec![]), + Command::UserMenuConfirm => { + if let Some(entry) = state.user_menu_entries.get(menu.cursor).cloned() { + let side = state.active; + let cwd = state.panel(side).cwd.clone(); + return OverlayStep::Close(vec![Effect::RunShellCommand( + shell::build_command(state.shell.shell.as_deref(), &entry.command, &cwd), + side, + )]); + } + // Cursor is at the built-in Themes slot (`entries.len()`): the + // theme picker opens in the user menu's place, through the exact + // same constructor `MenuAction::OpenThemes` uses, pre-highlighting + // the active theme. No shell effect (application-shell "The user + // menu's Themes entry replaces the user menu"). + OverlayStep::Replace(Overlay::ThemePicker(ThemePickerState::open(&state.theme.name)), vec![]) + } + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// Alt+F8 command-history dialog (pulldown-menu-completion; design D2) +// --------------------------------------------------------------------- + +/// Drive the Alt+F8 command-history dialog. The list renders +/// `state.history` newest first, so `cursor` counts from the end of the +/// vector; Enter places the highlighted entry on the command line (caret at +/// the end, recall cleared) and closes without running anything — the user +/// presses Enter again to run it (design D2's non-goal). On an empty history +/// the dialog shows a single `(no history)` row and Enter does nothing (the +/// dialog stays open until Esc). +fn handle_history_dialog(dialog: &mut HistoryDialogState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::HistoryDialogMove(delta) => { + let len = state.history.len(); + dialog.move_cursor(delta, len); + OverlayStep::Keep(vec![]) + } + Command::HistoryDialogCancel => OverlayStep::Close(vec![]), + Command::HistoryDialogConfirm => { + if state.history.is_empty() { + return OverlayStep::Keep(vec![]); + } + let cursor = dialog.cursor.min(state.history.len() - 1); + let entry = state.history[state.history.len() - 1 - cursor].clone(); + // Same placement as Up/Down recall: the whole line is replaced + // with the caret at its end, and any recall in progress ends + // (command-line "Command history navigation"; design D2). + state.command_line.set_text_caret_end(entry); + state.history_cursor = None; + OverlayStep::Close(vec![]) + } + _ => OverlayStep::Keep(vec![]), + } +} + +/// `Command::UserMenuReloaded`: the editor over `usermenu.toml` has closed +/// and the TUI re-read it. A well-formed file replaces the F2 entries +/// immediately — and if it carried non-fatal warnings (an unknown key, an +/// entry missing a field), their one-line summary is shown in the same +/// dismissable dialog (configuration-files "Warnings after editing the user +/// menu"); a malformed one raises the existing warning and keeps the +/// previous entries — the on-disk file (the user's own edit) is never +/// overwritten (user-menu "Malformed edit warns without overwriting"; +/// design D4). The slot is empty here: the reload follows the return of the +/// editor, which was opened from the F9 menu after the menu closed, and no +/// input is read in between (reducer-overlay-structure design D5). +pub(super) fn apply_user_menu_reload(state: &mut State, result: config::UserMenuLoadResult) { + if result.malformed { + state.set_startup_warning(format!("{} is malformed; keeping the previous user menu entries", config::USERMENU_FILE)); + } else { + state.user_menu_entries = result.entries; + if let Some(summary) = config::summarize_warnings(&result.warnings) { + state.set_startup_warning(summary); + } + } +} + +// --------------------------------------------------------------------- +// Options -> Themes picker (visual-themes) +// --------------------------------------------------------------------- + +/// Drive the Options → Themes picker. Enter applies the highlighted theme +/// immediately — swapping `state.theme` in this same reducer step, so the +/// very next frame renders with it — and returns `Effect::PersistTheme` so +/// the TUI event loop writes it to `config.toml`; Esc closes the dialog +/// leaving both the active theme and the config file untouched +/// (theme-selection "Picker navigation, apply, and cancel"). +fn handle_theme_picker(picker: &mut ThemePickerState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::ThemePickerMove(delta) => { + picker.move_cursor(delta); + OverlayStep::Keep(vec![]) + } + Command::ThemePickerCancel => OverlayStep::Close(vec![]), + Command::ThemePickerConfirm => { + if let Some(name) = crate::theme::BUILTIN_THEME_NAMES.get(picker.highlight) { + if let Some(theme) = Theme::by_name(name) { + state.theme = theme; + } + return OverlayStep::Close(vec![Effect::PersistTheme((*name).to_string())]); + } + OverlayStep::Close(vec![]) + } + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// F1 Help window + About dialog (M5) +// --------------------------------------------------------------------- + +/// Drive the F1 Help window. +fn handle_help(help: &mut HelpState, state: &mut State, cmd: Command) -> OverlayStep { + match cmd { + Command::HelpMove(delta) => { + let visible = crate::dialogs::help_topic_visible_rows(crate::dialogs::help_window_height(state.term_size)); + help.move_cursor(delta, visible); + OverlayStep::Keep(vec![]) + } + Command::HelpActivate => { + help.activate(); + OverlayStep::Keep(vec![]) + } + // `back` returns `true` when it stepped down one level (About -> + // list, or page -> list) rather than closing the window outright; + // only then is the window kept open. + Command::HelpCancel => { + if help.back() { + OverlayStep::Keep(vec![]) + } else { + OverlayStep::Close(vec![]) + } + } + _ => OverlayStep::Keep(vec![]), + } +} + +// --------------------------------------------------------------------- +// Mouse (mouse-basics, design D2) +// --------------------------------------------------------------------- + +/// The command a dialog button click stands for — the exact command the +/// equivalent keypress already issues, so a click is never a new way to do +/// something, only a new way to reach an existing one. `None` for a button +/// that has no reachable meaning outside its own gated context (there is +/// none today — kept `Option` for symmetry with [`menu_action_command`] and +/// so a future button can be added without an infallible-match refactor). +pub fn button_command(id: ButtonId) -> Option { + match id { + ButtonId::DeleteConfirmYes => Some(Command::FileOpConfirm), + ButtonId::DeleteConfirmNo => Some(Command::FileOpCancel), + ButtonId::ConflictOverwrite => Some(Command::FileOpConflictChoice(ConflictChoice::Overwrite)), + ButtonId::ConflictOverwriteAll => Some(Command::FileOpConflictChoice(ConflictChoice::OverwriteAll)), + ButtonId::ConflictSkip => Some(Command::FileOpConflictChoice(ConflictChoice::Skip)), + ButtonId::ConflictSkipAll => Some(Command::FileOpConflictChoice(ConflictChoice::SkipAll)), + ButtonId::ConflictRename => Some(Command::FileOpBeginRename), + ButtonId::ConflictCancel => Some(Command::FileOpConflictChoice(ConflictChoice::Cancel)), + ButtonId::ErrorRetry => Some(Command::FileOpErrorChoice(ErrorChoice::Retry)), + ButtonId::ErrorSkip => Some(Command::FileOpErrorChoice(ErrorChoice::Skip)), + ButtonId::ErrorSkipAll => Some(Command::FileOpErrorChoice(ErrorChoice::SkipAll)), + ButtonId::ErrorAbort => Some(Command::FileOpErrorChoice(ErrorChoice::Abort)), + ButtonId::ProgressCancel => Some(Command::FileOpCancelJob), + ButtonId::SummaryContinue => Some(Command::FileOpConfirm), + ButtonId::QuitYes => Some(Command::ConfirmQuit), + ButtonId::QuitNo => Some(Command::CancelQuit), + ButtonId::DropDialogCopy => Some(Command::FileOpConfirmAs(JobKind::Copy)), + ButtonId::DropDialogMove => Some(Command::FileOpConfirmAs(JobKind::Move)), + ButtonId::DropDialogCancel => Some(Command::FileOpCancel), + } +} + +/// `Command::KeybarPress`: the command each function-key-bar slot stands +/// for, exactly mirroring `input::map_panel_key`'s own F-key arms (mouse- +/// input "Key bar, menu bar, pull-down items, and dialog buttons are +/// clickable"). `None` for a slot number the bar never actually draws. +pub(super) fn keybar_command(slot: u8) -> Option { + match slot { + 1 => Some(Command::HelpOpen), + 2 => Some(Command::UserMenuOpen), + 3 => Some(Command::RequestViewer), + 4 => Some(Command::RequestEditor), + 5 => Some(Command::RequestCopy), + 6 => Some(Command::RequestMove), + 7 => Some(Command::RequestMkdir), + 8 => Some(Command::RequestDelete), + 9 => Some(Command::MenuOpen), + 10 => Some(Command::RequestQuit), + _ => None, + } +} + +/// `Command::OpenActionMenuAt`: move `side`'s cursor to the named entry and +/// open the file-action menu for it — for a file, the same shape +/// `handle_enter`'s `EntryKind::File` arm builds; for a directory, the +/// `View`/`Edit`/`Run`-less menu `file-action-menu`'s "Directory targets and +/// selection-scoped invocation" requirement adds (mouse-input "Right-click +/// opens the action menu"; design D4). The menu is also opened +/// selection-scoped whenever `name` is already a member of the panel's +/// selection set, so `activate_file_action` later acts on the whole +/// selection rather than `name` alone. A `..` target, or a name the panel no +/// longer lists, is a no-op — `..` is never a valid action-menu target, +/// mirroring Ctrl+click's own "Parent entry ignored" guard. +pub(super) fn handle_open_action_menu_at(state: &mut State, side: PanelSide, name: OsString) -> Vec { + state.active = side; + let panel = state.panel_mut(side); + let Some(idx) = panel.entries.iter().position(|e| e.name == name) else { return vec![] }; + panel.cursor = idx; + panel.cursor_user_moved = true; + reconcile_panel_viewport(state, side); + let panel = state.panel(side); + let kind = panel.entries[idx].kind; + if kind == EntryKind::ParentDir { + return vec![]; + } + let is_dir = kind == EntryKind::Directory; + let executable = !is_dir && shell::is_executable_name(&name.to_string_lossy(), &state.shell.pathext); + let selection_scoped = panel.selected.contains(&name); + state.open_overlay(Overlay::FileActionMenu(FileActionMenuState::open(name, is_dir, executable, selection_scoped))); + vec![] +} diff --git a/crates/filecommand-core/src/update/panels.rs b/crates/filecommand-core/src/update/panels.rs new file mode 100644 index 0000000..4ebb565 --- /dev/null +++ b/crates/filecommand-core/src/update/panels.rs @@ -0,0 +1,1013 @@ +//! The `Panels` arm of the reducer: the command line and its builtins, panel +//! navigation, tree mode, tabs, the split, quick search, quick filter, swap +//! panels, and mouse clicks on the panels. + +use super::*; + +// --------------------------------------------------------------------- +// Command line +// --------------------------------------------------------------------- + +/// Walk `history` by `delta` (-1 = older, +1 = newer) into the buffer. +/// +/// Stepping past the newest entry stops recalling but deliberately leaves +/// the buffer as it is: backspacing the buffer to empty, not Down, is the +/// documented way to release Up/Down back to the panel cursor (Esc requests +/// quit instead — command-line "Command history navigation"). +pub(super) fn recall_history(state: &mut State, delta: isize) { + if state.history.is_empty() { + return; + } + let last = state.history.len() - 1; + let next = match (state.history_cursor, delta) { + (None, d) if d < 0 => Some(last), + (None, _) => None, + (Some(i), d) if d < 0 => Some(i.saturating_sub(1)), + (Some(i), _) if i < last => Some(i + 1), + (Some(_), _) => None, + }; + match next { + Some(i) => { + // History recall replaces the whole line and leaves the caret at + // its end (command-line "Paste filename and path to command + // line" — caret at end; design D3). + state.command_line.set_text_caret_end(state.history[i].clone()); + state.history_cursor = Some(i); + } + None => state.history_cursor = None, + } +} + +/// Ctrl+Enter / Ctrl+]: append the cursor entry's name or full path, +/// space-separating it from whatever is already typed, and leave the caret +/// at the end (command-line "Paste filename and path to command line": +/// "Paste after caret editing lands at the end"). +pub(super) fn paste_cursor_entry(state: &mut State, full_path: bool) { + let panel = state.active_panel(); + let Some(entry) = panel.selected() else { return }; + let text = if full_path { panel.cwd.join(&entry.name).display().to_string() } else { entry.name.to_string_lossy().into_owned() }; + state.command_line.end(); + if !state.command_line.is_empty() && !state.command_line.text().ends_with(' ') { + state.command_line.insert(' '); + } + state.command_line.insert_str(&text); + state.history_cursor = None; +} + +/// Run the typed line: only the three built-in verbs (`cd`, `del`, +/// `rmdir`) are recognized (command-line "Command-line builtin +/// whitelist") — anything else is rejected without spawning anything. The +/// file-action menu's Run entry and the F2 user menu build their own +/// `Effect::RunShellCommand` independently and are unaffected by this +/// dispatch (design.md "`Effect::RunShellCommand` and `shell:: +/// build_command` are untouched"). +pub(super) fn run_command_line(state: &mut State) -> Vec { + let text = state.command_line.text().trim().to_string(); + if text.is_empty() { + return vec![]; + } + state.command_line.clear(); + state.history_cursor = None; + + // `cd` cannot work through the shell — each command runs in a fresh + // child, so its working directory dies with it. NC navigates the panel + // instead, which is also how a UNC path is entered by hand. + if let Some(target) = parse_cd(&text) { + return dispatch_cd(state, &text, &target); + } + if let Some(target) = parse_del(&text) { + return dispatch_delete_builtin(state, &target, false); + } + if let Some(target) = parse_rmdir(&text) { + return dispatch_delete_builtin(state, &target, true); + } + + // Unrecognized: reject outright — no process spawns, and (matching + // `cd`'s own rejection below) nothing is added to command history + // (command-line "An unrecognized command is rejected without spawning + // anything"). + let side = state.active; + let word = text.split_whitespace().next().unwrap_or(text.as_str()); + state.panel_mut(side).last_error = Some(format!("'{word}' is not a recognized command")); + vec![] +} + +/// `cd `: navigate the active panel to `target` resolved against its +/// cwd, but only after confirming the resolved path exists and is a +/// directory — a nonexistent or non-directory target is rejected outright, +/// leaving `cwd` untouched and starting no listing (command-line "cd +/// navigates the active panel or rejects a nonexistent target"; this is +/// also what fixes the panel switching into a nonexistent directory). +pub(super) fn dispatch_cd(state: &mut State, text: &str, target: &str) -> Vec { + let side = state.active; + let Some(path) = resolve_cd_target(&state.panel(side).cwd, target) else { + // ".." at a drive root, the only case `resolve_cd_target` itself + // rejects — there is nowhere to navigate to. + state.panel_mut(side).last_error = Some(format!("{target} not found")); + return vec![]; + }; + match probe_command_line_target(state, side, &path) { + // Listed in the panel: the listing answers at once (command-line "A + // listed subdirectory needs no filesystem check"). + Some(ProbeKind::Dir) => complete_cd(state, side, text, path), + Some(_) => { + state.panel_mut(side).last_error = Some(format!("{} is not a directory", path.display())); + vec![] + } + None => start_probe(state, side, path, ProbeIntent::Cd { text: text.to_string() }), + } +} + +/// The `cd` outcome once the target is known to be a directory: push the +/// line to command history and navigate. Shared by the listed-entry fast +/// path and the asynchronous probe reply so both produce exactly the same +/// state (offload-blocking-io design D1). +pub(super) fn complete_cd(state: &mut State, side: PanelSide, text: &str, path: PathBuf) -> Vec { + config::push_history(&mut state.history, text); + // `begin_listing` snapshots both histories into one + // `Effect::PersistHistory` after the push, so no separate persist is + // emitted here (history-persistence-hardening D4). + begin_listing(state, side, path) +} + +/// Record a pending command-line target check and ask the TUI to run it on +/// a worker thread (design D1). A newer `cd`/`del`/`rmdir` simply replaces +/// the pending entry, which supersedes the older request: its reply no +/// longer matches and is dropped. The panel announces the check on its +/// mini-status line meanwhile (command-line "cd to an unresponsive UNC path +/// does not freeze the UI"). +pub(super) fn start_probe(state: &mut State, side: PanelSide, path: PathBuf, intent: ProbeIntent) -> Vec { + let request = state.next_request_id(); + let cwd = state.panel(side).cwd.clone(); + state.panel_mut(side).set_pending_note(format!("Checking {}…", display_target(&path))); + state.command_probe = Some(PendingProbe { request, side, cwd, path: path.clone(), intent }); + vec![Effect::ProbePath { request, path }] +} + +/// `path` as the pending note shows it: a UNC share root resolves with a +/// trailing separator (`\\nas\share\`), which the note drops so it reads +/// `Checking \\nas\share…`; a drive root (`C:\`) keeps its separator. +pub(super) fn display_target(path: &Path) -> String { + let shown = path.display().to_string(); + match shown.strip_suffix(['\\', '/']) { + Some(trimmed) if !trimmed.is_empty() && !trimmed.ends_with(':') => trimmed.to_string(), + _ => shown, + } +} + +/// `Command::PathProbed`: complete the `cd`/`del`/`rmdir` whose check this +/// answers, through the very same code paths and messages the listed-entry +/// fast path uses, or discard it (design D1). A reply is applied only when +/// it is still the most recent check, the panel the command was entered for +/// still shows the directory it showed then, and — for `del`/`rmdir`, which +/// open a dialog — the panels still own input. The pending entry and the +/// panel's note are cleared in every case but one: a reply for a request +/// that is not the pending one belongs to a superseded check, and the +/// newer, still-outstanding one keeps its note. +pub(super) fn apply_path_probed(state: &mut State, request: u64, kind: ProbeKind) -> Vec { + if state.command_probe.as_ref().map(|p| p.request) != Some(request) { + return vec![]; + } + let Some(pending) = state.command_probe.take() else { return vec![] }; + let PendingProbe { side, cwd, path, intent, .. } = pending; + state.panel_mut(side).pending_note = None; + if state.panel(side).cwd != cwd { + return vec![]; + } + match intent { + ProbeIntent::Cd { text } => match kind { + ProbeKind::Dir => complete_cd(state, side, &text, path), + ProbeKind::File => { + state.panel_mut(side).last_error = Some(format!("{} is not a directory", path.display())); + vec![] + } + ProbeKind::Missing => { + state.panel_mut(side).last_error = Some(format!("{} not found", path.display())); + vec![] + } + }, + ProbeIntent::Delete { want_dir } => { + if !panels_own_input(state) { + return vec![]; + } + complete_delete_builtin(state, side, path, want_dir, kind) + } + } +} + +/// Whether the panels own input: `UiPhase::Panels` with no menu, dialog or +/// overlay open and no file operation in progress — the set `drag_allowed` +/// checks, minus `undersized`: a terminal below the floor hides the panels +/// but is not "leaving" them, so a reply that arrives then still applies +/// (resize-preserves-session-state). Gates the replies that would open a +/// dialog or a full-screen surface over whatever the user has since opened +/// (offload-blocking-io design D1/D4). +pub(super) fn panels_own_input(state: &State) -> bool { + matches!(state.phase, UiPhase::Panels) && state.overlay.is_none() && !state.quit_confirm +} + +/// `del ` (`want_dir == false`) / `rmdir ` +/// (`want_dir == true`): resolve `target` against the active panel's cwd +/// and, for an existing target of the matching type, open the same F8 +/// delete-confirmation dialog `enter_delete_confirm` uses — never deleting +/// directly. `.` and `..` are never valid targets and are rejected before +/// any lookup, so the dialog can never offer the panel's own or its parent +/// directory. A missing target or a type mismatch (`del` on a directory, +/// `rmdir` on a file) is rejected: no dialog opens (command-line "del and +/// rmdir route into the existing delete-confirmation flow"). +pub(super) fn dispatch_delete_builtin(state: &mut State, target: &str, want_dir: bool) -> Vec { + let side = state.active; + // Reject `.`/`..` up front: `components()` normalizes a trailing + // separator away (`..\` yields just `ParentDir`), so one check covers + // every spelling, while a multi-component target like `..\sibling` is + // untouched — its first component is not the whole path. Neither name + // ever denotes a deletable entry, so no listed-entry lookup or + // filesystem check is spent on them. + let mut components = Path::new(target).components(); + if matches!(components.next(), Some(Component::CurDir | Component::ParentDir)) && components.next().is_none() { + let verb = if want_dir { "rmdir" } else { "del" }; + state.panel_mut(side).last_error = Some(format!("{verb}: invalid target {target}")); + return vec![]; + } + let Some(path) = resolve_cd_target(&state.panel(side).cwd, target) else { + state.panel_mut(side).last_error = Some(format!("{target} not found")); + return vec![]; + }; + match probe_command_line_target(state, side, &path) { + Some(kind) => complete_delete_builtin(state, side, path, want_dir, kind), + None => start_probe(state, side, path, ProbeIntent::Delete { want_dir }), + } +} + +/// The `del`/`rmdir` outcome once the target's kind is known: open the +/// delete-confirmation dialog on a type match, otherwise set the matching +/// rejection. Shared by the listed-entry fast path and the asynchronous +/// probe reply (offload-blocking-io design D1). The dialog's entry name is +/// the target's final path component — for a listed entry that is the +/// on-disk-cased name the listing holds, since the resolved path was built +/// from the typed text joined onto the panel directory and the lookup +/// matched it exactly. +pub(super) fn complete_delete_builtin(state: &mut State, side: PanelSide, path: PathBuf, want_dir: bool, kind: ProbeKind) -> Vec { + match kind { + ProbeKind::Dir | ProbeKind::File if (kind == ProbeKind::Dir) == want_dir => { + let is_dir = kind == ProbeKind::Dir; + let original_name = listed_name(state, side, &path) + .unwrap_or_else(|| path.file_name().map(OsString::from).unwrap_or_else(|| OsString::from(path.display().to_string()))); + enter_delete_confirm_for_sources(state, vec![SourceItem { original_name, path, is_dir }]); + vec![] + } + ProbeKind::Dir => { + state.panel_mut(side).last_error = Some(format!("{} is a directory", path.display())); + vec![] + } + ProbeKind::File => { + state.panel_mut(side).last_error = Some(format!("{} is not a directory", path.display())); + vec![] + } + ProbeKind::Missing => { + state.panel_mut(side).last_error = Some(format!("{} not found", path.display())); + vec![] + } + } +} + +/// The panel's listed entry for `path`, when `path` names a direct child of +/// the panel's directory that is currently listed — the only lookup the +/// reducer performs, with no filesystem access (application-shell "The +/// reducer performs no filesystem I/O"). +pub(super) fn listed_entry<'a>(state: &'a State, side: PanelSide, path: &Path) -> Option<&'a Entry> { + let panel = state.panel(side); + let (name, parent) = (path.file_name()?, path.parent()?); + if parent != panel.cwd.as_path() { + return None; + } + panel.entries.iter().find(|e| e.name == name) +} + +/// The exact on-disk-cased name of the listed entry `path` names, if any. +pub(super) fn listed_name(state: &State, side: PanelSide, path: &Path) -> Option { + listed_entry(state, side, path).map(|e| e.name.clone()) +} + +/// What the active panel's own listing says `path` is — `Dir` or `File` +/// when `path` names a currently listed direct child of the panel's +/// directory, and `None` when the listing cannot answer, in which case the +/// caller asks the TUI to probe the filesystem on a worker thread +/// (`start_probe`). This is the listed-entry fast path and the only check +/// the reducer makes itself: it never touches the filesystem +/// (application-shell "The reducer performs no filesystem I/O"; +/// offload-blocking-io design D1). Shared by `cd`'s existence check and +/// `del`/`rmdir`'s target resolution. +pub(super) fn probe_command_line_target(state: &State, side: PanelSide, path: &Path) -> Option { + listed_entry(state, side, path).map(|entry| if entry.is_dir_like() { ProbeKind::Dir } else { ProbeKind::File }) +} + +/// The target of a `cd ` line, or `None` if this isn't a `cd`. +pub fn parse_cd(text: &str) -> Option { + parse_builtin_arg(text, "cd") +} + +/// The target of a `del ` line, or `None` if this isn't a `del`. +pub fn parse_del(text: &str) -> Option { + parse_builtin_arg(text, "del") +} + +/// The target of a `rmdir ` line, or `None` if this isn't a +/// `rmdir`. +pub fn parse_rmdir(text: &str) -> Option { + parse_builtin_arg(text, "rmdir") +} + +/// Strip a case-insensitive `" "` prefix (`cd`/`CD`/`Cd`/..., +/// matching classic NC/cmd usage — command-line "Builtin verbs are +/// case-insensitive") and return the trimmed, unquoted argument, or `None` +/// if `text` isn't `verb` or carries no argument. +pub(super) fn parse_builtin_arg(text: &str, verb: &str) -> Option { + let trimmed = text.trim(); + // `get` (rather than `split_at`) never panics on a non-ASCII `trimmed` + // whose byte offset `verb.len()` doesn't land on a char boundary — it + // just reports no match, which is the correct outcome either way. + let head = trimmed.get(..verb.len())?; + if !head.eq_ignore_ascii_case(verb) { + return None; + } + let rest = &trimmed[verb.len()..]; + if !rest.starts_with(char::is_whitespace) { + return None; + } + let target = rest.trim().trim_matches('"'); + if target.is_empty() { + None + } else { + Some(target.to_string()) + } +} + +/// Resolve a `cd` target against `cwd`; this one resolver also backs the +/// F5/F6 destination field and the F7/Rename name (operation-dialogs +/// "Relative destinations resolve against the source panel directory"), so +/// every free-form path the app accepts follows the same rules. The result +/// is absolute and lexically normalized — no `.`/`..` components — whenever +/// `cwd` is (command-line "cd, del and rmdir targets resolve to a +/// normalized absolute path"). `.` and `..`, bare drive letters (`D:`), +/// UNC paths, absolute paths, and paths relative to `cwd` keep their +/// meaning; a drive-relative `D:x` (Windows only) is taken relative to +/// `cwd` when `cwd` is on drive `D:` and relative to `D:\` otherwise, so no +/// result ever depends on the process's launch or per-drive current +/// directory. `..` at a drive or UNC root has nowhere to go: `None`. +pub fn resolve_cd_target(cwd: &Path, target: &str) -> Option { + let resolved = resolve_cd_target_unnormalized(cwd, target)?; + Some(crate::fs_ops::path::lexically_normalize(&resolved)) +} + +/// The raw resolution behind [`resolve_cd_target`], before the lexical +/// normalization every `Some` result then goes through. +pub(super) fn resolve_cd_target_unnormalized(cwd: &Path, target: &str) -> Option { + if target == ".." { + return crate::panel::parent_path(cwd); + } + if target == "." { + return Some(cwd.to_path_buf()); + } + let path = Path::new(target); + if drives::is_unc_path(path) || path.is_absolute() { + return Some(path.to_path_buf()); + } + // `D:` on its own means that drive's root, not a relative path. + let mut chars = target.chars(); + if let (Some(letter), Some(':'), None) = (chars.next(), chars.next(), chars.next()) { + if letter.is_ascii_alphabetic() { + return Some(drives::drive_root(letter)); + } + } + // `D:x` (a drive letter, a colon, then a non-separator) is a + // drive-relative path on Windows. FileCommand has no per-drive current + // directory, so `cwd` stands in for drive `D:`'s when it is on that + // drive; otherwise the drive's root. On other platforms `D:x` is an + // ordinary file name, relative to `cwd` like anything else. + if let Some((letter, rest)) = parse_drive_relative(target) { + let base = match drives::drive_letter_of(cwd) { + Some(cwd_letter) if cwd_letter.eq_ignore_ascii_case(&letter) => cwd.to_path_buf(), + _ => drives::drive_root(letter), + }; + return Some(base.join(rest)); + } + Some(cwd.join(target)) +} + +/// The drive letter and remainder of a drive-relative `X:rest` target +/// (Windows path semantics): an ASCII letter, a colon, then at least one +/// character that is not a path separator. `X:` alone (a bare drive letter) +/// and `X:\…`/`X:/…` (absolute) are both handled before this is consulted. +/// Non-Windows hosts have no drive-relative paths: always `None`, so `D:x` +/// stays an ordinary file name there. +#[cfg(windows)] +pub(super) fn parse_drive_relative(target: &str) -> Option<(char, &str)> { + let mut chars = target.chars(); + let (Some(letter), Some(':')) = (chars.next(), chars.next()) else { + return None; + }; + if !letter.is_ascii_alphabetic() { + return None; + } + let rest = chars.as_str(); + if rest.is_empty() || rest.starts_with(['\\', '/']) { + return None; + } + Some((letter, rest)) +} + +/// Non-Windows hosts have no drive-relative paths (see above). +#[cfg(not(windows))] +pub(super) fn parse_drive_relative(_target: &str) -> Option<(char, &str)> { + None +} + +/// Move the cursor to the first entry matching the type-ahead `pattern` +/// (`quicksearch::type_ahead_match`). A pattern that matches nothing leaves +/// the cursor where it is (type-ahead-jump "Alt+letter with no matching +/// entry", "Extended pattern no longer matches"). Input routing normally +/// keeps type-ahead and the Ctrl+P quick filter mutually exclusive, but if +/// both are ever active at once the jump must still only land within +/// `visible_indices()` — otherwise the cursor could land on an entry the +/// active filter hides (quick-filter "Navigation is restricted to matching +/// entries"). +pub(super) fn jump_to_prefix(state: &mut State, pattern: &str) { + let side = state.active; + let panel = state.panel(side); + let found = if panel.quick_filter.is_some() { + // The same prefix test as `type_ahead_match`, over the cached visible + // window and without cloning an entry (streaming-listing-performance + // design D5). + if pattern.is_empty() { + None + } else { + let needle = pattern.to_lowercase(); + panel.visible_window(0, panel.visible_len()).find(|&i| panel.entries[i].sort_key().starts_with(&needle)) + } + } else { + crate::quicksearch::type_ahead_match(&panel.entries, pattern) + }; + if let Some(index) = found { + let panel = state.panel_mut(side); + panel.cursor = index; + panel.cursor_user_moved = true; + } + reconcile_panel_viewport(state, side); +} + +// --------------------------------------------------------------------- +// Adjustable panel split (panel-split) +// --------------------------------------------------------------------- + +/// Ctrl+Left/Ctrl+Right: move the divider `delta_cols` columns (negative = +/// left, positive = right) via `panel_split::adjust_percent`, updating and +/// persisting `state.split_percent` only when the adjustment doesn't +/// violate either panel's minimum width — an adjustment at the limit is a +/// no-op, per panel-split "Adjustment at the limit is a no-op". +pub(super) fn adjust_split(state: &mut State, delta_cols: i32) -> Vec { + match panel_split::adjust_percent(state.split_percent, delta_cols, state.term_size.0) { + Some(new_percent) => { + state.split_percent = new_percent; + vec![Effect::PersistPanelSplit(new_percent)] + } + None => vec![], + } +} + +// --------------------------------------------------------------------- +// Info mode +// --------------------------------------------------------------------- + +pub(super) fn toggle_info_mode(state: &mut State, side: PanelSide) -> Vec { + let entering_info = state.panel(side).display_mode == DisplayMode::Full; + if entering_info { + let request = state.next_request_id(); + let panel = state.panel_mut(side); + panel.display_mode = DisplayMode::Info; + // Every value starts pending; the worker fills them in place. + panel.info = InfoValues::default(); + panel.info_request = Some(request); + // Same reasoning as `SetDisplayMode`/`EnterTreeMode`: Ctrl+L is a + // second path into Info mode, and a quick filter narrowing the + // prior Full-mode list must not linger invisibly here either + // (quick-filter "Substring narrowing as the pattern is typed"). + panel.clear_quick_filter(); + vec![Effect::QueryInfo { panel: side, path: panel.cwd.clone(), request }] + } else { + let panel = state.panel_mut(side); + panel.display_mode = DisplayMode::Full; + panel.info_request = None; + vec![] + } +} + +/// Enter: run the typed command line if there is one, otherwise act on the +/// entry under the cursor — descend into a directory, or open the +/// file-action menu for a file (file-action-menu "Enter on a file opens the +/// action menu"). Spawning an executable directly on Enter is gone; the +/// menu's Run entry is now the only way to reach the suspended-shell spawn +/// path (command-line: "Enter on an executable opens the menu instead of +/// spawning"). +pub(super) fn handle_enter(state: &mut State) -> Vec { + if !state.command_line.text().trim().is_empty() { + return run_command_line(state); + } + let side = state.active; + if state.panel(side).display_mode == DisplayMode::Tree { + return handle_tree_enter(state, side); + } + let Some(entry) = state.panel(side).selected() else { return vec![] }; + match entry.kind { + EntryKind::File => { + let name = entry.name.clone(); + let executable = shell::is_executable_name(&name.to_string_lossy(), &state.shell.pathext); + state.open_overlay(Overlay::FileActionMenu(FileActionMenuState::new(name, executable))); + vec![] + } + EntryKind::ParentDir => handle_parent(state, side), + EntryKind::Directory => { + let new_dir = state.panel(side).cwd.join(&entry.name); + begin_listing(state, side, new_dir) + } + } +} + +pub(super) fn handle_parent(state: &mut State, side: PanelSide) -> Vec { + match crate::panel::parent_path(&state.panel(side).cwd) { + Some(parent) => begin_listing(state, side, parent), + None => vec![], + } +} + +// --------------------------------------------------------------------- +// Tree display mode (M5, design D7) +// --------------------------------------------------------------------- + +/// Enter Tree mode on `side`: record the panel's current display mode so +/// Enter can restore it, root the tree at the panel's drive, and kick off +/// the drive root's immediate-children read — the only I/O Tree mode +/// performs up front (additional-panel-modes "No up-front full-drive +/// scan"). +pub(super) fn enter_tree_mode(state: &mut State, side: PanelSide) -> Vec { + let panel = state.panel(side); + let prior_mode = panel.display_mode; + let letter = drives::drive_letter_of(&panel.cwd).unwrap_or('C'); + let root = drives::drive_root(letter); + let p = state.panel_mut(side); + p.display_mode = DisplayMode::Tree; + p.tree = Some(TreeState::new(root.clone(), prior_mode)); + // Same reasoning as `SetDisplayMode`: a quick filter narrowing the + // prior list mode has no meaning in Tree mode and must not linger + // invisibly (quick-filter "Substring narrowing as the pattern is + // typed"). + p.clear_quick_filter(); + // A brand-new tree is a single root node, so this is a no-op today, but + // it keeps the freshly entered tree's viewport state consistent with + // every other tree-mutating path rather than relying on it happening to + // already be zeroed. + reconcile_panel_viewport(state, side); + vec![Effect::ExpandTreeNode { panel: side, path: root }] +} + +/// Move `side`'s tree cursor, then re-list the opposite panel at whatever +/// directory is now highlighted (additional-panel-modes "Tree mode drives +/// the opposite panel"), expanding the newly highlighted node if it hasn't +/// been already (additional-panel-modes "Children read on expand"). +pub(super) fn handle_tree_cursor_move(state: &mut State, side: PanelSide, m: CursorMove) -> Vec { + let (target, needs_expand) = { + let Some(tree) = state.panel_mut(side).tree.as_mut() else { return vec![] }; + tree.move_cursor(m); + let Some(node) = tree.selected() else { return vec![] }; + (node.path.clone(), !node.expanded) + }; + // Keep the tree cursor inside its own scrolled window (additional- + // panel-modes "Tree mode scrolling") before the opposite panel's + // re-listing kicks off below. + reconcile_panel_viewport(state, side); + let mut effects = begin_listing_inner(state, side.toggle(), target.clone(), false); + if needs_expand { + effects.push(Effect::ExpandTreeNode { panel: side, path: target }); + } + effects +} + +/// Enter on a tree node: leave Tree mode, restoring the panel's prior +/// display mode, and navigate this panel (not the opposite one) to the +/// highlighted directory (additional-panel-modes "Enter returns to prior +/// list mode at chosen directory"). +pub(super) fn handle_tree_enter(state: &mut State, side: PanelSide) -> Vec { + let Some(tree) = &state.panel(side).tree else { return vec![] }; + let Some(node) = tree.selected() else { return vec![] }; + let target = node.path.clone(); + let prior_mode = tree.prior_mode; + let p = state.panel_mut(side); + p.tree = None; + p.display_mode = prior_mode; + begin_listing(state, side, target) +} + +// --------------------------------------------------------------------- +// Swap panels (Ctrl+U; panel-navigation "Swap panels (Ctrl+U)"; design D3) +// --------------------------------------------------------------------- + +/// Exchange the two panels' entire state — directory, tabs, listing, cursor, +/// scroll offset, selection, sort mode, filter, display mode, and git info — +/// keeping the active side and the split percentage unchanged and without +/// re-reading either directory. +/// +/// Refused while either panel's listing is still streaming — a rule the +/// pulldown-menu-completion requirement keeps by spec (the mini-status +/// already shows `Reading…`), not a plumbing limitation: listing replies +/// carry a request id and `listing_request` moves with its panel, so a mid- +/// stream swap would no longer misdeliver chunks (listing-request-guard +/// design D7). Info/git replies carry request ids too, so after the swap +/// each side's pending Info-mode and git-info queries are re-issued with +/// fresh ids and any reply from before the swap is discarded as stale. +/// Ctrl+U during a mouse drag is ignored (a menu can never be open +/// mid-drag, so the guard lives here rather than in the menu path). +pub(super) fn handle_swap_panels(state: &mut State) -> Vec { + if state.drag.is_some() || state.left.progress.is_streaming() || state.right.progress.is_streaming() { + return vec![]; + } + std::mem::swap(&mut state.left, &mut state.right); + let mut effects = Vec::new(); + for side in [PanelSide::Left, PanelSide::Right] { + let path = state.panel(side).cwd.clone(); + // A panel in Info mode needs its figures re-gathered under a fresh + // request id; the values it carries over still describe its own + // (moved-with-it) directory, so they are kept while the re-query + // resolves. + if state.panel(side).display_mode == DisplayMode::Info { + let request = state.next_request_id(); + state.panel_mut(side).info_request = Some(request); + effects.push(Effect::QueryInfo { panel: side, path: path.clone(), request }); + } + effects.extend(git_info_query_effect(state, side, path)); + } + effects +} + +/// `Command::ClickEntry`: focus `side`, and either move the cursor to the +/// named entry (`Plain`/`Shift`) or toggle its selection in place without +/// advancing the cursor from wherever it lands (`Ctrl`) — mirroring `Ins`'s +/// `toggle_selection_and_advance` except the cursor's destination is the +/// clicked entry, not "one past wherever it already was" (mouse-input +/// "Click focuses and places the cursor", "Ctrl+click toggles selection"). A +/// name the panel no longer lists (e.g. a listing that changed between the +/// hit map being built and the click landing) is a silent no-op rather than +/// a panic. The parent `..` pseudo-entry is never selectable, matching +/// `toggle_selection_and_advance`'s own guard (mouse-input "Parent entry +/// ignored"). +pub(super) fn handle_click_entry(state: &mut State, side: PanelSide, name: OsString, mods: ClickMods) -> Vec { + state.active = side; + let panel = state.panel_mut(side); + let Some(idx) = panel.entries.iter().position(|e| e.name == name) else { return vec![] }; + panel.cursor = idx; + panel.cursor_user_moved = true; + if mods == ClickMods::Ctrl && panel.entries[idx].kind != EntryKind::ParentDir && !panel.selected.remove(&name) { + panel.selected.insert(name); + } + reconcile_panel_viewport(state, side); + vec![] +} + +/// `Command::ScrollPanel`: move `side`'s cursor by `delta` rows and let the +/// existing scroll-offset clamp bring the viewport along, so the +/// cursor-in-window invariant holds exactly as it does for a keyboard +/// Up/Down (mouse-input "Wheel moves the cursor of the panel under the +/// pointer"; design D6). Tree mode has no flat `entries` list to move a +/// bare `CursorMove` over, so it reuses the same tree-cursor path the +/// keyboard's Up/Down already takes there. +pub(super) fn handle_scroll_panel(state: &mut State, side: PanelSide, delta: isize) -> Vec { + let m = if delta < 0 { CursorMove::Up(delta.unsigned_abs()) } else { CursorMove::Down(delta as usize) }; + if state.panel(side).display_mode == DisplayMode::Tree { + return handle_tree_cursor_move(state, side, m); + } + state.panel_mut(side).move_cursor(m); + reconcile_panel_viewport(state, side); + vec![] +} + +/// The `UiPhase::Panels` arm of `update_impl`: every command the panels, +/// the command line, and their overlays' openers handle while no phase or +/// overlay above them has claimed it. Takes `state` by value because two of +/// its arms (`KeybarPress`, `MenuItemClick`) re-enter `update_impl` with the +/// command they stand for. +pub(super) fn handle_panels(mut state: State, cmd: Command, mut effects: Vec) -> (State, Vec) { + match cmd { + Command::MoveCursor(m) => { + // A movement key exits type-ahead (if active) *and* is + // still applied to the panel cursor as a normal movement, + // in the same keystroke — the `input/` mapper emits exactly + // this one command for a movement key while type-ahead owns + // the keyboard, relying on this side effect to also end the + // mode (type-ahead-jump "A movement key exits type-ahead + // and is applied to the panel"; design D5). + state.quick_search = None; + let side = state.active; + if state.panel(side).display_mode == DisplayMode::Tree { + effects.extend(handle_tree_cursor_move(&mut state, side, m)); + } else { + state.panel_mut(side).move_cursor(m); + reconcile_panel_viewport(&mut state, side); + } + } + Command::ToggleActivePanel => state.active = state.active.toggle(), + Command::Enter => effects.extend(handle_enter(&mut state)), + Command::ParentDir => { + let side = state.active; + effects.extend(handle_parent(&mut state, side)); + } + // `Command::RequestQuit` is handled globally, above, regardless + // of phase — it never reaches this arm. + Command::RereadPanel(side) => { + let path = state.panel(side).cwd.clone(); + effects.extend(begin_listing(&mut state, side, path)); + } + Command::ToggleSelectAtCursor => { + let side = state.active; + state.panel_mut(side).toggle_selection_and_advance(); + reconcile_panel_viewport(&mut state, side); + } + Command::GroupSelectAll => state.panel_mut(state.active).select_matching("*"), + Command::GroupDeselectAll => state.panel_mut(state.active).deselect_matching("*"), + Command::InvertSelection => state.panel_mut(state.active).invert_selection(), + Command::RequestCopy => enter_file_op_setup(&mut state, JobKind::Copy), + Command::RequestMove => enter_file_op_setup(&mut state, JobKind::Move), + Command::RequestMkdir => enter_file_op_setup(&mut state, JobKind::Mkdir), + Command::RequestDelete => enter_delete_confirm(&mut state), + Command::CopyToClipboard(kind) => effects.extend(handle_copy_to_clipboard(&mut state, kind)), + Command::ClipboardResult { payload, fell_back_to_paths, result } => apply_clipboard_result(&mut state, payload, fell_back_to_paths, result), + + Command::TextEdit(op) => { + // The command line's own editing arm — `TextEdit` is routed + // by phase (design D2): the dialog/overlay arms above claim + // it first whenever one of their fields owns the keyboard, + // so reaching the Panels arm means the command line does. A + // text-mutating edit ends history recall, exactly as typing + // and backspace always have; a pure caret move leaves it. + let mutating = op.is_text_mutation(); + state.command_line.apply(&op); + if mutating { + state.history_cursor = None; + } + } + Command::RequestPaste => effects.push(Effect::ReadClipboardText), + Command::CommandLineClear => { + state.command_line.clear(); + state.history_cursor = None; + } + Command::CommandLineHistoryPrev => recall_history(&mut state, -1), + Command::CommandLineHistoryNext => recall_history(&mut state, 1), + Command::PasteCursorName => paste_cursor_entry(&mut state, false), + Command::PasteCursorPath => paste_cursor_entry(&mut state, true), + Command::ShowScrollback => effects.push(Effect::ShowScrollback), + + Command::QuickSearchStart(c) => { + let mut pattern = String::new(); + pattern.push(c); + jump_to_prefix(&mut state, &pattern); + state.quick_search = Some(pattern); + } + Command::QuickSearchChar(c) => { + if let Some(mut pattern) = state.quick_search.take() { + pattern.push(c); + jump_to_prefix(&mut state, &pattern); + state.quick_search = Some(pattern); + } + } + Command::QuickSearchBackspace => { + if let Some(mut pattern) = state.quick_search.take() { + pattern.pop(); + // An empty pattern stays active rather than exiting + // type-ahead, and the cursor holds its position rather + // than re-jumping (type-ahead-jump "Backspace on a + // single-character pattern"). + if !pattern.is_empty() { + jump_to_prefix(&mut state, &pattern); + } + state.quick_search = Some(pattern); + } + } + Command::QuickSearchEnd => state.quick_search = None, + + Command::SetSortMode { side, mode } => { + state.panel_mut(side).set_sort_mode(mode); + reconcile_panel_viewport(&mut state, side); + } + + Command::QuickFilterStart(side) => { + // Starting the filter also makes `side` the active panel, so + // typed characters reach the filtered panel (design D1 — the + // Left/Right menus' Filter on an inactive panel; a visible + // but expected focus change, as with Left → Info). + state.active = side; + state.panel_mut(side).activate_quick_filter(); + } + Command::QuickFilterChar(c) => { + let side = state.active; + state.panel_mut(side).quick_filter_push(c); + reconcile_panel_viewport(&mut state, side); + } + Command::QuickFilterBackspace => { + let side = state.active; + state.panel_mut(side).quick_filter_backspace(); + reconcile_panel_viewport(&mut state, side); + } + Command::QuickFilterEnd => { + let side = state.active; + state.panel_mut(side).clear_quick_filter(); + reconcile_panel_viewport(&mut state, side); + } + + Command::OpenTab(side) => { + // The new tab inherits the active tab's state — including + // `listing_request`, so a listing still streaming keeps + // filling the new tab, which shows the same directory; no + // cancel is issued. The original tab, now in the background, + // is stamped stale by `to_tab_data` when that listing was + // still being read, so it re-reads on its next activation + // instead of showing the partial listing it held (panel-tabs + // "New tab mid-listing keeps the listing"; listing-request- + // guard design D4/D6). Focus does not move: the Left/Right + // menus open a tab on their own panel (design D1). + state.panel_mut(side).open_tab(); + reconcile_panel_viewport(&mut state, side); + } + Command::CloseTab(side) => { + let leaving = leaving_tab_listing(&state, side); + let tabs_before = state.panel(side).tab_count(); + let stale = state.panel_mut(side).close_tab(); + if state.panel(side).tab_count() < tabs_before { + // The closed tab's read, if still running, is stopped; + // its remaining replies carry an id no tab holds any + // more and are dropped (panel-tabs "Closing a tab mid- + // listing"; design D4). + effects.extend(leaving); + } + if stale { + effects.extend(refresh_activated_stale_tab(&mut state, side)); + } + reconcile_panel_viewport(&mut state, side); + } + Command::SwitchTab(n) => { + let side = state.active; + let leaving = leaving_tab_listing(&state, side); + let active_before = state.panel(side).active_tab_index; + let stale = state.panel_mut(side).switch_tab(n); + if state.panel(side).active_tab_index != active_before { + // The leaving tab's read, if still running, is stopped; + // the tab itself was stamped stale by `to_tab_data` and + // re-reads when next activated (panel-tabs "Switching + // tabs mid-listing"; design D4/D6). + effects.extend(leaving); + } + if stale { + effects.extend(refresh_activated_stale_tab(&mut state, side)); + } + reconcile_panel_viewport(&mut state, side); + } + + Command::EnterTreeMode(side) => effects.extend(enter_tree_mode(&mut state, side)), + Command::SetDisplayMode { side, mode } => { + let p = state.panel_mut(side); + p.tree = None; + p.info_request = None; + p.display_mode = mode; + // A quick filter narrowed the panel's prior display mode; a + // stale pattern must not linger invisibly into Brief/Tree/ + // Info mode, whose renderers don't surface it the same way + // Full mode does (quick-filter "Substring narrowing as the + // pattern is typed"). + p.clear_quick_filter(); + } + + // The overlay openers. Each runs with the slot empty — this arm is + // reached only when no open overlay claimed the command, and an + // overlay that leads to another closes itself first — which + // `open_overlay` checks in debug builds (reducer-overlay-structure + // design D5). + Command::FuzzyJumpOpen => state.open_overlay(Overlay::FuzzyJump(FuzzyJumpState::new())), + Command::FindFileOpen => { + let root = state.active_panel().cwd.clone(); + state.open_overlay(Overlay::FindFile(FindFileState::new(root))); + } + Command::UserMenuOpen => state.open_overlay(Overlay::UserMenu(UserMenuState::new())), + Command::HistoryDialogOpen => state.open_overlay(Overlay::HistoryDialog(HistoryDialogState::new())), + Command::SwapPanels => effects.extend(handle_swap_panels(&mut state)), + Command::EditUserMenuFile => effects.extend(handle_edit_user_menu_file(&mut state)), + Command::UserMenuReloaded(result) => apply_user_menu_reload(&mut state, result), + Command::ThemePickerOpen => { + let picker = ThemePickerState::open(&state.theme.name); + state.open_overlay(Overlay::ThemePicker(picker)); + } + Command::HelpOpen => state.open_overlay(Overlay::Help(HelpState::new())), + + Command::MenuOpen => state.open_overlay(Overlay::Menu(MenuState::opened())), + Command::OpenDriveSelect(side) => effects.push(Effect::EnumerateDrives(side)), + // The one asynchronous opener. The reply is drained before the next + // input is read, so no other overlay can have opened in between + // today; should one ever have, the drive list replaces it — the same + // winner the key router has always given drive select + // (reducer-overlay-structure design D5). + Command::DriveListReady { target, drives } => { + let current = drives::drive_letter_of(&state.panel(target).cwd); + let generation = state.next_request_id(); + for letter in &drives { + effects.push(Effect::FetchDriveLabel { target, letter: *letter, generation }); + } + let mut dialog = DriveSelect::new(target, drives, current); + dialog.generation = generation; + state.overlay = Some(Overlay::DriveSelect(dialog)); + } + Command::ToggleInfoMode(side) => effects.extend(toggle_info_mode(&mut state, side)), + + Command::RequestViewer => effects.extend(handle_request_viewer(&mut state)), + Command::RequestExternalEditor => effects.extend(handle_request_external_editor(&mut state)), + Command::ExternalEditorSpawnFailed { message } => state.panel_mut(state.active).last_error = Some(message), + + Command::RequestEditor => effects.extend(handle_request_editor(&mut state)), + // The `ViewerOpened`/`EditorOpened` family of replies is matched + // against `State::pending_open` ahead of every phase arm (see + // `take_current_open`), so none of them reach here. + + // file-action-menu "Open with the associated application": a + // failed launch is reported inline, on the status line of the + // panel the menu acted on — even if the user has switched panels + // since — and no dialog opens (offload-blocking-io design D7). + Command::OpenWithAssociationFailed { side, message } => state.panel_mut(side).last_error = Some(message), + + Command::SplitGrow => effects.extend(adjust_split(&mut state, panel_split::SPLIT_STEP as i32)), + Command::SplitShrink => effects.extend(adjust_split(&mut state, -(panel_split::SPLIT_STEP as i32))), + Command::SplitReset => { + if state.split_percent != panel_split::DEFAULT_SPLIT_PERCENT { + state.split_percent = panel_split::DEFAULT_SPLIT_PERCENT; + effects.push(Effect::PersistPanelSplit(state.split_percent)); + } + } + + // Mouse (mouse-basics; design D2). + Command::FocusPanel(side) => state.active = side, + Command::ClickEntry { side, name, mods } => effects.extend(handle_click_entry(&mut state, side, name, mods)), + Command::ScrollPanel { side, delta } => effects.extend(handle_scroll_panel(&mut state, side, delta)), + Command::KeybarPress(slot) => { + if let Some(next) = keybar_command(slot) { + return update_impl(state, next); + } + } + // A title click with the bar closed opens that pull-down; with the + // bar open, the menu overlay itself claims the click (and + // `MenuItemClick`), so neither reaches here then. + Command::MenuTitleClick(id) => state.open_overlay(Overlay::Menu(MenuState::for_menu(id))), + Command::OpenActionMenuAt { side, name } => effects.extend(handle_open_action_menu_at(&mut state, side, name)), + + // Mouse drag-and-drop (mouse-panel-drag; design D2/D4). + Command::DragBegin { side, name, op } => { + let items = drag_selection_sources(&state, side, &name); + if !items.is_empty() { + let source_dir = state.panel(side).cwd.clone(); + state.drag = Some(DragState { source: side, source_dir, items, op, target: None }); + } + } + Command::DragOver { op, target } => { + // Both borrows below are immutable — computed fully before + // the mutable one that follows — so there's no conflict with + // `state.drag.as_mut()` afterwards. + let valid_target = state.drag.as_ref().and_then(|drag| target.as_ref().filter(|t| valid_drop_target(&state, drag, t)).cloned()); + if let Some(drag) = state.drag.as_mut() { + drag.op = op; + drag.target = valid_target; + } + } + Command::DragDrop { op } => { + if let Some(mut drag) = state.drag.take() { + drag.op = op; + // "Robust against listing changes": the source panel + // must still show the directory the items were captured + // from, and the target must still resolve to a live + // directory (mouse-drag "Robust against listing + // changes"). + let source_ok = state.panel(drag.source).cwd == drag.source_dir; + let resolved = if source_ok { + drag.target.as_ref().filter(|t| valid_drop_target(&state, &drag, t)).and_then(|t| drop_target_path(&state, t)) + } else { + None + }; + if let Some(target_path) = resolved { + let prefill = target_path.display().to_string(); + enter_file_op_setup_for_sources(&mut state, drag.op, drag.items, drag.source, prefill, Some(DropButtons { focused: drag.op })); + } + // Otherwise: invalid/cancelled — the drag is already + // taken (cleared), and nothing else happens (mouse-drag + // "Release on an invalid spot"). + } + } + Command::DragCancel => state.drag = None, + + Command::Tick { .. } => {} + Command::ConfirmQuit | Command::CancelQuit | Command::Resize(..) | Command::DialogButtonClick(_) => unreachable!("handled above"), + _ => {} + } + (state, effects) +} diff --git a/crates/filecommand-core/src/update/tests.rs b/crates/filecommand-core/src/update/tests.rs deleted file mode 100644 index e346ace..0000000 --- a/crates/filecommand-core/src/update/tests.rs +++ /dev/null @@ -1,5118 +0,0 @@ -use super::*; -use crate::drives::DriveEntry; -use crate::listing::{Entry, EntryKind}; -use crate::menu::MenuEntry; -use std::ffi::OsString; - -fn test_state(phase: UiPhase) -> State { - State { - left: PanelState::new(PathBuf::from("/left")), - right: PanelState::new(PathBuf::from("/right")), - phase, - ..State::empty(Theme::classic()) - } -} - -fn file_entry(name: &str, size: u64) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size, modified: None } -} - -fn dir_entry(name: &str) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::Directory, size: 0, modified: None } -} - -/// Every navigation/re-read now also issues `Effect::QueryGitInfo` -/// alongside `Effect::StartListing` (git-info "Query re-issued on -/// navigation"), and `Effect::PersistHistory` (fuzzy-jump "Navigation -/// records history"; design D6). Tests that predate M5 and only care about -/// the listing-related effects filter both out with this helper rather than -/// asserting on request ids/frecency bookkeeping that are incidental to -/// what they're testing. -fn without_git_info_effects(effects: Vec) -> Vec { - effects.into_iter().filter(|e| !matches!(e, Effect::QueryGitInfo { .. } | Effect::PersistHistory(_))).collect() -} - -// --------------------------------------------------------------------- -// M1/M2 regression coverage -// --------------------------------------------------------------------- - -#[test] -fn update_is_deterministic() { - let s1 = test_state(UiPhase::Panels); - let s2 = test_state(UiPhase::Panels); - let (r1, e1) = update(s1, Command::ToggleActivePanel); - let (r2, e2) = update(s2, Command::ToggleActivePanel); - assert_eq!(r1, r2); - assert_eq!(e1, e2); -} - -#[test] -fn directory_read_returns_intent_effect_without_io() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (state, effects) = update(state, Command::Enter); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left/sub") }]); - // The panel's own entries were reset locally — no filesystem was touched. - assert!(state.left.entries.is_empty()); - assert!(matches!(state.left.progress, crate::panel::ListingProgress::Streaming { count: 0 })); -} - -#[test] -fn worker_events_reenter_through_update() { - let state = test_state(UiPhase::Panels); - let entries = vec![file_entry("a", 1)]; - let (state, effects) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: entries.clone() }); - assert!(effects.is_empty()); - assert_eq!(state.left.entries, entries); - let (state, effects) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 1 }); - assert!(effects.is_empty()); - assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 1 }); -} - -#[test] -fn toggle_active_panel_is_exclusive() { - let state = test_state(UiPhase::Panels); - assert_eq!(state.active, PanelSide::Left); - let (state, _) = update(state, Command::ToggleActivePanel); - assert_eq!(state.active, PanelSide::Right); - let (state, _) = update(state, Command::ToggleActivePanel); - assert_eq!(state.active, PanelSide::Left); -} - -#[test] -fn enter_on_parent_dir_navigates_up_and_resets_cursor() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from("/a/b"); - state.left.entries = vec![Entry::parent_dir()]; - state.left.cursor = 0; - let (state, effects) = update(state, Command::Enter); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/a") }]); - assert_eq!(state.left.cursor, 0); -} - -#[test] -fn parent_nav_at_root_is_no_op() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from("/"); - let (state, effects) = update(state, Command::ParentDir); - assert!(effects.is_empty()); - assert_eq!(state.left.cwd, PathBuf::from("/")); -} - -#[test] -fn f10_raises_the_quit_confirm_overlay_and_confirm_quits() { - // Post-quit-keys: the dialog is an overlay beside the phase - // (`State::quit_confirm: bool`), not a `UiPhase::QuitConfirm` variant — - // `RequestQuit` must not disturb `state.phase` at all (application-shell - // "Quit request keys and confirmation"; design D5). - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::RequestQuit); - assert!(state.quit_confirm, "RequestQuit must raise the overlay"); - assert_eq!(state.phase, UiPhase::Panels, "the overlay lives beside the phase, not inside it"); - assert!(effects.is_empty()); - let (state, effects) = update(state, Command::ConfirmQuit); - assert_eq!(effects, vec![Effect::Quit]); - assert!(!state.quit_confirm); -} - -#[test] -fn quit_confirm_cancel_clears_only_the_overlay_flag() { - let mut state = test_state(UiPhase::Panels); - state.quit_confirm = true; - let (state, effects) = update(state, Command::CancelQuit); - assert!(!state.quit_confirm); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty()); -} - -// --------------------------------------------------------------------- -// quit-keys: the quit-confirmation overlay opens from every context and -// cancelling restores that context bit-for-bit (application-shell "Quit -// request keys and confirmation"; design D5; tasks 19.1/19.2/19.3) -// --------------------------------------------------------------------- - -#[test] -fn request_quit_opens_over_idle_panels_and_cancel_restores_them_exactly() { - let before = test_state(UiPhase::Panels); - let (state, effects) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert!(effects.is_empty()); - let (state, effects) = update(state, Command::CancelQuit); - assert!(!state.quit_confirm); - assert!(effects.is_empty()); - assert_eq!(state, before, "cancel must restore idle panels bit-for-bit"); -} - -#[test] -fn request_quit_opens_mid_command_line_and_cancel_restores_the_buffer() { - let before = type_line(test_state(UiPhase::Panels), "dir"); - let (state, _) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert_eq!(state.command_line, "dir", "the typed buffer must survive RequestQuit untouched"); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must restore the typed command line bit-for-bit"); -} - -#[test] -fn request_quit_opens_under_an_active_quick_filter_and_cancel_restores_it() { - let mut before = test_state(UiPhase::Panels); - before.left.quick_filter = Some("rep".to_string()); - let (state, _) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert_eq!(state.left.quick_filter.as_deref(), Some("rep")); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must restore the active quick filter bit-for-bit"); -} - -#[test] -fn request_quit_opens_during_type_ahead_and_cancel_restores_it() { - let mut before = test_state(UiPhase::Panels); - before.quick_search = Some("re".to_string()); - let (state, _) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert_eq!(state.quick_search.as_deref(), Some("re")); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must restore type-ahead bit-for-bit"); -} - -#[test] -fn request_quit_opens_above_the_viewer_and_cancel_restores_it() { - let (before, _) = - update(test_state(UiPhase::Panels), Command::ViewerOpened { path: PathBuf::from("/left/big.log"), file_len: 5_000_000_000 }); - assert!(matches!(before.phase, UiPhase::Viewer(_)), "precondition: viewer must be open"); - let (state, effects) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm, "Ctrl+C/RequestQuit must open the overlay while the viewer is open"); - assert!(matches!(state.phase, UiPhase::Viewer(_)), "the overlay must not replace the viewer underneath it"); - assert!(effects.is_empty()); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must leave the viewer open, untouched, bit-for-bit"); -} - -#[test] -fn request_quit_opens_with_a_pull_down_menu_open_and_cancel_restores_it() { - let (before, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - assert!(before.menu.is_some(), "precondition: the menu must be open"); - let (state, effects) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert_eq!(state.menu, before.menu, "the open menu must not be disturbed by RequestQuit"); - assert!(effects.is_empty()); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must leave the menu open, untouched, bit-for-bit"); -} - -#[test] -fn request_quit_opens_with_a_modal_dialog_open_and_cancel_restores_it() { - let (before, _) = update(test_state(UiPhase::Panels), Command::FuzzyJumpOpen); - assert!(before.fuzzy_jump.is_some(), "precondition: the fuzzy-jump dialog must be open"); - let (state, effects) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert_eq!(state.fuzzy_jump, before.fuzzy_jump, "the open dialog must not be disturbed by RequestQuit"); - assert!(effects.is_empty()); - let (state, _) = update(state, Command::CancelQuit); - assert_eq!(state, before, "cancel must leave the modal dialog open, untouched, bit-for-bit"); -} - -#[test] -fn confirming_quit_while_a_job_is_running_aborts_it_before_quitting() { - // Confirming quit while a file operation is running must abort the job - // through the existing cancel path (`Effect::CancelJob`, the same one - // the Progress dialog's own Cancel key uses) before `Effect::Quit` — - // order matters, since the caller must stop the worker before tearing - // the app down (application-shell "Quit request keys and - // confirmation"; design D3; task 19.3). - let mut state = running_progress_state(JobKind::Copy, "/left", "/right"); - state.quit_confirm = true; - let (state, effects) = update(state, Command::ConfirmQuit); - assert_eq!(effects, vec![Effect::CancelJob, Effect::Quit], "the job must be cancelled before the app quits"); - assert!(!state.quit_confirm); -} - -#[test] -fn request_quit_while_a_job_is_running_does_not_touch_the_job_until_confirmed() { - let before = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, effects) = update(before.clone(), Command::RequestQuit); - assert!(state.quit_confirm); - assert!(effects.is_empty(), "merely opening the dialog must not cancel the running job"); - assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); - let (state, effects) = update(state, Command::CancelQuit); - assert!(effects.is_empty(), "cancelling the quit dialog must not touch the job either"); - assert_eq!(state, before, "cancel must leave the running job untouched, bit-for-bit"); -} - -#[test] -fn splash_dismissed_immediately_by_key_before_min_hold() { - let state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); - let (state, effects) = update(state, Command::ToggleActivePanel); - assert_eq!(state.phase, UiPhase::Panels); - // The dismissing key was consumed, not forwarded: active panel must - // still be Left (unaffected by the ToggleActivePanel command). - assert_eq!(state.active, PanelSide::Left); - assert!(effects.is_empty()); -} - -#[test] -fn splash_auto_dismisses_after_min_hold_via_tick() { - let state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); - let (state, _) = update(state, Command::Tick(1_799)); - assert!(matches!(state.phase, UiPhase::Splash { .. })); - let (state, _) = update(state, Command::Tick(1_800)); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn placeholder_below_min_and_grows_back_to_panels_never_splash() { - let state = test_state(UiPhase::Splash { started_at_ms: 0 }); - let (state, _) = update(state, Command::Resize(40, 10)); - assert_eq!(state.phase, UiPhase::Placeholder); - let (state, _) = update(state, Command::Resize(80, 24)); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn too_small_boundary_at_59x16_is_placeholder() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::Resize(59, 16)); - assert_eq!(state.phase, UiPhase::Placeholder); -} - -#[test] -fn too_small_boundary_at_60x15_is_placeholder() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::Resize(60, 15)); - assert_eq!(state.phase, UiPhase::Placeholder); -} - -#[test] -fn too_small_boundary_at_60x16_is_panels() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::Resize(60, 16)); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn splash_skipped_when_starting_below_60x16_floor() { - let (state, _) = State::initial(Theme::classic(), (59, 16), 0, PathBuf::from("/l"), PathBuf::from("/r"), true); - assert_eq!(state.phase, UiPhase::Placeholder); -} - -#[test] -fn splash_starts_normally_at_exactly_60x16() { - let (state, _) = State::initial(Theme::classic(), (60, 16), 0, PathBuf::from("/l"), PathBuf::from("/r"), true); - assert_eq!(state.phase, UiPhase::Splash { started_at_ms: 0 }); -} - -#[test] -fn placeholder_replaces_splash_when_resized_below_new_floor_mid_splash() { - let state = test_state(UiPhase::Splash { started_at_ms: 0 }); - let (state, _) = update(state, Command::Resize(59, 16)); - assert_eq!(state.phase, UiPhase::Placeholder); - // Enlarging back never returns to Splash, only Panels. - let (state, _) = update(state, Command::Resize(60, 16)); - assert_eq!(state.phase, UiPhase::Panels); -} - -// --------------------------------------------------------------------- -// Adjustable panel split (panel-split) -// --------------------------------------------------------------------- - -#[test] -fn split_grow_moves_divider_two_columns_right() { - // panel-split "Divider moves in 2-column steps": 50/50 at 100 columns, - // Ctrl+Right widens the left panel to 52. - let mut state = test_state(UiPhase::Panels); - state.term_size = (100, 24); - let (state, effects) = update(state, Command::SplitGrow); - assert_eq!(state.split_percent, 52); - assert_eq!(effects, vec![Effect::PersistPanelSplit(52)]); -} - -#[test] -fn split_shrink_moves_divider_two_columns_left() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (100, 24); - let (state, effects) = update(state, Command::SplitShrink); - assert_eq!(state.split_percent, 48); - assert_eq!(effects, vec![Effect::PersistPanelSplit(48)]); -} - -#[test] -fn split_shrink_is_a_no_op_at_the_minimum() { - // panel-split "Adjustment at the limit is a no-op": the right panel is - // already at its 20-column minimum (left = 80 of 100). - let mut state = test_state(UiPhase::Panels); - state.term_size = (100, 24); - state.split_percent = 80; - let (state, effects) = update(state, Command::SplitGrow); - assert_eq!(state.split_percent, 80, "no change at the limit"); - assert!(effects.is_empty()); -} - -#[test] -fn split_reset_restores_fifty_fifty() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (100, 24); - state.split_percent = 66; - let (state, effects) = update(state, Command::SplitReset); - assert_eq!(state.split_percent, 50); - assert_eq!(effects, vec![Effect::PersistPanelSplit(50)]); -} - -#[test] -fn split_reset_is_a_no_op_when_already_at_default() { - let state = test_state(UiPhase::Panels); - assert_eq!(state.split_percent, 50); - let (state, effects) = update(state, Command::SplitReset); - assert_eq!(state.split_percent, 50); - assert!(effects.is_empty(), "no redundant persist when already at the default"); -} - -#[test] -fn split_change_persists_via_effect() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (100, 24); - let (_, effects) = update(state, Command::SplitGrow); - assert!(matches!(effects.as_slice(), [Effect::PersistPanelSplit(_)])); -} - -#[test] -fn listing_chunk_during_splash_still_updates_panel_state() { - let state = test_state(UiPhase::Splash { started_at_ms: 0 }); - let entries = vec![file_entry("a", 0)]; - let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: entries.clone() }); - assert_eq!(state.left.entries, entries); - assert!(matches!(state.phase, UiPhase::Splash { .. })); -} - -#[test] -fn initial_builds_start_listing_effects_for_both_panels() { - let (state, effects) = State::initial(Theme::classic(), (80, 24), 0, PathBuf::from("/l"), PathBuf::from("/r"), true); - assert_eq!(state.phase, UiPhase::Splash { started_at_ms: 0 }); - assert_eq!( - without_git_info_effects(effects), - vec![ - Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/l") }, - Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from("/r") }, - ] - ); -} - -#[test] -fn initial_below_min_size_skips_straight_to_placeholder() { - let (state, _) = State::initial(Theme::classic(), (40, 10), 0, PathBuf::from("/l"), PathBuf::from("/r"), true); - assert_eq!(state.phase, UiPhase::Placeholder); -} - -#[test] -fn initial_without_splash_starts_at_panels() { - let (state, _) = State::initial(Theme::classic(), (80, 24), 0, PathBuf::from("/l"), PathBuf::from("/r"), false); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn request_copy_with_no_selection_and_no_cursor_entry_is_noop() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::RequestCopy); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty()); -} - -#[test] -fn request_copy_uses_cursor_entry_when_nothing_explicitly_selected() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 10)]; - let (state, _) = update(state, Command::RequestCopy); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, .. }) => { - assert_eq!(kind, JobKind::Copy); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("a.txt")); - } - other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), - } -} - -#[test] -fn request_copy_prefills_destination_with_opposite_panel_cwd() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 10)]; - let (state, _) = update(state, Command::RequestCopy); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { - assert_eq!(input, PathBuf::from("/right").display().to_string()); - } - other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), - } -} - -#[test] -fn request_copy_uses_explicit_selection_over_cursor() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.left.selected.insert(OsString::from("b.txt")); - let (state, _) = update(state, Command::RequestCopy); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { sources, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("b.txt")); - } - other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), - } -} - -#[test] -fn request_mkdir_is_always_available_even_with_empty_panel() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::RequestMkdir); - assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Mkdir, .. }))); -} - -#[test] -fn destination_input_typing_and_confirm_starts_job() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::RequestCopy); - let (state, _) = update(state, Command::FileOpInputBackspace); // backspace on prefilled input - let (state, _) = update(state, Command::FileOpInputChar('X')); - let (state, effects) = update(state, Command::FileOpConfirm); - match &state.phase { - UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, .. } } => { - assert_eq!(*kind, JobKind::Copy); - assert_eq!(source_dir, &PathBuf::from("/left")); - assert!(dest_dir.to_string_lossy().ends_with('X')); - } - other => panic!("expected FileOpRunning Progress, got {other:?}"), - } - assert!(matches!(effects.as_slice(), [Effect::RunJob(_)])); -} - -#[test] -fn destination_input_confirm_with_empty_input_is_noop() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::RequestMkdir); - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(effects.is_empty()); - assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { .. }))); -} - -#[test] -fn escape_cancels_destination_input_back_to_panels() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::RequestCopy); - let (state, effects) = update(state, Command::FileOpCancel); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty()); -} - -#[test] -fn delete_confirm_requires_second_confirmation_for_a_directory() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (state, effects) = update(state, Command::RequestDelete); - assert!(effects.is_empty()); - assert!(matches!( - state.phase, - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { needs_second_confirm: true, confirmed_once: false, .. }) - )); - - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(effects.is_empty(), "first confirm just arms the second confirmation"); - assert!(matches!( - state.phase, - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { needs_second_confirm: true, confirmed_once: true, .. }) - )); - - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(matches!(effects.as_slice(), [Effect::RunJob(Job { kind: JobKind::Delete, .. })])); - assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); -} - -#[test] -fn delete_confirm_single_file_needs_only_one_confirmation() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::RequestDelete); - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(matches!(effects.as_slice(), [Effect::RunJob(_)])); - assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); -} - -fn running_progress_state(kind: JobKind, source_dir: &str, dest_dir: &str) -> State { - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from(source_dir), - dest_dir: PathBuf::from(dest_dir), - dialog: RunningDialog::Progress { kind, progress: ProgressInfo::starting(3, 30) }, - }); - state.left.cwd = PathBuf::from(source_dir); - state.right.cwd = PathBuf::from(dest_dir); - state -} - -#[test] -fn job_progress_event_updates_progress_dialog() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let info = ProgressInfo { files_done: 1, files_total: 3, bytes_done: 10, bytes_total: 30, current_file: OsString::from("a.txt") }; - let (state, effects) = update(state, Command::JobProgress(info.clone())); - assert!(effects.is_empty()); - match state.phase { - UiPhase::FileOpRunning { dialog: RunningDialog::Progress { progress, .. }, .. } => assert_eq!(progress, info), - other => panic!("expected Progress dialog, got {other:?}"), - } -} - -fn sample_conflict() -> ConflictInfo { - ConflictInfo { - source_name: OsString::from("a.txt"), - source_size: 1, - source_modified: None, - target_path: PathBuf::from("/right/a.txt"), - target_size: 2, - target_modified: None, - } -} - -#[test] -fn job_conflict_event_switches_to_conflict_dialog_and_reply_returns_to_progress() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, _) = update(state, Command::JobConflict(sample_conflict())); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. })); - - let (state, effects) = update(state, Command::FileOpConflictChoice(ConflictChoice::Overwrite)); - assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Overwrite)]); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); -} - -#[test] -fn conflict_rename_flow_composes_a_name_then_replies() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, _) = update(state, Command::JobConflict(sample_conflict())); - let (state, _) = update(state, Command::FileOpBeginRename); - let (state, _) = update(state, Command::FileOpInputChar('b')); - let (state, _) = update(state, Command::FileOpInputChar('.')); - let (state, _) = update(state, Command::FileOpInputChar('c')); - let (state, effects) = update(state, Command::FileOpConfirm); - assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Rename(OsString::from("b.c")))]); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); -} - -#[test] -fn job_error_event_switches_to_error_dialog_and_reply_returns_to_progress() { - let state = running_progress_state(JobKind::Delete, "/left", "/left"); - let info = ErrorInfo { path: PathBuf::from("/left/a.txt"), message: "permission denied".to_string() }; - let (state, _) = update(state, Command::JobError(info)); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { .. }, .. })); - let (state, effects) = update(state, Command::FileOpErrorChoice(ErrorChoice::Skip)); - assert_eq!(effects, vec![Effect::SendErrorReply(ErrorChoice::Skip)]); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); -} - -#[test] -fn job_cancel_sends_cancel_effect_without_leaving_progress_dialog() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, effects) = update(state, Command::FileOpCancelJob); - assert_eq!(effects, vec![Effect::CancelJob]); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); -} - -#[test] -fn job_done_with_no_skips_rereads_matching_panels_and_returns_to_panels() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, effects) = update( - state, - Command::JobDone { outcome: JobOutcome::Completed { skipped: vec![] }, source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/right") }, - ); - assert_eq!(state.phase, UiPhase::Panels); - assert_eq!( - without_git_info_effects(effects), - vec![ - Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }, - Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from("/right") }, - ] - ); -} - -#[test] -fn job_done_with_skips_shows_summary_instead_of_panels() { - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/left"), - dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(3, 30) }, - }); - state.left.cwd = PathBuf::from("/left"); - state.right.cwd = PathBuf::from("/right"); // unaffected panel: must not be re-read - let skipped = vec![SkippedItem { path: PathBuf::from("/left/a.txt"), reason: "denied".to_string() }]; - let (state, effects) = update( - state, - Command::JobDone { outcome: JobOutcome::Completed { skipped: skipped.clone() }, source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/left") }, - ); - assert_eq!(state.phase, UiPhase::FileOpSummary(skipped)); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }]); - - let (state, _) = update(state, Command::FileOpConfirm); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn job_done_only_rereads_panels_whose_cwd_matches_source_or_dest() { - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/somewhere/else"), - dialog: RunningDialog::Progress { kind: JobKind::Copy, progress: ProgressInfo::starting(1, 1) }, - }); - state.left.cwd = PathBuf::from("/left"); - state.right.cwd = PathBuf::from("/right"); // does not match dest_dir - let (_, effects) = update( - state, - Command::JobDone { - outcome: JobOutcome::Completed { skipped: vec![] }, - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/somewhere/else"), - }, - ); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }]); -} - -#[test] -fn job_done_delete_rereads_both_panels_when_both_browse_the_same_directory() { - // BIG-162 base case: both panels' *active* tabs already sat on the - // directory a Delete completed in — this must keep re-reading both - // immediately, unchanged by the background-tab staleness work - // (file-operations "The opposite panel sharing the affected directory - // also refreshes"). - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from("/shared"), - dest_dir: PathBuf::from("/shared"), - dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(1, 1) }, - }); - state.left.cwd = PathBuf::from("/shared"); - state.right.cwd = PathBuf::from("/shared"); - let (state, effects) = update( - state, - Command::JobDone { - outcome: JobOutcome::Completed { skipped: vec![] }, - source_dir: PathBuf::from("/shared"), - dest_dir: PathBuf::from("/shared"), - }, - ); - assert_eq!(state.phase, UiPhase::Panels); - assert_eq!( - without_git_info_effects(effects), - vec![ - Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/shared") }, - Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from("/shared") }, - ] - ); -} - -#[test] -fn job_done_marks_background_tab_stale_without_eager_reread() { - // A background tab (not the active one) sitting on the deleted-from - // directory must be marked stale, not eagerly re-read — the read is - // deferred until it becomes active (file-operations "A background tab - // on the affected directory is marked stale, not eagerly re-read"). - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/left"), - dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(1, 1) }, - }); - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" - state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere; tab 0 remains at "/left" - state.right.cwd = PathBuf::from("/right"); // unaffected panel - - let (state, effects) = update( - state, - Command::JobDone { - outcome: JobOutcome::Completed { skipped: vec![] }, - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/left"), - }, - ); - assert_eq!(state.phase, UiPhase::Panels); - assert!(without_git_info_effects(effects).is_empty(), "a background tab must not be eagerly re-read"); - assert_eq!(state.left.tabs.len(), 1); - assert!(state.left.tabs[0].stale, "the background tab on the deleted-from directory must be marked stale"); -} - -#[test] -fn listing_complete_reconciles_selection_against_fresh_entries() { - let mut state = test_state(UiPhase::Panels); - state.left.selected.insert(OsString::from("gone.txt")); - state.left.selected.insert(OsString::from("stays.txt")); - state.left.entries = vec![file_entry("stays.txt", 1)]; - let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 1 }); - assert_eq!(state.left.selected, std::collections::HashSet::from([OsString::from("stays.txt")])); -} - -#[test] -fn selection_commands_operate_on_active_panel() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - let (state, _) = update(state, Command::ToggleSelectAtCursor); - assert!(state.left.selected.contains(&OsString::from("a.txt"))); - let (state, _) = update(state, Command::InvertSelection); - assert_eq!(state.left.selected, std::collections::HashSet::from([OsString::from("b.txt")])); - let (state, _) = update(state, Command::GroupSelectAll); - assert_eq!(state.left.selected.len(), 2); - let (state, _) = update(state, Command::GroupDeselectAll); - assert!(state.left.selected.is_empty()); -} - -#[test] -fn reread_reissues_start_listing_and_clears_the_error() { - let mut state = test_state(UiPhase::Panels); - state.left.last_error = Some("boom".to_string()); - state.left.cwd = PathBuf::from("/left"); - let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }]); - assert!(state.left.last_error.is_none()); -} - -// --------------------------------------------------------------------- -// M3: command line -// --------------------------------------------------------------------- - -fn type_line(state: State, text: &str) -> State { - text.chars().fold(state, |s, c| update(s, Command::CommandLineChar(c)).0) -} - -#[test] -fn prompt_shows_the_active_panel_path_and_follows_focus() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.right.cwd = PathBuf::from(r"D:\WORK"); - assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"C:\NORTON").display())); - - let (state, _) = update(state, Command::ToggleActivePanel); - assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"D:\WORK").display())); -} - -#[test] -fn prompt_follows_the_active_panel_into_a_subdirectory() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from("/left"); - state.left.entries = vec![dir_entry("sub")]; - let (state, _) = update(state, Command::Enter); - assert_eq!(state.prompt(), format!("{}>", PathBuf::from("/left").join("sub").display())); -} - -#[test] -fn printable_keys_build_the_command_buffer_without_moving_the_cursor() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; - state.left.cursor = 1; - let state = type_line(state, "dir"); - assert_eq!(state.command_line, "dir"); - assert_eq!(state.left.cursor, 1, "typing must not move the panel cursor"); -} - -#[test] -fn backspace_edits_the_buffer_and_clears_the_history_cursor() { - let mut state = test_state(UiPhase::Panels); - state.history = vec!["old".to_string()]; - let state = type_line(state, "dir"); - let (state, _) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.command_line, "old"); - assert_eq!(state.history_cursor, Some(0)); - - let (state, _) = update(state, Command::CommandLineBackspace); - assert_eq!(state.command_line, "ol"); - assert_eq!(state.history_cursor, None, "editing leaves history recall"); -} - -#[test] -fn command_line_clear_command_still_clears_the_buffer() { - // `Command::CommandLineClear` still exists and still behaves as a plain - // "clear the buffer" reducer action — only its trigger changed: Esc no - // longer dispatches it (`input::map_panel_key` maps panel-level Esc to - // `RequestQuit` unconditionally instead), so nothing in the shipped - // key-mapping layer emits this command anymore (command-line "Command - // history navigation": "Esc SHALL NOT clear the buffer"). - let state = type_line(test_state(UiPhase::Panels), "dir"); - let (state, _) = update(state, Command::CommandLineClear); - assert!(state.command_line.is_empty()); - assert_eq!(state.history_cursor, None); -} - -#[test] -fn quick_search_mode_consumes_printables_instead_of_the_command_line() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("alpha", 1), file_entry("beta", 2)]; - let (state, _) = update(state, Command::QuickSearchStart('b')); - assert_eq!(state.quick_search.as_deref(), Some("b")); - assert_eq!(state.left.cursor, 1, "quick search jumped to the first match"); - - let (state, _) = update(state, Command::QuickSearchChar('e')); - assert_eq!(state.quick_search.as_deref(), Some("be")); - assert!(state.command_line.is_empty(), "the command line never saw those keys"); -} - -#[test] -fn leaving_quick_search_hands_printables_back_to_the_command_line() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("alpha", 1)]; - let (state, _) = update(state, Command::QuickSearchStart('a')); - let (state, _) = update(state, Command::QuickSearchEnd); - assert_eq!(state.quick_search, None); - let state = type_line(state, "dir"); - assert_eq!(state.command_line, "dir"); -} - -#[test] -fn quick_search_backspace_shrinks_and_stays_active_when_emptied() { - // type-ahead-jump "Backspace on a single-character pattern": the - // pattern becomes empty but type-ahead mode itself stays active (only a - // movement key exits it now — Esc no longer does; it requests quit - // instead) and the cursor holds its position rather than re-jumping - // against an empty pattern. - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("alpha", 1)]; - let (state, _) = update(state, Command::QuickSearchStart('a')); - let (state, _) = update(state, Command::QuickSearchChar('l')); - assert_eq!(state.quick_search.as_deref(), Some("al")); - let cursor_before = state.left.cursor; - let (state, _) = update(state, Command::QuickSearchBackspace); - assert_eq!(state.quick_search.as_deref(), Some("a")); - let (state, _) = update(state, Command::QuickSearchBackspace); - assert_eq!(state.quick_search.as_deref(), Some(""), "type-ahead must remain active with an empty pattern"); - assert_eq!(state.left.cursor, cursor_before, "the cursor must hold position rather than jump on an empty pattern"); - let (state, _) = update(state, Command::QuickSearchEnd); - assert_eq!(state.quick_search, None, "QuickSearchEnd (now dispatched by a movement key, not Esc) still exits type-ahead"); -} - -// --------------------------------------------------------------------- -// Quick filter (Ctrl+P), reducer-level (task 15.2) -// --------------------------------------------------------------------- - -#[test] -fn quick_filter_start_char_and_end_are_scoped_to_the_active_panel() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; - state.right.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; - - let (state, _) = update(state, Command::QuickFilterStart); - assert_eq!(state.left.quick_filter.as_deref(), Some("")); - assert_eq!(state.right.quick_filter, None, "the opposite panel must be unaffected"); - - let (state, _) = update(state, Command::QuickFilterChar('r')); - let (state, _) = update(state, Command::QuickFilterChar('e')); - let (state, _) = update(state, Command::QuickFilterChar('p')); - assert_eq!(state.left.quick_filter.as_deref(), Some("rep")); - let visible: Vec = state.left.visible_indices().into_iter().map(|i| state.left.entries[i].name.to_string_lossy().into_owned()).collect(); - assert_eq!(visible, vec!["report.txt"]); - - let (state, _) = update(state, Command::QuickFilterBackspace); - assert_eq!(state.left.quick_filter.as_deref(), Some("re")); - - let (state, _) = update(state, Command::QuickFilterEnd); - assert_eq!(state.left.quick_filter, None); -} - -#[test] -fn jump_to_prefix_only_lands_within_the_active_quick_filter() { - // Input routing normally keeps type-ahead and the quick filter mutually - // exclusive, but `jump_to_prefix` (backing `QuickSearchStart`/`Char`) - // must still be safe if both are ever active together: it must not - // land the cursor on an entry the filter hides. Both "beta" and - // "berry" start with "b", so an unfiltered type-ahead jump would land - // on "beta" (it comes first); the active filter "rr" hides "beta" but - // not "berry", so the filtered jump must land on "berry" instead. - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("beta", 1), file_entry("berry", 2)]; - state.left.quick_filter = Some("rr".to_string()); - let (state, _) = update(state, Command::QuickSearchStart('b')); - assert_eq!( - state.left.entries[state.left.cursor].name.to_string_lossy(), - "berry", - "the jump must only search entries the active filter leaves visible" - ); -} - -// --------------------------------------------------------------------- -// Panel tabs (Ctrl+T / Ctrl+W / Alt+1..9), reducer-level (task 15.5) -// --------------------------------------------------------------------- - -#[test] -fn tab_commands_are_scoped_to_the_active_panel() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - - let (state, _) = update(state, Command::OpenTab); - assert_eq!(state.left.tab_count(), 2); - assert_eq!(state.right.tab_count(), 1, "the opposite panel's tabs must be untouched"); - - let (mut state, _) = update(state, Command::CloseTab); - assert_eq!(state.left.tab_count(), 1); - - state.left.open_tab(); - state.left.begin_new_listing(PathBuf::from("/left/other")); - let (state, _) = update(state, Command::SwitchTab(1)); - assert_eq!(state.left.cwd, PathBuf::from("/left")); -} - -#[test] -fn switch_tab_out_of_range_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - let before = state.right.cwd.clone(); - let (state, _) = update(state, Command::SwitchTab(9)); - assert_eq!(state.right.cwd, before); - assert_eq!(state.right.tab_count(), 1); -} - -// --------------------------------------------------------------------- -// Stale background tab refresh on activation (refresh-inactive-panel-on- -// delete; BIG-162) -// --------------------------------------------------------------------- - -#[test] -fn switch_tab_to_stale_tab_issues_fresh_read_and_clears_the_flag() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" - state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere - state.left.mark_background_tabs_stale(Path::new("/left")); - assert!(state.left.tabs[0].stale); - - let (state, effects) = update(state, Command::SwitchTab(1)); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "activated the previously-stale background tab"); - assert_eq!( - without_git_info_effects(effects), - vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }], - "activating a stale tab must issue a fresh read" - ); - - // The flag must be consumed, not left set: switching away and back - // must not trigger a second read. - let (state, _) = update(state, Command::SwitchTab(2)); - assert_eq!(state.left.cwd, PathBuf::from("/left/other")); - let (_, effects) = update(state, Command::SwitchTab(1)); - assert!(without_git_info_effects(effects).is_empty(), "the stale flag must not persist past its one consuming activation"); -} - -#[test] -fn close_tab_falling_back_to_stale_neighbor_issues_fresh_read() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" - state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere - state.left.mark_background_tabs_stale(Path::new("/left")); - - let (state, effects) = update(state, Command::CloseTab); - assert_eq!(state.left.tab_count(), 1, "closed the active tab, falling back to the one remaining tab"); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "fell back to the previously-stale background tab"); - assert_eq!( - without_git_info_effects(effects), - vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }], - "falling back to a stale neighbor must issue a fresh read" - ); -} - -#[test] -fn switch_tab_to_non_stale_tab_issues_no_listing_effects() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); - state.left.begin_new_listing(PathBuf::from("/left/other")); - // No mark_background_tabs_stale call: tab 0 is not stale. - - let (state, effects) = update(state, Command::SwitchTab(1)); - assert_eq!(state.left.cwd, PathBuf::from("/left")); - assert!(without_git_info_effects(effects).is_empty(), "switching to a tab with no pending staleness must not re-read (unchanged behavior)"); -} - -#[test] -fn stale_tab_refresh_records_no_history_and_persists_nothing() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" - state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere - state.left.mark_background_tabs_stale(Path::new("/left")); - let history_before = state.dir_history.clone(); - - let (state, effects) = update(state, Command::SwitchTab(1)); - assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. })), "the fresh read still happens"); - assert!(effects.iter().all(|e| !matches!(e, Effect::PersistHistory(_))), "a stale-activation refresh persists nothing, got {effects:?}"); - assert_eq!(state.dir_history, history_before, "a stale-activation refresh records no fuzzy-jump visit — the user switched tabs, not directories"); -} - -#[test] -fn close_tab_stale_neighbor_refresh_records_no_history_and_persists_nothing() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.left.cwd = PathBuf::from("/left"); - state.left.open_tab(); - state.left.begin_new_listing(PathBuf::from("/left/other")); - state.left.mark_background_tabs_stale(Path::new("/left")); - let history_before = state.dir_history.clone(); - - let (state, effects) = update(state, Command::CloseTab); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "fell back to the stale neighbor"); - assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. })), "the fresh read still happens"); - assert!(effects.iter().all(|e| !matches!(e, Effect::PersistHistory(_))), "a stale-activation refresh persists nothing, got {effects:?}"); - assert_eq!(state.dir_history, history_before, "a stale-activation refresh records no fuzzy-jump visit"); -} - -#[test] -fn history_up_recalls_previous_commands_newest_first() { - let mut state = test_state(UiPhase::Panels); - state.history = vec!["first".to_string(), "second".to_string(), "third".to_string()]; - let state = type_line(state, "x"); - - let (state, _) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.command_line, "third"); - let (state, _) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.command_line, "second"); - let (state, _) = update(state, Command::CommandLineHistoryNext); - assert_eq!(state.command_line, "third"); -} - -#[test] -fn history_up_stops_at_the_oldest_entry() { - let mut state = test_state(UiPhase::Panels); - state.history = vec!["only".to_string()]; - let state = type_line(state, "x"); - let (state, _) = update(state, Command::CommandLineHistoryPrev); - let (state, _) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.command_line, "only"); -} - -#[test] -fn history_navigation_never_moves_the_panel_cursor() { - let mut state = test_state(UiPhase::Panels); - state.history = vec!["dir".to_string()]; - state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; - state.left.cursor = 1; - let state = type_line(state, "x"); - let (state, _) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.left.cursor, 1); -} - -#[test] -fn history_recall_with_empty_history_is_a_noop() { - let state = type_line(test_state(UiPhase::Panels), "x"); - let (state, effects) = update(state, Command::CommandLineHistoryPrev); - assert_eq!(state.command_line, "x"); - assert!(effects.is_empty()); -} - -// `running_a_command_records_history_clears_the_buffer_and_persists` and -// `running_a_command_uses_the_configured_shell` used to assert that -// arbitrary typed text (`dir`, `Get-ChildItem`) reached the shell — no -// longer true now that the command line only recognizes `cd`/`del`/`rmdir` -// (command-line "Command-line builtin whitelist"). The buffer-clearing -// assertion moves to the rejection path below; `shell::build_command` -// respecting the configured shell is still exercised, just through the F2 -// user menu, one of its two remaining (unaffected) call sites. -#[test] -fn an_unrecognized_command_clears_the_buffer_and_is_rejected_without_touching_history() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - let state = type_line(state, "dir"); - let (state, effects) = update(state, Command::Enter); - - assert!(state.command_line.is_empty(), "the buffer is cleared even for a rejected line"); - assert!(state.history.is_empty(), "a rejected command is not recorded in history"); - assert!(effects.is_empty(), "nothing is spawned and nothing is persisted, got {effects:?}"); - assert_eq!(state.left.last_error.as_deref(), Some("'dir' is not a recognized command")); - assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"C:\NORTON").display()), "the panel itself is untouched"); -} - -#[test] -fn f2_user_menu_entry_uses_the_configured_shell() { - let mut state = test_state(UiPhase::Panels); - state.shell.shell = Some("powershell".to_string()); - state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "List".to_string(), command: "Get-ChildItem".to_string() }]; - let (state, _) = update(state, Command::UserMenuOpen); - let (_, effects) = update(state, Command::UserMenuConfirm); - match effects.first() { - Some(Effect::RunShellCommand(inv, _)) => { - assert_eq!(inv.program, "powershell"); - assert_eq!(inv.args, vec!["-NoLogo".to_string(), "-Command".to_string(), "Get-ChildItem".to_string()]); - } - other => panic!("expected a shell invocation, got {other:?}"), - } -} - -#[test] -fn enter_with_an_empty_buffer_still_navigates() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (_, effects) = update(state, Command::Enter); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left/sub") }]); -} - -// Superseded by the file-action menu (BREAKING, command-line "Enter on an -// executable opens the menu instead of spawning"): Enter on an executable no -// longer spawns it directly on the first keystroke — it opens the menu with -// Run first and highlighted, so `enter_on_an_executable_target_spawns_it_ -// through_the_shell` becomes the two tests below: the first Enter opens the -// menu (no spawn), and a second Enter (activating the highlighted Run entry) -// is what actually spawns it. -#[test] -fn enter_on_an_executable_target_opens_the_menu_with_run_first_and_does_not_spawn() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("setup.exe", 1)]; - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty(), "no direct spawn on the first Enter, got {effects:?}"); - let menu = state.file_action_menu.as_ref().expect("Enter on a file opens the file-action menu"); - assert_eq!(menu.target_name, OsString::from("setup.exe")); - assert_eq!(menu.entries[0], FileActionMenuEntry::Run, "an executable target lists Run first"); - assert_eq!(menu.selected(), FileActionMenuEntry::Run, "Run is highlighted by default"); -} - -#[test] -fn enter_enter_on_an_executable_spawns_it_through_the_shell() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("setup.exe", 1)]; - let (state, _) = update(state, Command::Enter); // opens the menu, Run highlighted - let (_, effects) = update(state, Command::FileActionMenuConfirm); // activates Run - match effects.as_slice() { - [Effect::RunShellCommand(inv, side)] => { - assert_eq!(inv.args.last().unwrap(), "\"setup.exe\""); - assert_eq!(inv.cwd, PathBuf::from("/left")); - assert_eq!(*side, PanelSide::Left); - } - other => panic!("expected the executable to spawn through the shell, got {other:?}"), - } -} - -// Superseded by the file-action menu (file-action-menu "Enter on a file -// opens the action menu"): Enter on a non-executable file used to be a dead -// key; it now opens the menu instead of doing nothing. -#[test] -fn enter_on_a_plain_file_opens_the_action_menu_without_spawning_anything() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("readme.txt", 1)]; - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty()); - let menu = state.file_action_menu.as_ref().expect("Enter on a non-executable file opens the file-action menu"); - assert_eq!(menu.target_name, OsString::from("readme.txt")); - assert!(!menu.entries.contains(&FileActionMenuEntry::Run), "non-executable: no Run entry"); - assert_eq!(menu.selected(), FileActionMenuEntry::View, "View is highlighted by default"); -} - -// --------------------------------------------------------------------- -// file-action-menu: navigation, dismissal, selection independence, -// precedence over a non-empty command buffer, and per-action routing. -// --------------------------------------------------------------------- - -/// Cursor on `cursor_on` among `names`, menu already open. -fn opened_menu_state(names: &[&str], cursor_on: &str) -> State { - let mut state = test_state(UiPhase::Panels); - state.left.entries = names.iter().map(|n| file_entry(n, 1)).collect(); - state.left.cursor = names.iter().position(|n| *n == cursor_on).unwrap(); - let (state, _) = update(state, Command::Enter); - assert!(state.file_action_menu.is_some(), "setup precondition: the menu must be open"); - state -} - -#[test] -fn file_action_menu_does_not_consume_or_alter_the_selection() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("report.txt", 2), file_entry("c.txt", 3), file_entry("d.txt", 4), file_entry("e.txt", 5)]; - for n in ["a.txt", "report.txt", "c.txt", "d.txt", "e.txt"] { - state.left.selected.insert(OsString::from(n)); - } - state.left.cursor = 1; // report.txt - let selected_before = state.left.selected.clone(); - - let (state, _) = update(state, Command::Enter); - let menu = state.file_action_menu.as_ref().expect("menu opens"); - assert_eq!(menu.target_name, OsString::from("report.txt"), "the menu targets only the cursor entry"); - assert_eq!(state.left.selected, selected_before, "opening the menu does not touch the selection"); - assert_eq!(state.left.selected.len(), 5); - - let (state, _) = update(state, Command::FileActionMenuCancel); - assert_eq!(state.left.selected, selected_before, "closing the menu leaves the selection intact"); -} - -#[test] -fn file_action_menu_does_not_open_when_the_command_buffer_is_non_empty() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("readme.txt", 1)]; - let state = type_line(state, "cd nosuchdir"); - let (state, effects) = update(state, Command::Enter); - assert!(state.file_action_menu.is_none(), "a non-empty command buffer takes precedence over the menu"); - assert!(effects.is_empty(), "the rejected cd spawns and starts nothing, got {effects:?}"); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the rejected cd leaves the panel untouched"); -} - -#[test] -fn file_action_menu_does_not_open_for_a_directory_which_still_navigates() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (state, effects) = update(state, Command::Enter); - assert!(state.file_action_menu.is_none(), "Enter on a directory must not open the menu"); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left/sub") }]); -} - -#[test] -fn file_action_menu_up_down_moves_the_highlight_clamped_at_both_ends() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - // Non-executable: View, Edit, Send to clipboard, Copy, Rename, Move, - // Delete (7 entries). - let (state, _) = update(state, Command::FileActionMenuMove(-5)); - assert_eq!(state.file_action_menu.as_ref().unwrap().cursor, 0, "Up from the first entry holds, it does not wrap"); - let (state, _) = update(state, Command::FileActionMenuMove(100)); - let menu = state.file_action_menu.as_ref().unwrap(); - assert_eq!(menu.cursor, menu.entries.len() - 1, "Down past the last entry clamps, it does not wrap"); - assert_eq!(menu.selected(), FileActionMenuEntry::Delete); -} - -#[test] -fn file_action_menu_esc_closes_with_no_action_and_leaves_the_cursor_and_panel_untouched() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, effects) = update(state, Command::FileActionMenuCancel); - assert!(state.file_action_menu.is_none()); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); - assert_eq!(state.left.cursor, 0); -} - -#[test] -fn file_action_menu_first_letter_hotkey_activates_directly() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, _) = update(state, Command::FileActionMenuHotkey('D')); - assert!(state.file_action_menu.is_none(), "the hotkey closes the menu"); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("notes.txt")); - } - other => panic!("expected the Delete hotkey to open DeleteConfirm, got {other:?}"), - } -} - -#[test] -fn file_action_menu_unknown_hotkey_is_a_no_op() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, effects) = update(state, Command::FileActionMenuHotkey('Z')); - assert!(state.file_action_menu.is_some(), "an unmatched hotkey leaves the menu open"); - assert!(effects.is_empty()); -} - -#[test] -fn file_action_menu_targets_the_entry_it_opened_on_even_if_the_cursor_drifts_from_a_background_listing_refresh() { - // Regression: ListingChunk is applied unconditionally regardless of any - // open modal (including this menu), and insert_streamed re-pins the - // cursor to row 0 whenever cursor_user_moved is still false — the - // normal state right after Enter opened the menu. The menu must keep - // acting on the entry it was opened on (`b.txt`), never on whatever a - // background listing update lands under the cursor in the meantime. - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("b.txt", 1)]; - state.left.cursor = 0; - let (state, _) = update(state, Command::Enter); // opens the menu on b.txt - let menu = state.file_action_menu.as_ref().unwrap(); - assert_eq!(menu.target_name, OsString::from("b.txt")); - - // A background listing chunk inserts a.txt, which sorts ahead of - // b.txt, and re-pins the (not-yet-user-moved) cursor to row 0. - let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![file_entry("a.txt", 1)] }); - assert_eq!(state.left.cursor, 0, "setup precondition: the cursor drifted onto a.txt"); - assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("a.txt")); - - let (state, _) = update(state, Command::FileActionMenuHotkey('D')); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("b.txt"), "must target the entry the menu opened on, not the drifted cursor"); - } - other => panic!("expected DeleteConfirm targeting b.txt (the menu's original target), got {other:?}"), - } -} - -#[test] -fn file_action_menu_view_routes_to_the_f3_viewer_path() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, effects) = update(state, Command::FileActionMenuConfirm); // View is highlighted first - assert!(state.file_action_menu.is_none()); - assert_eq!(effects, vec![Effect::OpenViewer { path: PathBuf::from("/left/notes.txt") }]); - assert_eq!(state.phase, UiPhase::Panels, "opening is I/O; the phase flips only once ViewerOpened comes back"); -} - -#[test] -fn file_action_menu_edit_routes_to_the_f4_edit_path() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, _) = update(state, Command::FileActionMenuMove(1)); // Edit - let (state, effects) = update(state, Command::FileActionMenuConfirm); - assert!(state.file_action_menu.is_none()); - assert_eq!(effects, vec![Effect::OpenEditor { path: PathBuf::from("/left/notes.txt") }]); -} - -#[test] -fn file_action_menu_copy_opens_the_f5_destination_dialog_scoped_to_the_target_only() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("report.txt", 2)]; - state.left.selected.insert(OsString::from("a.txt")); // an unrelated selection must not leak in - state.left.cursor = 1; // report.txt - let (state, _) = update(state, Command::Enter); - let (state, _) = update(state, Command::FileActionMenuMove(3)); // View, Edit, SendToClipboard, Copy - let (state, effects) = update(state, Command::FileActionMenuConfirm); - assert!(effects.is_empty(), "opening the destination dialog is not itself a mutation"); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, input, .. }) => { - assert_eq!(kind, JobKind::Copy); - assert_eq!(sources.len(), 1, "scoped to the menu's target only, not the selection"); - assert_eq!(sources[0].original_name, OsString::from("report.txt")); - assert_eq!(input, PathBuf::from("/right").display().to_string(), "pre-filled with the opposite panel's path"); - } - other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), - } -} - -#[test] -fn file_action_menu_move_opens_the_f6_destination_dialog_scoped_to_the_target_only() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, _) = update(state, Command::FileActionMenuMove(5)); // View, Edit, SendToClipboard, Copy, Rename, Move - let (state, _) = update(state, Command::FileActionMenuConfirm); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, .. }) => { - assert_eq!(kind, JobKind::Move); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("notes.txt")); - } - other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), - } -} - -#[test] -fn file_action_menu_delete_requires_the_existing_confirmation_and_declining_deletes_nothing() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, _) = update(state, Command::FileActionMenuHotkey('D')); - let (state, effects) = update(state, Command::FileOpCancel); - assert_eq!(state.phase, UiPhase::Panels, "declining returns to the panels"); - assert!(effects.is_empty(), "nothing runs, nothing is deleted"); -} - -#[test] -fn activating_a_mutating_menu_action_changes_nothing_before_its_dialog_is_accepted() { - // Copy, Move, Rename, and Delete each only *open* a setup dialog on - // activation — no `Effect::RunJob` (the only mutation-triggering effect) - // is emitted until that dialog's own Confirm (file-action-menu "No - // mutation without an intervening dialog"). - for hotkey in ['C', 'M', 'R', 'D'] { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, effects) = update(state, Command::FileActionMenuHotkey(hotkey)); - assert!(!effects.iter().any(|e| matches!(e, Effect::RunJob(_))), "hotkey {hotkey:?} must not run a job on activation alone"); - assert!(matches!(state.phase, UiPhase::FileOpSetup(_)), "hotkey {hotkey:?} should have opened a setup dialog, got {:?}", state.phase); - } -} - -#[test] -fn cancelling_the_interposed_destination_dialog_after_move_starts_no_job() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let (state, _) = update(state, Command::FileActionMenuHotkey('M')); - let (state, effects) = update(state, Command::FileOpCancel); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty()); -} - -// --------------------------------------------------------------------- -// file-action-menu: in-place Rename -// --------------------------------------------------------------------- - -#[test] -fn rename_pre_fills_the_current_name() { - let state = opened_menu_state(&["draft.txt"], "draft.txt"); - let (state, _) = update(state, Command::FileActionMenuHotkey('R')); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::RenameInput { original_name, input, .. }) => { - assert_eq!(original_name, OsString::from("draft.txt")); - assert_eq!(input, "draft.txt"); - } - other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), - } -} - -#[test] -fn rename_confirm_dispatches_a_rename_job_scoped_to_the_target_in_its_own_directory() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("draft.txt", 1)]; - let (state, _) = update(state, Command::Enter); - let (state, _) = update(state, Command::FileActionMenuHotkey('R')); - // Clear the "draft.txt" pre-fill via Backspace, then type the new name — - // driven the same way a real edit of the input would be (file-action-menu - // "Accepting the dialog SHALL rename the entry ... with the value - // `final.txt`"). - let state = (0.."draft.txt".len()).fold(state, |s, _| update(s, Command::FileOpInputBackspace).0); - let state = "final.txt".chars().fold(state, |s, c| update(s, Command::FileOpInputChar(c)).0); - match &state.phase { - UiPhase::FileOpSetup(FileOpSetup::RenameInput { input, .. }) => assert_eq!(input, "final.txt"), - other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), - } - let (state, effects) = update(state, Command::FileOpConfirm); - match effects.as_slice() { - [Effect::RunJob(job)] => { - assert_eq!(job.kind, JobKind::Rename); - assert_eq!(job.sources.len(), 1); - assert_eq!(job.sources[0].original_name, OsString::from("draft.txt")); - assert_eq!(job.sources[0].path, PathBuf::from(r"C:\NORTON\draft.txt")); - assert_eq!(job.source_dir, PathBuf::from(r"C:\NORTON")); - assert_eq!(job.dest_dir, PathBuf::from(r"C:\NORTON"), "the entry stays in the same directory"); - assert_eq!(job.new_dir_name, Some(OsString::from("final.txt"))); - } - other => panic!("expected exactly one RunJob effect, got {other:?}"), - } - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { kind: JobKind::Rename, .. }, .. })); -} - -/// The reducer carries a case-only edit of the pre-filled name through to -/// the job exactly as typed, without normalizing it away — the identity- -/// aware case-only rename itself happens in the job engine, once dispatched -/// (`fs_ops::worker::run_rename`; see `rename_case_only_change_succeeds_via_ -/// identity_check` in `fs_ops::worker::tests`) (file-action-menu "Case-only -/// rename works"). -#[test] -fn rename_case_only_edit_reaches_the_job_unchanged() { - let state = opened_menu_state(&["readme.md"], "readme.md"); - let (state, _) = update(state, Command::FileActionMenuHotkey('R')); - let state = (0.."readme.md".len()).fold(state, |s, _| update(s, Command::FileOpInputBackspace).0); - let state = "README.md".chars().fold(state, |s, c| update(s, Command::FileOpInputChar(c)).0); - let (_, effects) = update(state, Command::FileOpConfirm); - match effects.as_slice() { - [Effect::RunJob(job)] => { - assert_eq!(job.sources[0].original_name, OsString::from("readme.md")); - assert_eq!(job.new_dir_name, Some(OsString::from("README.md"))); - } - other => panic!("expected exactly one RunJob effect, got {other:?}"), - } -} - -#[test] -fn rename_esc_renames_nothing() { - let state = opened_menu_state(&["draft.txt"], "draft.txt"); - let (state, _) = update(state, Command::FileActionMenuHotkey('R')); - let (state, effects) = update(state, Command::FileOpCancel); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty(), "Esc must not dispatch a job"); -} - -#[test] -fn rename_collision_and_error_surface_through_the_existing_operation_dialogs() { - // Once dispatched, a Rename job is just another `Job` running through - // the ordinary `FileOpRunning` machinery — `JobConflict`/`JobError` - // fold into `RunningDialog::Conflict`/`RunningDialog::Error` exactly as - // they do for Copy/Move/Delete, regardless of `kind` (file-action-menu - // "Rename collisions/failures must surface through the existing - // overwrite-conflict and error-recovery dialogs"). - let running = running_progress_state(JobKind::Rename, "/left", "/left"); - let (state, _) = update(running, Command::JobConflict(sample_conflict())); - assert!( - matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { kind: JobKind::Rename, .. }, .. }), - "a rename collision surfaces the existing conflict dialog, got {:?}", - state.phase - ); - - let running = running_progress_state(JobKind::Rename, "/left", "/left"); - let err = ErrorInfo { path: PathBuf::from("/left/draft.txt"), message: "access denied".to_string() }; - let (state, _) = update(running, Command::JobError(err)); - assert!( - matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { kind: JobKind::Rename, .. }, .. }), - "a rename error surfaces the existing error dialog, got {:?}", - state.phase - ); -} - -#[test] -fn rename_success_rereads_the_affected_panel() { - let mut state = test_state(UiPhase::FileOpRunning { - source_dir: PathBuf::from("/left"), - dest_dir: PathBuf::from("/left"), - dialog: RunningDialog::Progress { kind: JobKind::Rename, progress: ProgressInfo::starting(1, 0) }, - }); - state.left.cwd = PathBuf::from("/left"); - let (state, effects) = update( - state, - Command::JobDone { outcome: JobOutcome::Completed { skipped: vec![] }, source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/left") }, - ); - assert_eq!(state.phase, UiPhase::Panels); - assert!( - effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, path } if path.as_path() == std::path::Path::new("/left"))), - "the panel showing the renamed entry's directory is re-read, got {effects:?}" - ); -} - -#[test] -fn ctrl_enter_pastes_the_cursor_filename_and_ctrl_bracket_the_full_path() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("README.md", 1)]; - - let (name_state, _) = update(state.clone(), Command::PasteCursorName); - assert_eq!(name_state.command_line, "README.md"); - - let (path_state, _) = update(state, Command::PasteCursorPath); - assert_eq!(path_state.command_line, PathBuf::from(r"C:\NORTON").join("README.md").display().to_string()); -} - -#[test] -fn pasting_onto_a_non_empty_buffer_separates_with_a_space() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let state = type_line(state, "type"); - let (state, _) = update(state, Command::PasteCursorName); - assert_eq!(state.command_line, "type a.txt"); -} - -#[test] -fn pasting_from_an_empty_panel_is_a_noop() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::PasteCursorName); - assert!(state.command_line.is_empty()); -} - -#[test] -fn ctrl_o_asks_the_tui_for_the_host_scrollback() { - let (_, effects) = update(test_state(UiPhase::Panels), Command::ShowScrollback); - assert_eq!(effects, vec![Effect::ShowScrollback]); -} - -#[test] -fn cd_navigates_the_panel_instead_of_spawning_a_shell() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from("/left"); - // The new synchronous existence check (command-line "cd navigates the - // active panel or rejects a nonexistent target") is satisfied here via - // the panel's own listing rather than real I/O — `sub` is a - // currently-visible directory entry. - state.left.entries = vec![dir_entry("sub")]; - let state = type_line(state, "cd sub"); - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); - assert!( - !effects.iter().any(|e| matches!(e, Effect::RunShellCommand(..))), - "`cd` in a fresh child would be a no-op, so it must not reach the shell" - ); - assert!(effects.contains(&Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left/sub") })); - assert_eq!(state.history, vec!["cd sub".to_string()], "`cd` is still a history entry"); -} - -#[test] -fn cd_to_a_nonexistent_directory_is_rejected_without_navigating() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - let state = type_line(state, r"cd \NOSUCHDIR"); - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the active panel's cwd is left completely unchanged"); - assert!(effects.is_empty(), "no listing read is attempted, got {effects:?}"); - assert!(state.left.last_error.is_some(), "the panel shows an error indicating the target was not found"); - assert!(state.history.is_empty(), "a rejected cd is not recorded in history"); -} - -#[test] -fn cd_to_a_file_is_rejected() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("readme.txt", 1)]; - let state = type_line(state, "cd readme.txt"); - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the active panel's cwd is left unchanged"); - assert!(effects.is_empty(), "no listing read is attempted, got {effects:?}"); - let expected = format!("{} is not a directory", PathBuf::from(r"C:\NORTON\readme.txt").display()); - assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); -} - -#[test] -fn cd_accepts_a_manually_entered_unc_path() { - // A manually-typed UNC path resolves as absolute rather than being - // joined onto the panel's cwd (this is `dispatch_cd`'s pure path - // arithmetic, shared with `del`/`rmdir`); a fabricated share can't - // additionally satisfy the new existence check in a unit test (no real - // network I/O here), so that half is covered separately by the - // synchronous-rejection and race-scenario tests below. - assert_eq!(resolve_cd_target(Path::new(r"C:\NORTON"), r"\\server\share"), Some(PathBuf::from(r"\\server\share"))); -} - -#[test] -fn a_cd_target_that_fails_asynchronously_after_passing_the_sync_check_surfaces_the_panel_error_state() { - // Different from `cd_to_a_nonexistent_directory_is_rejected_without_ - // navigating`: this is the race where the target existed at - // synchronous-check time (so the check passed and a listing began) but - // became unreachable before the read completed — the read itself runs - // on a worker thread, and its failure comes back through the same - // event path as any other listing error (design.md "a race where a - // target existed at check time but became unreachable before the read - // completed"). - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("share")]; - let state = type_line(state, "cd share"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { .. })), "the sync check passed, so a listing began"); - let (state, _) = update(state, Command::ListingFailed { panel: PanelSide::Left, message: "network path not found".into() }); - assert_eq!(state.left.last_error.as_deref(), Some("network path not found")); -} - -#[test] -fn cd_dotdot_and_bare_drive_letters_resolve() { - assert_eq!(resolve_cd_target(Path::new("/a/b"), ".."), Some(PathBuf::from("/a"))); - assert_eq!(resolve_cd_target(Path::new("/a/b"), "."), Some(PathBuf::from("/a/b"))); - assert_eq!(resolve_cd_target(Path::new("/a"), "D:"), Some(PathBuf::from(r"D:\"))); - assert_eq!(parse_cd("dir"), None); - assert_eq!(parse_cd("cd \"C:\\Program Files\" "), Some(r"C:\Program Files".to_string())); - assert_eq!(parse_cd("CD sub"), Some("sub".to_string()), "builtin verbs are case-insensitive"); -} - -// --------------------------------------------------------------------- -// command-line-builtin-whitelist: `del`/`rmdir` typed builtins -// --------------------------------------------------------------------- - -#[test] -fn del_on_a_file_opens_the_delete_confirm_dialog_naming_it() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("notes.txt", 1)]; - let state = type_line(state, "del notes.txt"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty(), "nothing is deleted until the dialog is accepted, got {effects:?}"); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, confirmed_once, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("notes.txt")); - assert_eq!(sources[0].path, PathBuf::from(r"C:\NORTON\notes.txt")); - assert!(!sources[0].is_dir); - assert!(!needs_second_confirm, "a single file needs only one confirmation"); - assert!(!confirmed_once); - } - other => panic!("expected the delete-confirm dialog to open, got {other:?}"), - } - assert!(state.history.is_empty(), "`del` is not recorded in command history"); -} - -#[test] -fn rmdir_on_a_directory_opens_the_delete_confirm_dialog_and_needs_second_confirmation() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![dir_entry("docs")]; - let state = type_line(state, "rmdir docs"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty()); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, confirmed_once, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("docs")); - assert!(sources[0].is_dir); - assert!(needs_second_confirm, "a directory requires the existing second confirmation"); - assert!(!confirmed_once); - } - other => panic!("expected the delete-confirm dialog to open, got {other:?}"), - } - - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(effects.is_empty(), "first confirm just arms the second confirmation"); - let (_, effects) = update(state, Command::FileOpConfirm); - assert!(matches!(effects.as_slice(), [Effect::RunJob(Job { kind: JobKind::Delete, .. })]), "nothing was deleted until the second confirm"); -} - -#[test] -fn del_rejects_a_directory_target() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![dir_entry("docs")]; - let state = type_line(state, "del docs"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels, "no dialog opens"); - let expected = format!("{} is a directory", PathBuf::from(r"C:\NORTON\docs").display()); - assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); -} - -#[test] -fn rmdir_rejects_a_file_target() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("notes.txt", 1)]; - let state = type_line(state, "rmdir notes.txt"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels, "no dialog opens"); - let expected = format!("{} is not a directory", PathBuf::from(r"C:\NORTON\notes.txt").display()); - assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); -} - -#[test] -fn del_and_rmdir_reject_dot_targets() { - for verb in ["del", "rmdir"] { - for target in [".", "..", r".\", r"..\"] { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![dir_entry("docs")]; - let state = type_line(state, &format!("{verb} {target}")); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty(), "{verb} {target}: no dialog and no effects, got {effects:?}"); - assert_eq!(state.phase, UiPhase::Panels, "{verb} {target}: the delete-confirm dialog must never open on the current or parent directory"); - let expected = format!("{verb}: invalid target {target}"); - assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str()), "{verb} {target}: invalid-target error"); - } - } -} - -#[test] -fn del_dotdot_sibling_is_not_caught_by_the_dot_target_guard() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - let state = type_line(state, r"del ..\sibling.txt"); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); - let err = state.left.last_error.unwrap(); - assert!(err.ends_with("not found"), "a multi-component relative target follows the ordinary not-found path, got: {err}"); -} - -#[test] -fn del_and_rmdir_on_a_nonexistent_target_are_rejected() { - for verb in ["del", "rmdir"] { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - let state = type_line(state, &format!(r"{verb} \NOSUCHFILE.TXT")); - let (state, effects) = update(state, Command::Enter); - assert!(effects.is_empty(), "{verb}: nothing spawns and no dialog opens, got {effects:?}"); - assert_eq!(state.phase, UiPhase::Panels, "{verb}: no dialog opens"); - assert!(state.left.last_error.is_some(), "{verb}: the panel shows a not-found error"); - } -} - -#[test] -fn del_and_rmdir_verbs_are_case_insensitive() { - assert_eq!(parse_del("DEL notes.txt"), Some("notes.txt".to_string())); - assert_eq!(parse_rmdir("RmDir docs"), Some("docs".to_string())); - assert_eq!(parse_del("delete notes.txt"), None, "`delete` must not falsely match the `del` verb"); - assert_eq!(parse_rmdir("rd docs"), None, "`rd` is not an alias for `rmdir` (design.md non-goal)"); -} - -// --------------------------------------------------------------------- -// M3: sort modes -// --------------------------------------------------------------------- - -#[test] -fn sort_mode_commands_reorder_without_issuing_a_read() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("b.txt", 10), file_entry("a.txt", 30), file_entry("c.txt", 20)]; - - let (state, effects) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); - assert!(effects.is_empty(), "sorting must not issue a directory read"); - let names: Vec = state.left.entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); - assert_eq!(names, vec!["b.txt", "c.txt", "a.txt"]); - assert_eq!(state.left.sort_mode, SortMode::Size); -} - -#[test] -fn sort_mode_is_set_per_panel_and_leaves_the_other_alone() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("b", 1), file_entry("a", 2)]; - state.right.entries = vec![file_entry("b", 1), file_entry("a", 2)]; - let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); - assert_eq!(state.left.sort_mode, SortMode::Size); - assert_eq!(state.right.sort_mode, SortMode::Name); - let right_names: Vec = state.right.entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); - assert_eq!(right_names, vec!["b", "a"], "the untouched panel keeps its order"); -} - -#[test] -fn every_sort_mode_command_lands_on_its_mode() { - for mode in [SortMode::Name, SortMode::Extension, SortMode::Time, SortMode::Size, SortMode::Unsorted] { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode }); - assert_eq!(state.left.sort_mode, mode); - } -} - -#[test] -fn reread_preserves_the_sort_mode() { - let mut state = test_state(UiPhase::Panels); - state.left.sort_mode = SortMode::Size; - let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); - assert_eq!(state.left.sort_mode, SortMode::Size); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/left") }]); - assert!(state.left.entries.is_empty(), "a re-read discards the current entries and streams fresh ones"); - assert!(state.left.progress.is_streaming()); -} - -// --------------------------------------------------------------------- -// M3: F9 menus -// --------------------------------------------------------------------- - -#[test] -fn f9_opens_the_bar_with_the_left_pulldown_showing() { - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let menu = state.menu.expect("F9 opens the bar"); - assert_eq!(menu.active, MenuId::Left); - assert!(menu.pulldown_open); -} - -#[test] -fn esc_closes_the_pulldown_first_then_the_bar() { - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let (state, _) = update(state, Command::MenuCollapse); - let menu = state.menu.as_ref().expect("the bar stays open after the first Esc"); - assert!(!menu.pulldown_open); - assert_eq!(menu.active, MenuId::Left, "the active title stays highlighted"); - - let (state, _) = update(state, Command::MenuCollapse); - assert!(state.menu.is_none(), "the second Esc closes the bar"); -} - -#[test] -fn hotkey_letter_jumps_to_another_menu_with_its_pulldown_open() { - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('c')); - let menu = state.menu.expect("bar still open"); - assert_eq!(menu.active, MenuId::Commands); - assert!(menu.pulldown_open); -} - -#[test] -fn horizontal_movement_keeps_a_pulldown_open_and_wraps() { - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let (state, _) = update(state, Command::MenuNextMenu); - assert_eq!(state.menu.as_ref().unwrap().active, MenuId::Files); - assert!(state.menu.as_ref().unwrap().pulldown_open); - - // Left -> Files -> Commands -> Options -> Right -> wraps to Left. - let state = (0..4).fold(state, |s, _| update(s, Command::MenuNextMenu).0); - assert_eq!(state.menu.as_ref().unwrap().active, MenuId::Left); - - let (state, _) = update(state, Command::MenuPrevMenu); - assert_eq!(state.menu.as_ref().unwrap().active, MenuId::Right); - assert!(state.menu.as_ref().unwrap().pulldown_open); -} - -#[test] -fn vertical_selection_skips_disabled_items_and_separators() { - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('f')); - // Files opens on Copy (View/Edit are M4 and disabled). - assert_eq!(state.menu.as_ref().unwrap().selected_item().map(|i| i.label), Some("Copy")); - let (state, _) = update(state, Command::MenuSelectNext); - assert_eq!(state.menu.as_ref().unwrap().selected_item().map(|i| i.label), Some("Rename/Move")); - let (state, _) = update(state, Command::MenuSelectPrev); - assert_eq!(state.menu.as_ref().unwrap().selected_item().map(|i| i.label), Some("Copy")); -} - -#[test] -fn enter_dispatches_the_selected_item_and_closes_the_overlay() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('f')); - let (state, _) = update(state, Command::MenuActivate); // Copy - - assert!(state.menu.is_none(), "activating an item closes the whole overlay"); - assert!( - matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Copy, .. })), - "the item's action ran, got {:?}", - state.phase - ); -} - -#[test] -fn left_menu_sorts_the_left_panel_even_when_the_right_one_is_focused() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - state.left.entries = vec![file_entry("b", 1), file_entry("a", 2)]; - - let (state, _) = update(state, Command::MenuOpen); // Left menu - // Opens on Brief; Down walks Full, Tree, Quick view, Info, then (the - // separator skipped, not counted) Name, Extension, Modif. time, Size. - let state = (0..8).fold(state, |s, _| update(s, Command::MenuSelectNext).0); - assert_eq!(state.menu.as_ref().unwrap().selected_item().map(|i| i.label), Some("Size")); - let (state, _) = update(state, Command::MenuActivate); - - assert_eq!(state.left.sort_mode, SortMode::Size, "the Left menu targets the left panel regardless of focus"); - assert_eq!(state.right.sort_mode, SortMode::Name); -} - -#[test] -fn right_menu_targets_the_right_panel() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('r')); - // Opens on Brief; Down four times walks Full, Tree, Quick view, Info. - let state = (0..4).fold(state, |s, _| update(s, Command::MenuSelectNext).0); - let (state, effects) = update(state, Command::MenuActivate); // Info - assert_eq!(state.right.display_mode, DisplayMode::Info); - assert_eq!(state.left.display_mode, DisplayMode::Full); - assert!(effects.iter().any(|e| matches!(e, Effect::QueryInfo { panel: PanelSide::Right, .. }))); -} - -#[test] -fn activating_options_themes_opens_the_theme_picker_and_closes_the_menu() { - // Options used to be entirely disabled (the old target of this test); - // Themes (visual-themes) is now its first enabled item, so activating - // Options and pressing Enter opens the picker instead of doing nothing - // (theme-selection "Options menu opens the theme picker"). - let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('o')); // Options: Themes is first-selectable - let (state, effects) = update(state, Command::MenuActivate); - assert!(state.menu.is_none(), "activating an item closes the whole menu overlay"); - assert!(state.theme_picker.is_some(), "Options -> Themes opens the picker"); - assert!(effects.is_empty()); -} - -#[test] -fn panel_keys_do_not_leak_through_an_open_menu() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; - let (state, _) = update(state, Command::MenuOpen); - // MoveCursor is not a menu command; the mapper would never emit it while - // the bar is open, but the overlay must not be disturbed if it does. - let (state, _) = update(state, Command::MenuSelectNext); - assert_eq!(state.left.cursor, 0); - assert!(state.menu.is_some()); -} - -#[test] -fn every_menu_item_that_is_enabled_maps_to_a_command() { - for id in crate::menu::ALL_MENUS { - for entry in crate::menu::entries(id) { - if let MenuEntry::Item(item) = entry { - if item.is_enabled() { - assert!( - menu_action_command(item.action, PanelSide::Left).is_some(), - "`{}` is enabled but dispatches nothing", - item.label - ); - } - } - } - } -} - -// --------------------------------------------------------------------- -// M3: drive select -// --------------------------------------------------------------------- - -#[test] -fn alt_f1_and_alt_f2_target_their_own_panels() { - for side in [PanelSide::Left, PanelSide::Right] { - let (_, effects) = update(test_state(UiPhase::Panels), Command::OpenDriveSelect(side)); - assert_eq!(effects, vec![Effect::EnumerateDrives(side)]); - } -} - -#[test] -fn drive_list_opens_the_dialog_and_requests_every_label_lazily() { - let (state, effects) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C', 'D'] }, - ); - let dialog = state.drive_select.as_ref().expect("the dialog opened"); - assert_eq!(dialog.target, PanelSide::Left); - assert_eq!(dialog.drives.len(), 3, "every letter is present on the first frame"); - assert!(dialog.drives.iter().all(|d| d.label.is_none()), "labels are still pending"); - let generation = dialog.generation; - assert_eq!( - effects, - vec![ - Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'A', generation }, - Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'C', generation }, - Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'D', generation }, - ] - ); -} - -#[test] -fn the_dialog_opens_on_the_target_panel_s_current_drive() { - let mut state = test_state(UiPhase::Panels); - state.right.cwd = PathBuf::from(r"D:\work"); - let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Right, drives: vec!['C', 'D'] }); - assert_eq!(state.drive_select.unwrap().selected_letter(), Some('D')); -} - -#[test] -fn a_resolved_label_fills_in_place_without_moving_other_rows() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C'] }, - ); - let generation = state.drive_select.as_ref().unwrap().generation; - let (state, effects) = update( - state, - Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation }, - ); - assert!(effects.is_empty()); - let dialog = state.drive_select.unwrap(); - assert_eq!(dialog.drives, vec![DriveEntry { letter: 'A', label: None }, DriveEntry { letter: 'C', label: Some("OS".to_string()) }]); -} - -#[test] -fn a_label_arriving_after_the_dialog_closed_is_discarded() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }, - ); - let generation = state.drive_select.as_ref().unwrap().generation; - let (state, _) = update(state, Command::DriveSelectCancel); - assert!(state.drive_select.is_none()); - - let (state, effects) = update( - state, - Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation }, - ); - assert!(state.drive_select.is_none(), "a stale result must not resurrect the dialog"); - assert!(effects.is_empty()); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "and must not touch the panel"); -} - -#[test] -fn a_label_for_a_superseded_target_panel_is_discarded() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }, - ); - let first_generation = state.drive_select.as_ref().unwrap().generation; - // The dialog was reopened for the other panel before the label landed. - let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Right, drives: vec!['C'] }); - let (state, _) = update( - state, - Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation: first_generation }, - ); - assert_eq!(state.drive_select.unwrap().drives[0].label, None); -} - -#[test] -fn a_stale_drive_label_from_a_quick_reopen_of_the_same_target_is_discarded_but_the_fresher_one_applies() { - // Regression coverage for the out-of-order-completion race: the user - // cancels and reopens the drive dialog for the *same* panel before the - // first fetch lands (Alt+F1, Esc, Alt+F1 again). `target` alone can't - // tell the two sessions apart — only `generation` can. - let (state, _) = - update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); - let first_generation = state.drive_select.as_ref().unwrap().generation; - - let (state, _) = update(state, Command::DriveSelectCancel); - let (state, _) = - update(state, Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); - let second_generation = state.drive_select.as_ref().unwrap().generation; - assert_ne!(first_generation, second_generation, "reopening mints a fresh generation"); - - let (state, _) = update( - state, - Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("STALE".to_string()), generation: first_generation }, - ); - assert_eq!(state.drive_select.as_ref().unwrap().drives[0].label, None, "the stale (first-generation) answer is dropped"); - - let (state, _) = update( - state, - Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("FRESH".to_string()), generation: second_generation }, - ); - assert_eq!(state.drive_select.unwrap().drives[0].label, Some("FRESH".to_string()), "the current generation's answer applies"); -} - -#[test] -fn esc_dismisses_the_dialog_leaving_the_panel_where_it_was() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\Users"); - let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Left, drives: vec!['C', 'D'] }); - let (state, effects) = update(state, Command::DriveSelectCancel); - assert!(state.drive_select.is_none()); - assert!(effects.is_empty()); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\Users")); -} - -#[test] -fn selecting_a_drive_switches_the_target_panel_to_its_root() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Right, drives: vec!['C', 'D'] }, - ); - let (state, _) = update(state, Command::DriveSelectMove(1)); - let (state, effects) = update(state, Command::DriveSelectConfirm); - assert!(state.drive_select.is_none(), "confirming closes the dialog"); - assert_eq!(state.right.cwd, PathBuf::from(r"D:\")); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from(r"D:\") }]); -} - -#[test] -fn selecting_an_unavailable_drive_surfaces_the_panel_error_state() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['A'] }, - ); - let (state, effects) = update(state, Command::DriveSelectConfirm); - assert_eq!(without_git_info_effects(effects), vec![Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from(r"A:\") }]); - // The worker's read fails and comes back as a normal listing failure — - // no dedicated hang-prone path. - let (state, _) = update(state, Command::ListingFailed { panel: PanelSide::Left, message: "device not ready".into() }); - assert_eq!(state.left.last_error.as_deref(), Some("device not ready")); -} - -#[test] -fn drive_select_claims_navigation_keys_while_open() { - let (state, _) = update( - test_state(UiPhase::Panels), - Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C', 'D'] }, - ); - let (state, _) = update(state, Command::DriveSelectMove(2)); - assert_eq!(state.drive_select.as_ref().unwrap().selected_letter(), Some('D')); - let (state, _) = update(state, Command::DriveSelectMove(-1)); - assert_eq!(state.drive_select.unwrap().selected_letter(), Some('C')); -} - -// --------------------------------------------------------------------- -// M3: Info mode -// --------------------------------------------------------------------- - -#[test] -fn ctrl_l_toggles_info_for_one_panel_only() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::ToggleInfoMode(PanelSide::Left)); - assert_eq!(state.left.display_mode, DisplayMode::Info); - assert_eq!(state.right.display_mode, DisplayMode::Full, "the opposite panel is untouched"); - let request = state.left.info_request.expect("a request id was minted"); - assert_eq!(effects, vec![Effect::QueryInfo { panel: PanelSide::Left, path: PathBuf::from("/left"), request }]); - - let (state, effects) = update(state, Command::ToggleInfoMode(PanelSide::Left)); - assert_eq!(state.left.display_mode, DisplayMode::Full); - assert!(effects.is_empty(), "leaving Info mode queries nothing"); -} - -#[test] -fn info_values_start_pending_and_resolve_in_place() { - let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); - assert_eq!(state.left.info, InfoValues::default(), "every value starts unresolved"); - - let request = state.left.info_request.unwrap(); - let values = InfoValues { file_count: Some(12), dir_count: Some(3), ..InfoValues::default() }; - let (state, effects) = update( - state, - Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, values: values.clone() }, - ); - assert!(effects.is_empty()); - assert_eq!(state.left.info, values); -} - -#[test] -fn an_info_result_for_a_directory_the_panel_left_is_discarded() { - let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); - let request = state.left.info_request.unwrap(); - let values = InfoValues { file_count: Some(12), ..InfoValues::default() }; - let (state, _) = - update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/elsewhere"), request, values }); - assert_eq!(state.left.info, InfoValues::default(), "a result for another directory is dropped"); -} - -#[test] -fn an_info_result_arriving_after_info_mode_was_left_is_discarded() { - let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); - let request = state.left.info_request.unwrap(); - let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); // back to Full - let values = InfoValues { file_count: Some(12), ..InfoValues::default() }; - let (state, _) = update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, values }); - assert_eq!(state.left.info, InfoValues::default()); -} - -#[test] -fn navigating_while_in_info_mode_re_queries_for_the_new_directory() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); - let request = state.left.info_request.expect("navigating while in Info mode mints a fresh request"); - assert!(effects.contains(&Effect::QueryInfo { panel: PanelSide::Left, path: PathBuf::from("/left/sub"), request })); - assert_eq!(state.left.info, InfoValues::default(), "the previous directory's figures are cleared"); -} - -#[test] -fn a_stale_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies() { - // Regression coverage for the out-of-order-completion race: a double - // Ctrl+R (or any two RereadPanel commands landing before the first - // reply arrives) mints two different request ids for the *same* - // directory, so `path` equality alone can't tell the stale reply apart - // from the current one. - let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); - let first_request = state.left.info_request.unwrap(); - - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let second_request = state.left.info_request.unwrap(); - assert_ne!(first_request, second_request, "re-reading mints a fresh request id even for the same path"); - - let stale_values = InfoValues { file_count: Some(999), ..InfoValues::default() }; - let (state, _) = update( - state, - Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request: first_request, values: stale_values }, - ); - assert_eq!(state.left.info, InfoValues::default(), "the stale (first-request) answer is dropped"); - - let fresh_values = InfoValues { file_count: Some(7), ..InfoValues::default() }; - let (state, _) = update( - state, - Command::InfoResolved { - panel: PanelSide::Left, - path: PathBuf::from("/left"), - request: second_request, - values: fresh_values.clone(), - }, - ); - assert_eq!(state.left.info, fresh_values, "the current request's answer applies"); -} - -#[test] -fn a_panel_not_in_info_mode_issues_no_info_query_when_it_navigates() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (_, effects) = update(state, Command::Enter); - assert!(!effects.iter().any(|e| matches!(e, Effect::QueryInfo { .. }))); -} - -// --------------------------------------------------------------------- -// M4: F3 viewer & F4 external editor -// --------------------------------------------------------------------- - -#[test] -fn f3_on_a_file_dispatches_open_viewer_without_touching_the_filesystem() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("notes.txt", 42)]; - let (state, effects) = update(state, Command::RequestViewer); - assert_eq!(effects, vec![Effect::OpenViewer { path: PathBuf::from("/left/notes.txt") }]); - // Nothing changed yet — the phase flips only once `ViewerOpened` comes - // back, since opening is I/O and `update` performs none itself. - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn f3_on_a_directory_or_parent_dir_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (_, effects) = update(state, Command::RequestViewer); - assert!(effects.is_empty()); - - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir()]; - let (_, effects) = update(state, Command::RequestViewer); - assert!(effects.is_empty()); -} - -#[test] -fn f3_on_an_empty_panel_is_a_no_op() { - let state = test_state(UiPhase::Panels); - let (_, effects) = update(state, Command::RequestViewer); - assert!(effects.is_empty()); -} - -#[test] -fn viewer_opened_enters_the_viewer_phase_at_the_start_of_the_file() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::ViewerOpened { path: PathBuf::from("/left/big.log"), file_len: 5_000_000_000 }); - assert!(effects.is_empty()); - match state.phase { - UiPhase::Viewer(v) => { - assert_eq!(v.path, PathBuf::from("/left/big.log")); - assert_eq!(v.file_len, 5_000_000_000); - assert_eq!(v.top_offset, 0); - assert_eq!(v.mode, crate::viewer::ViewMode::Text); - assert!(!v.wrap); - } - other => panic!("expected UiPhase::Viewer, got {other:?}"), - } -} - -#[test] -fn viewer_open_failed_surfaces_an_inline_error_on_the_active_panel_instead_of_opening() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::ViewerOpenFailed { message: "access denied".to_string() }); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); - assert_eq!(state.left.last_error.as_deref(), Some("access denied")); -} - -#[test] -fn a_subsequently_successful_f3_open_clears_a_stale_last_error_from_an_earlier_failed_attempt() { - let mut state = test_state(UiPhase::Panels); - let (state_after_failure, _) = update(state.clone(), Command::ViewerOpenFailed { message: "access denied".to_string() }); - assert_eq!(state_after_failure.left.last_error.as_deref(), Some("access denied")); - - state.left.last_error = Some("access denied".to_string()); - let (state, _) = update(state, Command::ViewerOpened { path: PathBuf::from("/left/notes.txt"), file_len: 10 }); - assert_eq!( - state.left.last_error, None, - "a successful F3 open must clear a stale error left by an earlier failed attempt" - ); - assert!(matches!(state.phase, UiPhase::Viewer(_))); -} - -fn opened_viewer_state(file_len: u64) -> State { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::ViewerOpened { path: PathBuf::from("/left/f.txt"), file_len }); - state -} - -#[test] -fn f10_closes_the_viewer_and_returns_focus_to_the_panels() { - let state = opened_viewer_state(1000); - let (state, effects) = update(state, Command::ViewerClose); - assert_eq!(state.phase, UiPhase::Panels); - assert!(effects.is_empty()); -} - -#[test] -fn f4_in_the_viewer_toggles_mode_and_the_key_bar_label_swaps() { - let state = opened_viewer_state(1000); - let (state, effects) = update(state, Command::ViewerToggleMode); - assert!(effects.is_empty()); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.mode, crate::viewer::ViewMode::Hex); - assert_eq!(v.mode.toggle_label(), "ASCII"); - - let (state, _) = update(state, Command::ViewerToggleMode); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.mode, crate::viewer::ViewMode::Text); - assert_eq!(v.mode.toggle_label(), "Hex"); -} - -#[test] -fn f2_in_the_viewer_toggles_wrap_and_resets_horizontal_scroll() { - let mut state = opened_viewer_state(1000); - if let UiPhase::Viewer(v) = &mut state.phase { - v.h_scroll = 12; - } - let (state, _) = update(state, Command::ViewerToggleWrap); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert!(v.wrap); - assert_eq!(v.h_scroll, 0); -} - -#[test] -fn viewer_set_top_clamps_to_the_file_length() { - let state = opened_viewer_state(100); - let (state, _) = update(state, Command::ViewerSetTop(1_000_000)); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.top_offset, 100); -} - -#[test] -fn viewer_set_h_scroll_updates_the_column_indicator() { - let state = opened_viewer_state(1000); - let (state, _) = update(state, Command::ViewerSetHScroll(30)); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.h_scroll, 30); -} - -#[test] -fn viewer_keys_are_not_forwarded_to_panels_while_the_viewer_is_open() { - // ToggleActivePanel is a Panels-phase command; while the viewer owns - // the phase it must be swallowed rather than mutating panel focus - // (viewer: Frame-less full-screen chrome — "Viewer owns focus while - // open"). - let state = opened_viewer_state(1000); - let active_before = state.active; - let (state, effects) = update(state, Command::ToggleActivePanel); - assert_eq!(state.active, active_before); - assert!(effects.is_empty()); - assert!(matches!(state.phase, UiPhase::Viewer(_))); -} - -#[test] -fn viewer_search_confirm_dispatches_the_search_effect_and_keeps_the_pattern() { - let state = opened_viewer_state(1000); - let (state, _) = update(state, Command::ViewerSearchStart); - let (state, _) = update(state, Command::ViewerSearchChar('a')); - let (state, _) = update(state, Command::ViewerSearchChar('b')); - let (state, effects) = update(state, Command::ViewerSearchConfirm); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - let request = v.search_request.expect("a search request id must be minted and recorded"); - assert_eq!( - effects, - vec![Effect::RunViewerSearch { path: PathBuf::from("/left/f.txt"), start_offset: 0, pattern: b"ab".to_vec(), request }] - ); - assert_eq!(v.search_pattern, Some(b"ab".to_vec())); - assert_eq!(v.search_input, None, "the prompt closes once the search is dispatched"); -} - -#[test] -fn viewer_search_backspace_and_cancel_edit_the_in_progress_pattern() { - let state = opened_viewer_state(1000); - let (state, _) = update(state, Command::ViewerSearchStart); - let (state, _) = update(state, Command::ViewerSearchChar('x')); - let (state, _) = update(state, Command::ViewerSearchBackspace); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.search_input, Some(String::new())); - - let (state, _) = update(state, Command::ViewerSearchCancel); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.search_input, None); -} - -#[test] -fn empty_search_pattern_does_not_dispatch_a_search() { - let state = opened_viewer_state(1000); - let (state, _) = update(state, Command::ViewerSearchStart); - let (state, effects) = update(state, Command::ViewerSearchConfirm); - assert!(effects.is_empty()); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.search_pattern, None); -} - -/// Run a real F7 search-confirm sequence so the returned state carries the -/// actual outstanding `search_request` id, mirroring how the TUI event loop -/// would drive it (`ViewerSearchStart` -> typed chars -> `ViewerSearchConfirm`). -fn viewer_with_pattern_confirmed(state: State, pattern: &str) -> (State, u64) { - let (state, _) = update(state, Command::ViewerSearchStart); - let mut state = state; - for c in pattern.chars() { - let (s, _) = update(state, Command::ViewerSearchChar(c)); - state = s; - } - let (state, _) = update(state, Command::ViewerSearchConfirm); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - let request = v.search_request.expect("search confirm must record an outstanding request id"); - (state, request) -} - -#[test] -fn viewer_search_result_moves_the_top_anchor_and_highlights_the_match() { - let state = opened_viewer_state(1000); - let (state, request) = viewer_with_pattern_confirmed(state, "ab"); - let (state, _) = update(state, Command::ViewerSearchResult { offset: Some(250), match_range: Some((250, 256)), request }); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.top_offset, 250); - assert_eq!(v.last_match, Some((250, 256))); -} - -#[test] -fn viewer_search_result_with_no_match_leaves_the_top_anchor_untouched() { - let mut state = opened_viewer_state(1000); - if let UiPhase::Viewer(v) = &mut state.phase { - v.top_offset = 40; - } - let (state, request) = viewer_with_pattern_confirmed(state, "zz"); - let (state, _) = update(state, Command::ViewerSearchResult { offset: None, match_range: None, request }); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.top_offset, 40); -} - -#[test] -fn a_stale_out_of_order_search_reply_is_dropped_not_applied() { - // The M3-style staleness race: a first search is confirmed (minting - // request id N), then — before its reply arrives — a second search is - // confirmed (minting N+1) which is the one still outstanding. The first - // search's (now stale) reply must be silently dropped rather than - // clobbering the top offset/match the second search is still waiting - // on to fill in. - let mut state = opened_viewer_state(1000); - if let UiPhase::Viewer(v) = &mut state.phase { - v.top_offset = 5; - } - let (state, stale_request) = viewer_with_pattern_confirmed(state, "first"); - let (state, current_request) = viewer_with_pattern_confirmed(state, "second"); - assert_ne!(stale_request, current_request); - - // The stale reply from the first (superseded) search arrives late. - let (state, _) = - update(state, Command::ViewerSearchResult { offset: Some(900), match_range: Some((900, 905)), request: stale_request }); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.top_offset, 5, "a stale reply must not move the top offset"); - assert_eq!(v.last_match, None, "a stale reply must not set a phantom match highlight"); - - // The still-current search's reply, once it arrives, is applied. - let (state, _) = update( - state, - Command::ViewerSearchResult { offset: Some(700), match_range: Some((700, 706)), request: current_request }, - ); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.top_offset, 700); - assert_eq!(v.last_match, Some((700, 706))); -} - -#[test] -fn a_stale_search_reply_for_a_closed_and_reopened_viewer_session_is_dropped() { - // A search is confirmed against one open file, then the viewer is - // closed and a different file opened (or even the same file reopened — - // it is a fresh session either way, per `ViewerState::new`). The first - // session's stale reply must not be applied to the new session even - // though `state.phase` is `UiPhase::Viewer(_)` again by the time it - // arrives. - let state = opened_viewer_state(1000); - let (state, stale_request) = viewer_with_pattern_confirmed(state, "needle"); - - let (state, _) = update(state, Command::ViewerClose); - let (state, _) = update(state, Command::ViewerOpened { path: PathBuf::from("/left/other.txt"), file_len: 200 }); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.search_request, None, "a freshly opened session has no outstanding search"); - - let (state, _) = - update(state, Command::ViewerSearchResult { offset: Some(50), match_range: Some((50, 56)), request: stale_request }); - let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; - assert_eq!(v.path, PathBuf::from("/left/other.txt")); - assert_eq!(v.top_offset, 0, "the stale reply from the closed session must not move the new session's top offset"); - assert_eq!(v.last_match, None, "the stale reply must not set a phantom match highlight on the new session"); -} - -#[test] -fn f4_from_a_panel_with_no_editor_configured_shows_the_message_and_spawns_nothing() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 10)]; - let (state, effects) = update(state, Command::RequestExternalEditor); - assert!(effects.is_empty()); - assert_eq!(state.left.last_error.as_deref(), Some(crate::external_editor::NO_EDITOR_CONFIGURED_MESSAGE)); -} - -#[test] -fn f4_from_a_panel_with_a_blank_editor_command_is_also_treated_as_unset() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some(" ".to_string()); - state.left.entries = vec![file_entry("report.txt", 10)]; - let (_, effects) = update(state, Command::RequestExternalEditor); - assert!(effects.is_empty()); -} - -#[test] -fn f4_on_a_directory_entry_does_not_launch_the_editor() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.left.entries = vec![dir_entry("sub")]; - let (state, effects) = update(state, Command::RequestExternalEditor); - assert!(effects.is_empty()); - assert_eq!(state.left.last_error, None, "a directory target is silently ignored, not an error dialog"); -} - -#[test] -fn f4_on_parent_dir_does_not_launch_the_editor() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.left.entries = vec![Entry::parent_dir()]; - let (_, effects) = update(state, Command::RequestExternalEditor); - assert!(effects.is_empty()); -} - -#[test] -fn f4_launches_the_configured_editor_on_the_file_under_the_cursor() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.active = PanelSide::Right; - state.right.cwd = PathBuf::from(r"C:\work"); - state.right.entries = vec![file_entry("report.txt", 10)]; - let (_, effects) = update(state, Command::RequestExternalEditor); - match effects.as_slice() { - [Effect::RunExternalEditor(inv, PanelSide::Right)] => { - assert_eq!(inv.program, "notepad"); - assert_eq!(inv.file_arg, OsString::from("report.txt")); - assert_eq!(inv.cwd, PathBuf::from(r"C:\work")); - } - other => panic!("expected a single RunExternalEditor effect, got {other:?}"), - } -} - -#[test] -fn f4_passes_the_original_os_string_file_name_through_without_lossy_conversion() { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt; - let raw = std::ffi::OsStr::from_bytes(&[0x66, 0x6f, 0x80, 0x6f]).to_os_string(); - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.left.entries = vec![Entry { name: raw.clone(), kind: EntryKind::File, size: 0, modified: None }]; - let (_, effects) = update(state, Command::RequestExternalEditor); - match effects.as_slice() { - [Effect::RunExternalEditor(inv, _)] => assert_eq!(inv.file_arg, raw), - other => panic!("expected a single RunExternalEditor effect, got {other:?}"), - } - } -} - -#[test] -fn external_editor_spawn_failure_surfaces_an_inline_error_without_crashing() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::ExternalEditorSpawnFailed { message: "program not found".to_string() }); - assert!(effects.is_empty()); - assert_eq!(state.left.last_error.as_deref(), Some("program not found")); - assert_eq!(state.phase, UiPhase::Panels, "still running, not crashed"); -} - -#[test] -fn a_subsequently_successful_f4_launch_clears_a_stale_last_error_from_an_earlier_failed_attempt() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.left.entries = vec![file_entry("report.txt", 10)]; - state.left.last_error = Some("program not found".to_string()); - - let (state, effects) = update(state, Command::RequestExternalEditor); - assert_eq!( - state.left.last_error, None, - "successfully dispatching the F4 editor spawn must clear a stale error from an earlier failed attempt" - ); - assert!(matches!(effects.as_slice(), [Effect::RunExternalEditor(_, PanelSide::Left)])); -} - -// --------------------------------------------------------------------- -// M5: git_info — generation-key staleness guard (design D3; git-info -// "Query re-issued on navigation", "Silent absence on timeout and -// stale-result discarding") -// --------------------------------------------------------------------- - -#[test] -fn initial_state_issues_a_git_info_query_for_each_panel() { - let (state, effects) = State::initial(Theme::classic(), (80, 24), 0, PathBuf::from("/l"), PathBuf::from("/r"), false); - let left_request = state.left.git_request.expect("left panel mints a git-info request on startup"); - let right_request = state.right.git_request.expect("right panel mints a git-info request on startup"); - assert_ne!(left_request, right_request); - assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/l"), request: left_request })); - assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Right, path: PathBuf::from("/r"), request: right_request })); -} - -#[test] -fn navigating_reissues_the_git_info_query_and_clears_the_previous_directorys_info() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - state.left.git_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); - assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "the previous directory's git info is cleared, not carried over"); - let request = state.left.git_request.expect("navigating mints a fresh git-info request"); - assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/left/sub"), request })); -} - -#[test] -fn a_reread_mints_a_fresh_git_info_request_even_for_the_same_path() { - let state = test_state(UiPhase::Panels); - let first_request = state.left.git_request; - let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); - let second_request = state.left.git_request.expect("re-reading mints a request id"); - assert_ne!(Some(second_request), first_request); - assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/left"), request: second_request })); -} - -#[test] -fn a_resolved_git_info_result_is_applied_in_place() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let request = state.left.git_request.unwrap(); - let info = crate::git_info::GitInfo { - branch: Some("main".to_string()), - statuses: std::collections::HashMap::from([(OsString::from("a.txt"), crate::git_info::FileStatus::Modified)]), - }; - let (state, effects) = - update(state, Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, info: info.clone() }); - assert!(effects.is_empty()); - assert_eq!(state.left.git_info, info); -} - -#[test] -fn a_git_info_result_for_a_directory_the_panel_left_is_discarded() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let request = state.left.git_request.unwrap(); - let info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; - let (state, _) = update(state, Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/elsewhere"), request, info }); - assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "a result for another directory is dropped"); -} - -#[test] -fn a_stale_git_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies() { - // Mirrors `a_stale_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies`: - // two RereadPanel commands for the same path mint two different - // request ids, so `path` equality alone can't tell the stale reply - // apart from the current one — this is also how a timed-out query's - // late reply is safely dropped (git-info "Silent absence on timeout - // and stale-result discarding"). - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let first_request = state.left.git_request.unwrap(); - - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let second_request = state.left.git_request.unwrap(); - assert_ne!(first_request, second_request, "re-reading mints a fresh request id even for the same path"); - - let stale_info = crate::git_info::GitInfo { branch: Some("stale-branch".to_string()), ..Default::default() }; - let (state, _) = update( - state, - Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request: first_request, info: stale_info }, - ); - assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "the stale (first-request) answer is dropped"); - - let fresh_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; - let (state, _) = update( - state, - Command::GitInfoResolved { - panel: PanelSide::Left, - path: PathBuf::from("/left"), - request: second_request, - info: fresh_info.clone(), - }, - ); - assert_eq!(state.left.git_info, fresh_info, "the current request's answer applies"); -} - -#[test] -fn a_timed_out_query_answered_late_with_no_info_is_silently_dropped_once_superseded() { - // A worker-thread timeout is, from the reducer's point of view, just - // another reply — it degrades to `GitInfo::none()` — so it goes - // through the exact same generation-key guard as any other late - // reply: once a fresher request is outstanding, the timed-out query's - // eventual answer (of any content) must not clobber it. - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let timed_out_request = state.left.git_request.unwrap(); - - let (mut state, _) = update(state, Command::RereadPanel(PanelSide::Left)); - let current_request = state.left.git_request.unwrap(); - state.left.git_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; - - let (state, _) = update( - state, - Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request: timed_out_request, info: GitInfo::none() }, - ); - assert_eq!( - state.left.git_info.branch.as_deref(), - Some("main"), - "the timed-out query's late reply must not overwrite the current request's already-applied result" - ); - let _ = current_request; -} - -// --------------------------------------------------------------------- -// M5: F4 built-in editor -// --------------------------------------------------------------------- - -use crate::editor::{EditorMove, EditorState, EntryMode, ReplacePrompt}; - -fn editor_with(lines: &[&str]) -> EditorState { - let text = lines.join("\n"); - let mut bytes = text.into_bytes(); - if !lines.is_empty() { - bytes.push(b'\n'); - } - EditorState::from_bytes(PathBuf::from("/left/edit.txt"), &bytes) -} - -/// A real on-disk file to back tests that exercise an actual -/// `EditorState::save` round trip (the `Effect::SaveEditor` reply tests), -/// rather than a bare in-memory `from_bytes` buffer whose path doesn't -/// exist. -fn editor_with_temp_file(name: &str, lines: &[&str]) -> EditorState { - let dir = std::env::temp_dir().join(format!("filecommand-update-test-editor-{}-{}", std::process::id(), name)); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("file.txt"); - let mut text = lines.join("\n"); - if !lines.is_empty() { - text.push('\n'); - } - std::fs::write(&path, &text).unwrap(); - match EditorState::open(&path).unwrap() { - crate::editor::LoadResult::Loaded(e) => e, - crate::editor::LoadResult::TooLarge { .. } => panic!("test fixture unexpectedly exceeds the editor size cap"), - } -} - -fn editor_state(editor: EditorState) -> State { - State { phase: UiPhase::Editor(editor), ..test_state(UiPhase::Panels) } -} - -#[test] -fn f4_with_no_editor_configured_opens_the_built_in_editor() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 10)]; - let (state, effects) = update(state, Command::RequestEditor); - assert_eq!(effects, vec![Effect::OpenEditor { path: PathBuf::from("/left/report.txt") }]); - assert_eq!(state.phase, UiPhase::Panels, "phase flips only once EditorOpened comes back — opening is I/O"); -} - -#[test] -fn f4_with_an_external_editor_configured_takes_precedence_over_the_built_in_editor() { - let mut state = test_state(UiPhase::Panels); - state.editor = Some("notepad".to_string()); - state.left.entries = vec![file_entry("report.txt", 10)]; - let (_, effects) = update(state, Command::RequestEditor); - assert!( - matches!(effects.as_slice(), [Effect::RunExternalEditor(_, PanelSide::Left)]), - "external editor takes precedence: no OpenEditor effect, got {effects:?}" - ); -} - -#[test] -fn f4_on_a_directory_or_empty_panel_does_not_open_the_editor() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (_, effects) = update(state, Command::RequestEditor); - assert!(effects.is_empty()); - - let state = test_state(UiPhase::Panels); - let (_, effects) = update(state, Command::RequestEditor); - assert!(effects.is_empty()); -} - -#[test] -fn editor_opened_enters_the_editor_phase_with_the_loaded_buffer() { - let state = test_state(UiPhase::Panels); - let editor = editor_with(&["line one", "line two"]); - let (state, effects) = update(state, Command::EditorOpened(Box::new(editor))); - assert!(effects.is_empty()); - match state.phase { - UiPhase::Editor(e) => assert_eq!(e.lines, vec!["line one".to_string(), "line two".to_string(), String::new()]), - other => panic!("expected UiPhase::Editor, got {other:?}"), - } -} - -#[test] -fn editor_too_large_opens_the_viewer_instead_with_an_inline_notice() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::EditorTooLarge { path: PathBuf::from("/left/huge.log"), size: 20_000_000 }); - assert_eq!(effects, vec![Effect::OpenViewer { path: PathBuf::from("/left/huge.log") }]); - assert_eq!(state.phase, UiPhase::Panels); - let message = state.left.last_error.expect("a notice explaining the redirect"); - assert!(message.contains("huge.log"), "{message}"); - assert!(message.contains("10 MB"), "{message}"); -} - -#[test] -fn editor_open_failed_surfaces_an_inline_error() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::EditorOpenFailed { message: "access denied".to_string() }); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); - assert_eq!(state.left.last_error.as_deref(), Some("access denied")); -} - -#[test] -fn typing_in_the_editor_inserts_and_marks_the_buffer_modified() { - let state = editor_state(editor_with(&["abc"])); - let (state, _) = update(state, Command::EditorChar('X')); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.lines[0], "Xabc"); - assert!(e.is_modified()); -} - -#[test] -fn editor_move_commands_move_the_caret() { - let state = editor_state(editor_with(&["hello"])); - let (state, _) = update(state, Command::EditorMove(EditorMove::Right)); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.caret.col, 1); - let (state, _) = update(state, Command::EditorMove(EditorMove::End)); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.caret.col, 5); -} - -#[test] -fn insert_toggles_overwrite_mode() { - let state = editor_state(editor_with(&["abc"])); - let (state, _) = update(state, Command::EditorToggleMode); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.mode, EntryMode::Overwrite); -} - -#[test] -fn mark_cut_copy_paste_flow_through_the_reducer() { - let state = editor_state(editor_with(&["a", "b", "c"])); - let (state, _) = update(state, Command::EditorMark); - let (state, _) = update(state, Command::EditorMove(EditorMove::Down)); - let (state, _) = update(state, Command::EditorCut); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.lines, vec!["c".to_string(), String::new()]); - assert_eq!(e.clipboard, vec!["a".to_string(), "b".to_string()]); - - let (state, _) = update(state, Command::EditorPaste); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.lines, vec!["a".to_string(), "b".to_string(), "c".to_string(), String::new()]); -} - -#[test] -fn undo_flows_through_the_reducer() { - let state = editor_state(editor_with(&["abc"])); - let (state, _) = update(state, Command::EditorMove(EditorMove::End)); - let (state, _) = update(state, Command::EditorChar('d')); - let (state, _) = update(state, Command::EditorUndo); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.lines[0], "abc"); -} - -#[test] -fn search_prompt_confirm_moves_the_caret_to_the_next_match() { - let state = editor_state(editor_with(&["needle here", "another needle"])); - let (state, _) = update(state, Command::EditorSearchStart); - let (state, _) = update(state, Command::EditorSearchChar('n')); - let (state, _) = update(state, Command::EditorSearchChar('e')); - let (state, _) = update(state, Command::EditorSearchConfirm); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert!(e.search_prompt.is_none(), "the prompt closes on confirm"); - assert_eq!(e.caret, crate::editor::Caret { line: 1, col: 8 }); -} - -#[test] -fn search_prompt_backspace_and_cancel_edit_and_close_the_prompt() { - let state = editor_state(editor_with(&["abc"])); - let (state, _) = update(state, Command::EditorSearchStart); - let (state, _) = update(state, Command::EditorSearchChar('x')); - let (state, _) = update(state, Command::EditorSearchBackspace); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.search_prompt.as_deref(), Some("")); - let (state, _) = update(state, Command::EditorSearchCancel); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.search_prompt, None); -} - -#[test] -fn replace_prompt_advances_from_pattern_to_replacement_then_replaces() { - let state = editor_state(editor_with(&["hello world"])); - let (state, _) = update(state, Command::EditorReplaceStart); - let (state, _) = update(state, Command::EditorReplaceChar('w')); - let (state, _) = update(state, Command::EditorReplaceChar('o')); - let (state, _) = update(state, Command::EditorReplaceChar('r')); - let (state, _) = update(state, Command::EditorReplaceChar('l')); - let (state, _) = update(state, Command::EditorReplaceChar('d')); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.replace_prompt, Some(ReplacePrompt::Pattern("world".to_string()))); - - let (state, _) = update(state, Command::EditorReplaceConfirm); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.replace_prompt, Some(ReplacePrompt::Replacement { pattern: "world".to_string(), replacement: String::new() })); - - let (state, _) = update(state, Command::EditorReplaceChar('X')); - let (state, _) = update(state, Command::EditorReplaceConfirm); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.replace_prompt, None, "the prompt closes once the replacement runs"); - assert_eq!(e.lines[0], "hello X"); - assert!(e.is_modified()); -} - -#[test] -fn replace_confirm_with_an_empty_pattern_cancels_rather_than_advancing() { - let state = editor_state(editor_with(&["hello"])); - let (state, _) = update(state, Command::EditorReplaceStart); - let (state, _) = update(state, Command::EditorReplaceConfirm); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.replace_prompt, None); -} - -#[test] -fn editor_save_dispatches_the_save_effect_and_the_reply_updates_the_saved_snapshot() { - let mut editor = editor_with_temp_file("save-effect", &["abc"]); - editor.type_char('X'); - assert!(editor.is_modified()); - let state = editor_state(editor.clone()); - let (state, effects) = update(state, Command::EditorSave); - match effects.as_slice() { - [Effect::SaveEditor { editor: e, then_quit: false }] => assert_eq!(e.lines, editor.lines), - other => panic!("expected a single SaveEditor effect, got {other:?}"), - } - // The buffer is unchanged until the reply arrives — save is I/O. - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert!(e.is_modified()); - - // Mirrors what the TUI's effect executor actually does: clone the - // editor into the effect, call the real (I/O-performing) `save()` on - // it, then reply with the post-save state. - let mut saved = editor; - saved.save().unwrap(); - assert!(!saved.is_modified(), "sanity: a real save clears the modified flag on the saved copy"); - let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: false }); - assert!(effects.is_empty()); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert!(!e.is_modified(), "the post-save snapshot from the reply clears the modified flag"); -} - -#[test] -fn editor_save_failed_surfaces_an_inline_message_without_losing_the_buffer() { - let mut editor = editor_with(&["abc"]); - editor.type_char('X'); - let state = editor_state(editor); - let (state, effects) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); - assert!(effects.is_empty()); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert_eq!(e.save_error.as_deref(), Some("disk full")); - assert!(e.is_modified(), "a failed save must not silently discard the unsaved edit"); -} - -#[test] -fn f10_on_an_unmodified_buffer_exits_directly() { - let state = editor_state(editor_with(&["abc"])); - let (state, effects) = update(state, Command::EditorRequestQuit); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn f10_on_a_modified_buffer_raises_the_save_on_exit_confirm() { - let mut editor = editor_with(&["abc"]); - editor.type_char('X'); - let state = editor_state(editor); - let (state, effects) = update(state, Command::EditorRequestQuit); - assert!(effects.is_empty()); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert!(e.quit_confirm); -} - -#[test] -fn cancelling_the_save_on_exit_confirm_returns_to_editing() { - let mut editor = editor_with(&["abc"]); - editor.type_char('X'); - let state = editor_state(editor); - let (state, _) = update(state, Command::EditorRequestQuit); - let (state, effects) = update(state, Command::EditorCancelQuit); - assert!(effects.is_empty()); - let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; - assert!(!e.quit_confirm); -} - -#[test] -fn discarding_at_the_save_on_exit_confirm_exits_without_saving() { - let mut editor = editor_with(&["abc"]); - editor.type_char('X'); - let state = editor_state(editor); - let (state, _) = update(state, Command::EditorRequestQuit); - let (state, effects) = update(state, Command::EditorConfirmQuitDiscard); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels); -} - -#[test] -fn confirming_save_at_the_save_on_exit_confirm_dispatches_a_save_that_quits() { - let mut editor = editor_with_temp_file("quit-save", &["abc"]); - editor.type_char('X'); - let state = editor_state(editor.clone()); - let (state, _) = update(state, Command::EditorRequestQuit); - let (state, effects) = update(state, Command::EditorConfirmQuitSave); - match effects.as_slice() { - [Effect::SaveEditor { then_quit: true, .. }] => {} - other => panic!("expected a SaveEditor effect with then_quit: true, got {other:?}"), - } - // Still in the editor until the save actually lands. - assert!(matches!(state.phase, UiPhase::Editor(_))); - - let mut saved = editor; - saved.save().unwrap(); - let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: true }); - assert!(effects.is_empty()); - assert_eq!(state.phase, UiPhase::Panels, "a successful save-then-quit closes the editor"); -} - -#[test] -fn a_failed_save_during_quit_aborts_the_quit_and_keeps_editing() { - let mut editor = editor_with(&["abc"]); - editor.type_char('X'); - let state = editor_state(editor); - let (state, _) = update(state, Command::EditorRequestQuit); - let (state, effects) = update(state, Command::EditorConfirmQuitSave); - assert!(matches!(effects.as_slice(), [Effect::SaveEditor { then_quit: true, .. }])); - - let (state, _) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); - let UiPhase::Editor(e) = &state.phase else { panic!("expected the failed save to abort the quit and stay in the editor") }; - assert_eq!(e.save_error.as_deref(), Some("disk full")); - assert!(!e.quit_confirm, "the confirm dialog itself closes so the user can see and act on the error"); -} - -// --------------------------------------------------------------------- -// M5: Tree display mode (design D7; additional-panel-modes) -// --------------------------------------------------------------------- - -fn dir_child(name: &str) -> Entry { - dir_entry(name) -} - -#[test] -fn entering_tree_mode_roots_at_the_drive_and_requests_the_root_children() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\Users\demo"); - state.left.display_mode = DisplayMode::Info; - let (state, effects) = update(state, Command::EnterTreeMode(PanelSide::Left)); - assert_eq!(state.left.display_mode, DisplayMode::Tree); - let tree = state.left.tree.as_ref().expect("Tree mode populates tree state"); - assert_eq!(tree.nodes.len(), 1); - assert_eq!(tree.nodes[0].path, PathBuf::from(r"C:\")); - assert_eq!(tree.prior_mode, DisplayMode::Info, "the pre-Tree display mode is recorded for Enter to restore"); - assert!(effects.contains(&Effect::ExpandTreeNode { panel: PanelSide::Left, path: PathBuf::from(r"C:\") })); -} - -#[test] -fn tree_node_expanded_splices_children_into_the_matching_panels_tree() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, - ); - let tree = state.left.tree.as_ref().unwrap(); - assert_eq!(tree.nodes.len(), 3); - assert_eq!(tree.nodes[1].path, PathBuf::from(r"C:\alpha")); -} - -#[test] -fn a_tree_node_expanded_reply_for_the_other_panel_is_dropped() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - // The right panel never entered Tree mode, so it has no tree at all — - // a reply naming it must not panic or fabricate one. - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Right, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }, - ); - assert!(state.right.tree.is_none()); - assert_eq!(state.left.tree.as_ref().unwrap().nodes.len(), 1, "the left panel's tree is untouched by a reply addressed elsewhere"); -} - -#[test] -fn moving_the_tree_cursor_relists_the_opposite_panel_at_the_highlighted_directory() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, - ); - let (state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); - assert_eq!(state.left.tree.as_ref().unwrap().cursor, 1); - assert!( - without_git_info_effects(effects).contains(&Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from(r"C:\alpha") }), - "the opposite (right) panel must be re-listed at the newly highlighted directory" - ); - // Landing on "alpha" (not yet expanded) also requests its children. - assert!(!state.left.tree.as_ref().unwrap().nodes[1].expanded); -} - -#[test] -fn moving_onto_an_unexpanded_node_requests_its_children_but_not_an_already_expanded_one() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }, - ); - let (state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); - assert!(effects.contains(&Effect::ExpandTreeNode { panel: PanelSide::Left, path: PathBuf::from(r"C:\alpha") })); - - // Expand it, then move away and back — no second expand request for an - // already-expanded node. - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\alpha"), children: vec![] }, - ); - let (state, _) = update(state, Command::MoveCursor(CursorMove::Up(1))); - let (_state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); - assert!( - !effects.iter().any(|e| matches!(e, Effect::ExpandTreeNode { path, .. } if path == std::path::Path::new(r"C:\alpha"))), - "an already-expanded node must not be re-requested: {effects:?}" - ); -} - -#[test] -fn enter_on_a_tree_node_restores_the_prior_mode_and_navigates_this_panel_there() { - let mut state = test_state(UiPhase::Panels); - state.left.display_mode = DisplayMode::Brief; - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, - ); - let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); // highlight "alpha" - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.left.display_mode, DisplayMode::Brief, "Enter restores the pre-Tree display mode"); - assert!(state.left.tree.is_none(), "leaving Tree mode clears the tree state"); - assert!( - without_git_info_effects(effects).contains(&Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from(r"C:\alpha") }), - "Enter navigates *this* panel to the highlighted directory, not the opposite one" - ); -} - -// --------------------------------------------------------------------- -// M5: Brief/Full/Quick view display-mode switch (design D7) -// --------------------------------------------------------------------- - -#[test] -fn set_display_mode_switches_the_named_panel_and_clears_tree_state() { - let mut state = test_state(UiPhase::Panels); - state.right.tree = Some(crate::panel::TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full)); - state.right.display_mode = DisplayMode::Tree; - let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Right, mode: DisplayMode::Brief }); - assert_eq!(state.right.display_mode, DisplayMode::Brief); - assert!(state.right.tree.is_none()); - assert_eq!(state.left.display_mode, DisplayMode::Full, "the other panel is untouched"); -} - -// --------------------------------------------------------------------- -// M5 review fix: a quick filter must not linger invisibly across a -// display-mode switch, since Brief/Tree/Info's renderers don't surface it -// the same way Full mode does (quick-filter "Substring narrowing as the -// pattern is typed"). -// --------------------------------------------------------------------- - -#[test] -fn set_display_mode_clears_an_active_quick_filter_on_the_named_panel() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; - state.left.quick_filter = Some("rep".to_string()); - let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Left, mode: DisplayMode::Brief }); - assert_eq!(state.left.display_mode, DisplayMode::Brief); - assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Brief mode"); -} - -#[test] -fn set_display_mode_does_not_clear_the_opposite_panels_quick_filter() { - let mut state = test_state(UiPhase::Panels); - state.right.entries = vec![file_entry("report.txt", 1)]; - state.right.quick_filter = Some("rep".to_string()); - let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Left, mode: DisplayMode::Brief }); - assert_eq!(state.right.quick_filter.as_deref(), Some("rep"), "the opposite panel's filter must be untouched"); -} - -#[test] -fn entering_tree_mode_clears_an_active_quick_filter() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 1)]; - state.left.quick_filter = Some("rep".to_string()); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - assert_eq!(state.left.display_mode, DisplayMode::Tree); - assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Tree mode"); -} - -#[test] -fn toggling_into_info_mode_clears_an_active_quick_filter() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("report.txt", 1)]; - state.left.quick_filter = Some("rep".to_string()); - let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); - assert_eq!(state.left.display_mode, DisplayMode::Info); - assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Info mode"); -} - -// --------------------------------------------------------------------- -// M5: Ctrl+J fuzzy jump -// --------------------------------------------------------------------- - -fn history_entry(path: &str, count: u32, ms: u64) -> crate::quicksearch::FrecencyEntry { - crate::quicksearch::FrecencyEntry { path: PathBuf::from(path), visit_count: count, last_visited_ms: ms } -} - -#[test] -fn fuzzy_jump_open_and_esc_close_without_navigating() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::FuzzyJumpOpen); - assert!(state.fuzzy_jump.is_some()); - let (state, effects) = update(state, Command::FuzzyJumpCancel); - assert!(state.fuzzy_jump.is_none()); - assert!(effects.is_empty()); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "the panel is unchanged"); -} - -#[test] -fn fuzzy_jump_typing_and_backspace_edit_the_pattern() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::FuzzyJumpOpen); - let (state, _) = update(state, Command::FuzzyJumpChar('d')); - let (state, _) = update(state, Command::FuzzyJumpChar('o')); - assert_eq!(state.fuzzy_jump.as_ref().unwrap().pattern, "do"); - let (state, _) = update(state, Command::FuzzyJumpBackspace); - assert_eq!(state.fuzzy_jump.as_ref().unwrap().pattern, "d"); -} - -#[test] -fn fuzzy_jump_confirm_navigates_the_active_panel_to_the_highlighted_directory() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - state.dir_history = vec![history_entry(r"C:\Low", 1, 0), history_entry(r"C:\High", 10, 0)]; - let (state, _) = update(state, Command::FuzzyJumpOpen); - // No pattern typed: the full frecency-ranked list shows, most frecent - // first, so the default (cursor 0) highlight is `C:\High`. - let (state, effects) = update(state, Command::FuzzyJumpConfirm); - assert!(state.fuzzy_jump.is_none(), "the dialog closes"); - assert_eq!(state.right.cwd, PathBuf::from(r"C:\High")); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "the opposite panel is unaffected"); - assert!(without_git_info_effects(effects).iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Right, .. }))); -} - -#[test] -fn fuzzy_jump_move_clamps_within_the_currently_filtered_list() { - let mut state = test_state(UiPhase::Panels); - state.dir_history = vec![history_entry(r"C:\A", 1, 0), history_entry(r"C:\B", 1, 0)]; - let (state, _) = update(state, Command::FuzzyJumpOpen); - let (state, _) = update(state, Command::FuzzyJumpMove(-5)); - assert_eq!(state.fuzzy_jump.as_ref().unwrap().cursor, 0); - let (state, _) = update(state, Command::FuzzyJumpMove(5)); - assert_eq!(state.fuzzy_jump.as_ref().unwrap().cursor, 1); -} - -// --------------------------------------------------------------------- -// M5: Alt+F7 find file -// --------------------------------------------------------------------- - -fn find_match(rel: &str, name: &str) -> FindMatch { - FindMatch { relative_path: PathBuf::from(rel), entry: dir_entry_named(name) } -} - -fn dir_entry_named(name: &str) -> Entry { - Entry { name: OsString::from(name), kind: EntryKind::File, size: 0, modified: None } -} - -#[test] -fn find_file_open_roots_at_the_active_panels_directory() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - state.right.cwd = PathBuf::from(r"C:\proj"); - let (state, _) = update(state, Command::FindFileOpen); - assert_eq!(state.find_file.as_ref().unwrap().root, PathBuf::from(r"C:\proj")); -} - -#[test] -fn find_file_submit_mints_a_request_and_dispatches_the_walk_effect() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\proj"); - let (state, _) = update(state, Command::FindFileOpen); - let (state, _) = update(state, Command::FindFileChar('r')); - let (state, effects) = update(state, Command::FindFileSubmit); - match effects.as_slice() { - [Effect::FindInSubtree { root, pattern, request }] => { - assert_eq!(root, &PathBuf::from(r"C:\proj")); - assert_eq!(pattern, "r"); - assert_eq!(state.find_file.as_ref().unwrap().request, Some(*request)); - } - other => panic!("expected exactly one FindInSubtree effect, got {other:?}"), - } -} - -#[test] -fn find_file_matches_from_a_superseded_request_are_dropped() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::FindFileOpen); - let (state, _) = update(state, Command::FindFileSubmit); // request 1 - let (state, _) = update(state, Command::FindFileMatch { request: 999, m: find_match("a.txt", "a.txt") }); - assert!(state.find_file.as_ref().unwrap().results.is_empty(), "a stale-request match must be dropped"); -} - -#[test] -fn find_file_confirm_navigates_in_place_and_seeds_the_cursor_target() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\proj"); - let (state, _) = update(state, Command::FindFileOpen); - let (state, _) = update(state, Command::FindFileSubmit); - let request = state.find_file.as_ref().unwrap().request.unwrap(); - let (state, _) = update(state, Command::FindFileMatch { request, m: find_match(r"sub\report.txt", "report.txt") }); - let (state, effects) = update(state, Command::FindFileConfirm); - assert!(state.find_file.is_none(), "the dialog closes"); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\proj\sub"), "navigates to the match's containing directory"); - assert_eq!(state.left.pending_cursor_target, Some(OsString::from("report.txt"))); - assert!(without_git_info_effects(effects).iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. }))); -} - -#[test] -fn find_file_confirm_with_a_root_level_match_navigates_to_the_root() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\proj"); - let (state, _) = update(state, Command::FindFileOpen); - let (state, _) = update(state, Command::FindFileSubmit); - let request = state.find_file.as_ref().unwrap().request.unwrap(); - let (state, _) = update(state, Command::FindFileMatch { request, m: find_match("readme.txt", "readme.txt") }); - let (state, _) = update(state, Command::FindFileConfirm); - assert_eq!(state.left.cwd, PathBuf::from(r"C:\proj")); -} - -#[test] -fn find_file_cancel_abandons_the_search_without_navigating() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::FindFileOpen); - let (state, _) = update(state, Command::FindFileSubmit); - let (state, effects) = update(state, Command::FindFileCancel); - assert!(state.find_file.is_none()); - assert!(effects.is_empty()); - assert_eq!(state.left.cwd, PathBuf::from("/left")); -} - -#[test] -fn listing_complete_settles_the_cursor_on_a_pending_find_file_target() { - let mut state = test_state(UiPhase::Panels); - state.left.pending_cursor_target = Some(OsString::from("b.txt")); - let (state, _) = update( - state, - Command::ListingChunk { - panel: PanelSide::Left, - entries: vec![dir_entry_named("a.txt"), dir_entry_named("b.txt"), dir_entry_named("c.txt")], - }, - ); - let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 3 }); - assert_eq!(state.left.cursor, 1); - assert!(state.left.pending_cursor_target.is_none(), "consumed once applied"); -} - -// --------------------------------------------------------------------- -// M5: F2 user menu -// --------------------------------------------------------------------- - -#[test] -fn user_menu_confirm_dispatches_the_highlighted_commands_shell_passthrough() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - state.right.cwd = PathBuf::from(r"C:\Projects\app"); - state.user_menu_entries = vec![ - crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }, - crate::config::UserMenuEntry { label: "Build".to_string(), command: "cargo build".to_string() }, - ]; - let (state, _) = update(state, Command::UserMenuOpen); - let (state, _) = update(state, Command::UserMenuMove(1)); - let (state, effects) = update(state, Command::UserMenuConfirm); - assert!(state.user_menu.is_none(), "the menu closes"); - match effects.as_slice() { - [Effect::RunShellCommand(inv, side)] => { - assert_eq!(inv.cwd, PathBuf::from(r"C:\Projects\app")); - assert!(inv.args.iter().any(|a| a.contains("cargo build")), "{:?}", inv.args); - assert_eq!(*side, PanelSide::Right); - } - other => panic!("expected exactly one RunShellCommand effect, got {other:?}"), - } -} - -#[test] -fn user_menu_esc_closes_without_running_anything() { - let mut state = test_state(UiPhase::Panels); - state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; - let (state, _) = update(state, Command::UserMenuOpen); - let (state, effects) = update(state, Command::UserMenuCancel); - assert!(state.user_menu.is_none()); - assert!(effects.is_empty()); -} - -#[test] -fn user_menu_move_clamps_and_confirm_on_an_empty_menu_is_a_harmless_close() { - let state = test_state(UiPhase::Panels); // user_menu_entries is empty - let (state, _) = update(state, Command::UserMenuOpen); - let (state, _) = update(state, Command::UserMenuMove(5)); - assert_eq!(state.user_menu.as_ref().unwrap().cursor, 0); - let (state, effects) = update(state, Command::UserMenuConfirm); - assert!(state.user_menu.is_none()); - assert!(effects.is_empty(), "nothing to run on an empty menu"); -} - -// --------------------------------------------------------------------- -// user-menu-themes-entry: built-in Themes slot -// --------------------------------------------------------------------- - -#[test] -fn user_menu_down_past_the_last_user_entry_lands_on_themes_and_clamps_there() { - let mut state = test_state(UiPhase::Panels); - state.user_menu_entries = vec![ - crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }, - crate::config::UserMenuEntry { label: "B".to_string(), command: "echo b".to_string() }, - ]; - let (state, _) = update(state, Command::UserMenuOpen); - // Two user entries (indices 0, 1) plus the built-in slot at index 2. - let (state, _) = update(state, Command::UserMenuMove(2)); - assert_eq!(state.user_menu.as_ref().unwrap().cursor, 2, "Down from the last user entry lands on the built-in Themes slot"); - let (state, _) = update(state, Command::UserMenuMove(1)); - assert_eq!(state.user_menu.as_ref().unwrap().cursor, 2, "the built-in slot is the end of the domain: Down holds, it does not wrap"); -} - -#[test] -fn user_menu_confirm_on_themes_opens_the_picker_pre_highlighted_with_no_shell_effect() { - let mut state = test_state(UiPhase::Panels); - state.theme = Theme::terminal_green(); - state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; - let (state, _) = update(state, Command::UserMenuOpen); - let (state, _) = update(state, Command::UserMenuMove(1)); // off the one user entry, onto the built-in slot - assert_eq!(state.user_menu.as_ref().unwrap().cursor, 1); - let (state, effects) = update(state, Command::UserMenuConfirm); - assert!(state.user_menu.is_none(), "the F2 menu closes"); - let picker = state.theme_picker.expect("Confirm on the built-in slot opens the theme picker"); - let expected = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "terminal-green").unwrap(); - assert_eq!(picker.highlight, expected, "the active theme's row is pre-highlighted, same as the Options -> Themes route"); - assert!(effects.is_empty(), "no shell effect is emitted for the built-in slot"); -} - -#[test] -fn user_menu_confirm_on_a_user_entry_still_runs_it_via_the_shell_unaffected_by_the_built_in_slot() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Right; - state.right.cwd = PathBuf::from(r"C:\Projects\app"); - state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "Build".to_string(), command: "cargo build".to_string() }]; - let (state, _) = update(state, Command::UserMenuOpen); - let (state, effects) = update(state, Command::UserMenuConfirm); - assert!(state.user_menu.is_none()); - assert!(state.theme_picker.is_none(), "a user entry never opens the theme picker"); - match effects.as_slice() { - [Effect::RunShellCommand(inv, side)] => { - assert_eq!(inv.cwd, PathBuf::from(r"C:\Projects\app")); - assert!(inv.args.iter().any(|a| a.contains("cargo build")), "{:?}", inv.args); - assert_eq!(*side, PanelSide::Right); - } - other => panic!("expected exactly one RunShellCommand effect, got {other:?}"), - } -} - -#[test] -fn theme_picker_esc_after_f2_origin_returns_to_the_panels_without_reopening_the_user_menu() { - let mut state = test_state(UiPhase::Panels); - state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; - let (state, _) = update(state, Command::UserMenuOpen); - let (state, _) = update(state, Command::UserMenuMove(1)); // onto the built-in Themes slot - let (state, _) = update(state, Command::UserMenuConfirm); - assert!(state.user_menu.is_none() && state.theme_picker.is_some()); - let (state, effects) = update(state, Command::ThemePickerCancel); - assert!(state.theme_picker.is_none(), "the picker closes"); - assert!(state.user_menu.is_none(), "Esc lands on the panels, not back on the F2 menu (design D5)"); - assert!(effects.is_empty()); -} - -// --------------------------------------------------------------------- -// visual-themes: Options -> Themes picker -// --------------------------------------------------------------------- - -#[test] -fn theme_picker_opens_from_the_options_menu_with_the_active_theme_highlighted() { - let mut state = test_state(UiPhase::Panels); - state.theme = Theme::terminal_green(); - let (state, _) = update(state, Command::MenuOpen); - let (state, _) = update(state, Command::MenuHotkey('o')); - let (state, effects) = update(state, Command::MenuActivate); - assert!(state.menu.is_none(), "the menu overlay closes"); - let picker = state.theme_picker.expect("Options -> Themes opens the picker"); - let expected = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "terminal-green").unwrap(); - assert_eq!(picker.highlight, expected, "the active theme's row is pre-highlighted"); - assert!(effects.is_empty()); -} - -#[test] -fn theme_picker_up_down_moves_the_highlight_over_the_theme_list() { - let state = test_state(UiPhase::Panels); // active theme is nc-classic (index 0) - let (state, _) = update(state, Command::ThemePickerOpen); - assert_eq!(state.theme_picker.as_ref().unwrap().highlight, 0); - let (state, _) = update(state, Command::ThemePickerMove(1)); - assert_eq!(state.theme_picker.as_ref().unwrap().highlight, 1); - let (state, _) = update(state, Command::ThemePickerMove(-1)); - assert_eq!(state.theme_picker.as_ref().unwrap().highlight, 0, "Up from the first row holds, it does not wrap"); -} - -#[test] -fn theme_picker_enter_applies_the_highlighted_theme_immediately_and_persists_it() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::ThemePickerOpen); - // Move down to `yellow-storm` (index 4 of BUILTIN_THEME_NAMES). - let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "yellow-storm").unwrap(); - let (state, _) = update(state, Command::ThemePickerMove(target as isize)); - let (state, effects) = update(state, Command::ThemePickerConfirm); - assert!(state.theme_picker.is_none(), "the dialog closes"); - assert_eq!(state.theme.name, "yellow-storm", "the active theme switches in this same reducer step"); - assert_eq!(effects, vec![Effect::PersistTheme("yellow-storm".to_string())]); -} - -#[test] -fn theme_picker_esc_changes_nothing() { - let state = test_state(UiPhase::Panels); // active theme is nc-classic - let (state, _) = update(state, Command::ThemePickerOpen); - let (state, _) = update(state, Command::ThemePickerMove(3)); // highlight some other theme - let (state, effects) = update(state, Command::ThemePickerCancel); - assert!(state.theme_picker.is_none(), "the dialog closes"); - assert_eq!(state.theme.name, "nc-classic", "the active theme is untouched"); - assert!(effects.is_empty(), "nothing is persisted on cancel"); -} - -// --------------------------------------------------------------------- -// theme-picker-live-preview: State::render_theme() -// --------------------------------------------------------------------- - -#[test] -fn render_theme_before_opening_the_picker_equals_the_active_theme() { - let mut state = test_state(UiPhase::Panels); - state.theme = Theme::terminal_green(); - assert_eq!(state.render_theme(), state.theme, "no picker open: render theme is the active theme"); -} - -#[test] -fn render_theme_while_open_tracks_the_highlighted_theme_as_it_moves() { - let mut state = test_state(UiPhase::Panels); // active theme is nc-classic (index 0) - state.theme = Theme::classic(); - let (state, _) = update(state, Command::ThemePickerOpen); - // Opening is visually a no-op: highlight starts on the active theme. - assert_eq!(state.render_theme(), state.theme, "opening previews the active theme first"); - - let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "purple-lights").unwrap(); - let (state, _) = update(state, Command::ThemePickerMove(target as isize)); - assert_eq!( - state.render_theme().name, - "purple-lights", - "moving the highlight previews the newly highlighted theme" - ); - assert_eq!(state.theme.name, "nc-classic", "the applied theme is untouched by moving the highlight"); -} - -#[test] -fn render_theme_after_cancel_reverts_to_the_active_theme() { - let state = test_state(UiPhase::Panels); // active theme is nc-classic - let (state, _) = update(state, Command::ThemePickerOpen); - let (state, _) = update(state, Command::ThemePickerMove(3)); // preview some other theme - assert_ne!(state.render_theme().name, "nc-classic", "sanity: a non-active theme is previewed"); - let (state, _) = update(state, Command::ThemePickerCancel); - assert_eq!(state.render_theme(), state.theme, "after cancel, render theme is the (unchanged) active theme"); - assert_eq!(state.theme.name, "nc-classic", "cancel never touched the active theme"); -} - -#[test] -fn render_theme_after_confirm_equals_the_newly_applied_theme() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::ThemePickerOpen); - let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "yellow-storm").unwrap(); - let (state, _) = update(state, Command::ThemePickerMove(target as isize)); - let (state, _) = update(state, Command::ThemePickerConfirm); - assert_eq!(state.render_theme(), state.theme, "after confirm, render theme is the applied theme"); - assert_eq!(state.render_theme().name, "yellow-storm"); -} - -// --------------------------------------------------------------------- -// M5: F1 Help window + About dialog -// --------------------------------------------------------------------- - -#[test] -fn help_opens_with_about_filecommand_highlighted_first() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let help = state.help.unwrap(); - assert_eq!(help.cursor, 0); - assert_eq!(crate::dialogs::HELP_TOPICS[help.cursor], "About FileCommand"); - assert!(help.page.is_none()); - assert!(!help.about_open); -} - -#[test] -fn help_activate_on_about_opens_the_about_dialog_not_a_page() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let (state, _) = update(state, Command::HelpActivate); - assert!(state.help.as_ref().unwrap().about_open); - assert!(state.help.as_ref().unwrap().page.is_none()); -} - -#[test] -fn help_activate_on_a_topic_opens_its_page() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let (state, _) = update(state, Command::HelpMove(1)); // "Keyboard reference" - let (state, _) = update(state, Command::HelpActivate); - assert_eq!(state.help.as_ref().unwrap().page, Some(1)); -} - -#[test] -fn help_cancel_returns_a_page_to_the_list_then_closes_the_window() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let (state, _) = update(state, Command::HelpMove(1)); - let (state, _) = update(state, Command::HelpActivate); - assert!(state.help.as_ref().unwrap().page.is_some()); - - let (state, _) = update(state, Command::HelpCancel); - assert!(state.help.is_some(), "Esc from a page returns to the list, not closing the window"); - assert!(state.help.as_ref().unwrap().page.is_none()); - assert_eq!(state.help.as_ref().unwrap().cursor, 1, "the highlight is preserved"); - - let (state, _) = update(state, Command::HelpCancel); - assert!(state.help.is_none(), "Esc from the list closes the window"); -} - -#[test] -fn help_cancel_dismisses_about_back_to_the_list_with_about_still_highlighted() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let (state, _) = update(state, Command::HelpActivate); // opens About - let (state, _) = update(state, Command::HelpCancel); - assert!(state.help.is_some()); - assert!(!state.help.as_ref().unwrap().about_open); - assert_eq!(state.help.as_ref().unwrap().cursor, 0); -} - -#[test] -fn help_move_does_not_walk_past_the_ends_of_the_topic_list() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::HelpOpen); - let (state, _) = update(state, Command::HelpMove(-5)); - assert_eq!(state.help.as_ref().unwrap().cursor, 0); - let last = crate::dialogs::HELP_TOPICS.len() - 1; - let state = (0..20).fold(state, |s, _| update(s, Command::HelpMove(1)).0); - assert_eq!(state.help.as_ref().unwrap().cursor, last); -} - -// --------------------------------------------------------------------- -// M5: directory frecency recording + persistence (fuzzy-jump "Navigation -// records history") -// --------------------------------------------------------------------- - -#[test] -fn navigating_into_a_directory_records_and_persists_frecency() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - state.clock_ms = 5_000; - let (state, effects) = update(state, Command::Enter); - assert_eq!(state.dir_history, vec![history_entry("/left/sub", 1, 5_000)]); - assert!(effects.iter().any(|e| matches!(e, Effect::PersistHistory(file) if file.directories == state.dir_history))); -} - -#[test] -fn revisiting_a_directory_increments_its_frecency_entry_rather_than_duplicating() { - let mut state = test_state(UiPhase::Panels); - state.dir_history = vec![history_entry(r"C:\a", 1, 0)]; - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); // /left, a fresh entry - let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); // /left again - let count = state.dir_history.iter().find(|e| e.path.as_path() == Path::new("/left")).unwrap().visit_count; - assert_eq!(count, 2, "re-reading the same directory increments the same entry rather than adding a duplicate"); -} - -#[test] -fn tree_cursor_preview_of_the_opposite_panel_does_not_record_frecency() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); - let (state, _) = update( - state, - Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }, - ); - let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); - assert!(state.dir_history.is_empty(), "browsing the tree previews the opposite panel; it does not count as a visit"); -} - -// --------------------------------------------------------------------- -// M5 review fix: startup-warning modal (malformed usermenu.toml) -// --------------------------------------------------------------------- - -#[test] -fn dismiss_startup_warning_clears_it() { - let mut state = test_state(UiPhase::Panels); - state.startup_warning = Some("usermenu.toml is malformed; F2 uses the default user menu".to_string()); - let (state, effects) = update(state, Command::DismissStartupWarning); - assert_eq!(state.startup_warning, None); - assert!(effects.is_empty()); -} - -#[test] -fn dismiss_startup_warning_is_a_no_op_when_nothing_is_warned() { - let state = test_state(UiPhase::Panels); - assert_eq!(state.startup_warning, None); - let (state, effects) = update(state, Command::DismissStartupWarning); - assert_eq!(state.startup_warning, None); - assert!(effects.is_empty()); -} - -// --------------------------------------------------------------------- -// Panel-scrolling: reducer-level viewport reconciliation (task 1.5) -// -// `panel::tests::scroll_offset_tests` exercises `ensure_cursor_visible` -// directly at fixed row counts; these tests instead drive the reducer end -// to end so each wiring site in `update.rs` (task 1.4) is proven to -// actually call it, at term sizes chosen so `panel_viewport_rows` produces -// a known, hand-verifiable row count (visible here via `super::*`, the same -// private helper `update`'s own reducer arms use). -// --------------------------------------------------------------------- - -#[test] -fn full_mode_viewport_rows_at_80x24_are_19() { - // Every test below is built on this figure: panels_h = 24 - 2 = 22, no - // tab strip at 1 tab (reserved = 2), body_h = 20, Full/Tree = body_h - - // 1 (header row) = 19. - assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Full, 1), 19); -} - -#[test] -fn quick_filter_narrowing_re_clamps_the_offset_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Unsorted; - state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - state.left.cursor = 25; // "e25" -- one of only three entries containing "5" - state.left.scroll_offset = 15; // a stale offset from before filtering narrowed the list - - let (state, _) = update(state, Command::QuickFilterStart); - let (state, _) = update(state, Command::QuickFilterChar('5')); - - let visible: Vec = state.left.visible_indices().into_iter().map(|i| state.left.entries[i].name.to_string_lossy().into_owned()).collect(); - assert_eq!(visible, vec!["e5", "e15", "e25"], "only entries containing \"5\" remain"); - assert_eq!(state.left.cursor, 25, "\"e25\" stays selected — it's still visible under the filter"); - assert_eq!(state.left.scroll_offset, 2, "\"e25\" is now at visible position 2; the stale offset (15) must re-clamp to it (panel-navigation \"Quick-filter narrowing re-clamps the offset\")"); -} - -#[test] -fn re_sort_re_clamps_the_offset_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Unsorted; - // Descending by size, so "e00" (the largest) starts at position 0. - state.left.entries = (0..30).map(|i| file_entry(&format!("e{i:02}"), 29 - i as u64)).collect(); - state.left.cursor = 0; // "e00" - state.left.scroll_offset = 0; - - let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); - - assert_eq!(state.left.entries.last().unwrap().name, OsString::from("e00"), "ascending-by-size moves the largest entry to the very end"); - assert_eq!(state.left.cursor, 29, "the cursor re-anchors onto \"e00\"'s new position"); - assert_eq!(state.left.scroll_offset, 11, "29 + 1 - 19 = 11 (panel-navigation \"Re-sort keeps the cursor's entry in view\")"); -} - -#[test] -fn terminal_shrinking_re_clamps_the_offset_on_resize() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Unsorted; - state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - state.left.cursor = 18; - state.left.scroll_offset = 0; - - let (state, _) = update(state, Command::Resize(80, 24)); - assert_eq!(state.left.scroll_offset, 0, "no shrink yet: the cursor is already inside the 19-row window"); - - // 80x14: panels_h = 12, no tab strip, body_h = 10, Full rows = 9. - assert_eq!(panel_viewport_rows((80, 14), DisplayMode::Full, 1), 9); - let (state, _) = update(state, Command::Resize(80, 14)); - assert_eq!(state.left.scroll_offset, 10, "18 + 1 - 9 = 10 (panel-navigation \"Terminal resize re-clamps\")"); -} - -#[test] -fn tab_restore_re_clamps_against_the_current_viewport_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Unsorted; - state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - state.left.cursor = 18; - state.left.scroll_offset = 10; // valid at 19 rows: window [10, 29) - - // Stash this as tab 1; the new (now-active) tab inherits the same - // cursor/offset and is left untouched from here on. - let (state, _) = update(state, Command::OpenTab); - assert_eq!(state.left.tab_count(), 2); - - // Shrink drastically enough that the stashed tab's offset (10) will no - // longer keep its cursor (18) in view once restored. - let (state, _) = update(state, Command::Resize(80, 10)); - // 80x10, 2 tabs: panels_h = 8, tab strip visible (reserved = 3), - // body_h = 5, Full rows = 4. - assert_eq!(panel_viewport_rows((80, 10), DisplayMode::Full, 2), 4); - - let (state, _) = update(state, Command::SwitchTab(1)); - assert_eq!(state.left.cwd, PathBuf::from("/left"), "tab 1 is the originally-stashed tab"); - assert_eq!(state.left.cursor, 18, "the restored cursor position round-trips exactly"); - assert_eq!( - state.left.scroll_offset, 15, - "the stashed offset (10) no longer fits a 4-row window around cursor 18; 18 + 1 - 4 = 15 (panel-navigation \"Tab restore re-clamps against the current viewport\")" - ); -} - -#[test] -fn streamed_chunk_keeps_the_offset_pinned_to_zero_via_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Name; - let entries: Vec = (0..30).map(|i| file_entry(&format!("e{i:02}"), 0)).collect(); - - let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries }); - - assert_eq!(state.left.cursor, 0, "the cursor stays pinned to the top while the user hasn't moved it"); - assert_eq!(state.left.scroll_offset, 0, "the window stays pinned to the top right along with the cursor (panel-navigation \"Streamed listing keeps the top pinned\")"); -} - -#[test] -fn find_file_settle_on_listing_complete_lands_the_cursor_in_view() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Full-mode body: 19 rows - state.left.sort_mode = SortMode::Unsorted; - state.left.pending_cursor_target = Some(OsString::from("e25")); - let entries: Vec = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - - let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries }); - let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 30 }); - - assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("e25"), "find-file's deferred settle lands the cursor on the matched entry"); - assert_eq!(state.left.cursor, 25); - assert_eq!( - state.left.scroll_offset, 7, - "25 + 1 - 19 = 7: the settled cursor lands inside the window (panel-navigation \"Scroll offset is core panel state\" -- find-file's deferred cursor settle re-clamps)" - ); -} - -// --------------------------------------------------------------------- -// Panel-scrolling: Brief column-window and Tree reconciliation (task 2.3) -// -// These drive the reducer end to end, proving `reconcile_panel_viewport`'s -// Brief/Tree branches (task 2.1/2.2) are actually wired into -// `Command::MoveCursor` and `Command::TreeNodeExpanded`, on top of -// `panel::tests::brief_scroll_tests`/`tree_scroll_tests`'s pure coverage of -// the underlying clamp math. -// --------------------------------------------------------------------- - -#[test] -fn brief_mode_interior_width_and_column_count_at_80x24_default_split() { - // split 50/50 at width 80 -> left_w = 40, interior = 38 -> 3 columns - // (byte-identical to `filecommand-tui::views::panel::render_brief_body`'s - // `(inner_w / 12).max(1)`). - let interior = panel_interior_width((80, 24), panel_split::DEFAULT_SPLIT_PERCENT, PanelSide::Left); - assert_eq!(interior, 38); - assert_eq!(brief_column_count(interior), 3); - // Brief rows_h = the full body (no header row): panels_h = 22, no tab - // strip at 1 tab, body_h = 20. - assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Brief, 1), 20); -} - -#[test] -fn brief_mode_cursor_past_the_last_column_shifts_the_window_one_column_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Brief-mode: 20 rows_h, 3 columns (60-position window) - state.left.display_mode = DisplayMode::Brief; - state.left.sort_mode = SortMode::Unsorted; - state.left.entries = (0..100).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - state.left.cursor = 59; // column 2 (last of the window), row 19 -- the window's last position - state.left.scroll_offset = 0; - - let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); - - assert_eq!(state.left.cursor, 60, "column 3, row 0: one step past the window's last column"); - assert_eq!( - state.left.scroll_offset, 20, - "shifts by exactly one column (20 positions), not further (additional-panel-modes \"Cursor past the last visible column shifts the window one column\")" - ); - assert_eq!(state.left.scroll_offset % 20, 0, "the offset stays on a rows_h-multiple column boundary (additional-panel-modes \"Window start stays on a column boundary\")"); -} - -#[test] -fn brief_mode_quick_filter_re_clamps_the_column_window_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Brief-mode: 20 rows_h, 3 columns - state.left.display_mode = DisplayMode::Brief; - state.left.sort_mode = SortMode::Unsorted; - state.left.entries = (0..100).map(|i| file_entry(&format!("e{i}"), 0)).collect(); - state.left.cursor = 65; // column 3 - state.left.scroll_offset = 60; // window starts at column 3, matching the cursor - - let (state, _) = update(state, Command::QuickFilterStart); - // Only "e6" and "e65"/"e6x" family remain a small handful of matches - // near the front of the list, forcing the (now far too large) stale - // offset to pull back. - let (state, _) = update(state, Command::QuickFilterChar('e')); - let (state, _) = update(state, Command::QuickFilterChar('6')); - - let visible = state.left.visible_indices(); - assert!(!visible.is_empty()); - let pos = visible.iter().position(|&i| i == state.left.cursor).unwrap(); - let start_col = state.left.scroll_offset / 20; - let pos_col = pos / 20; - assert!( - start_col <= pos_col && pos_col < start_col + 3, - "the cursor's column ({pos_col}) must be inside the re-clamped window starting at column {start_col} (panel-navigation \"Quick-filter narrowing re-clamps the offset\", extended to Brief's column space)" - ); - assert_eq!(state.left.scroll_offset % 20, 0, "offset stays on a column boundary after re-clamping"); -} - -#[test] -fn tree_mode_cursor_below_the_bottom_scrolls_the_node_window_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 24); // Tree-mode body: 19 rows (body_h - 1 header) - assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Tree, 1), 19); - state.left.display_mode = DisplayMode::Tree; - let mut tree = TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full); - let children: Vec = (0..30).map(|i| dir_entry(&format!("d{i}"))).collect(); - tree.insert_children(&PathBuf::from(r"C:\"), children); // 31 nodes total (root + 30) - tree.cursor = 18; // last visible row of a 19-row window starting at 0 - state.left.tree = Some(tree); - - let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); - - let tree = state.left.tree.as_ref().unwrap(); - assert_eq!(tree.cursor, 19); - assert_eq!(tree.scroll_offset, 1, "shifts by exactly one row, mirroring Full mode (additional-panel-modes \"Tree cursor below the bottom scrolls the nodes\")"); -} - -#[test] -fn tree_node_expanded_re_clamps_the_tree_offset_through_the_reducer() { - let mut state = test_state(UiPhase::Panels); - state.term_size = (80, 12); // a small Tree-mode body forces a small row count - let rows = panel_viewport_rows((80, 12), DisplayMode::Tree, 1); - state.left.display_mode = DisplayMode::Tree; - let mut tree = TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full); - let children: Vec = (0..15).map(|i| dir_entry(&format!("d{i}"))).collect(); - tree.insert_children(&PathBuf::from(r"C:\"), children); // 16 nodes total (root + 15) - tree.cursor = 12; - tree.scroll_offset = 0; // stale at the current (small) row count: 12 is not in [0, rows) - state.left.tree = Some(tree); - - // Expand a *different* node than the cursor's; it must not move the - // cursor, but the node-list mutation still funnels through - // reconciliation (additional-panel-modes "Tree mode scrolling"). - let more = vec![dir_entry("extra")]; - let (state, _) = update(state, Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\d0"), children: more }); - - let tree = state.left.tree.as_ref().unwrap(); - assert_eq!(tree.cursor, 12, "expanding a different node doesn't move the cursor"); - assert_eq!(tree.scroll_offset, 12 + 1 - rows, "the stale offset re-clamps to the minimal-shift window around the cursor once TreeNodeExpanded re-runs reconciliation"); -} - -// --------------------------------------------------------------------- -// Clipboard export (clipboard-export) -// --------------------------------------------------------------------- - -#[test] -fn copy_to_clipboard_uses_cursor_entry_when_nothing_explicitly_selected() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("README.md", 10)]; - let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Paths)); - assert_eq!( - effects, - vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Paths, items: vec![PathBuf::from(r"C:\NORTON\README.md")] })] - ); - assert!(state.left.clipboard_feedback.is_none(), "feedback waits for the ClipboardResult reply"); -} - -#[test] -fn copy_to_clipboard_uses_explicit_selection_over_cursor() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; - state.left.selected.insert(OsString::from("a.txt")); - state.left.selected.insert(OsString::from("c.txt")); - state.left.cursor = 1; // b.txt: not selected, must not be used - - let (_, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Files)); - let Some(Effect::SetClipboard(payload)) = effects.into_iter().next() else { panic!("expected a SetClipboard effect") }; - assert_eq!(payload.kind, ClipboardPayloadKind::Files); - let mut items = payload.items; - items.sort(); - assert_eq!(items, vec![PathBuf::from(r"C:\NORTON\a.txt"), PathBuf::from(r"C:\NORTON\c.txt")]); -} - -#[test] -fn copy_to_clipboard_on_parent_dir_with_no_selection_reports_nothing_to_copy() { - // The parent-directory pseudo-entry is never a valid clipboard source - // (clipboard-export "Parent entry is never copied"), exactly like F5. - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; - state.left.cursor = 0; // ".." - let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Files)); - assert!(effects.is_empty(), "nothing is written to the clipboard"); - let feedback = state.left.clipboard_feedback.expect("the mini-status reports there is nothing to copy"); - assert_eq!(feedback.message, "Nothing to copy"); - assert!(!feedback.is_error); -} - -#[test] -fn copy_to_clipboard_with_empty_panel_reports_nothing_to_copy() { - let state = test_state(UiPhase::Panels); - let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Names)); - assert!(effects.is_empty()); - assert_eq!(state.left.clipboard_feedback.unwrap().message, "Nothing to copy"); -} - -#[test] -fn clipboard_result_ok_files_reports_the_plural_template_even_for_one() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; - let (state, effects) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert!(effects.is_empty()); - let feedback = state.left.clipboard_feedback.expect("success sets feedback"); - assert_eq!(feedback.message, "1 files copied to clipboard"); - assert!(!feedback.is_error); -} - -#[test] -fn clipboard_result_ok_paths_singular_names_the_path() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Paths, items: vec![PathBuf::from(r"C:\NORTON\README.md")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert_eq!(state.left.clipboard_feedback.unwrap().message, r"Path copied: C:\NORTON\README.md"); -} - -#[test] -fn clipboard_result_ok_paths_plural_counts() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { - kind: ClipboardPayloadKind::Paths, - items: vec![PathBuf::from(r"C:\NORTON\a.txt"), PathBuf::from(r"C:\NORTON\b.txt")], - }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert_eq!(state.left.clipboard_feedback.unwrap().message, "2 paths copied"); -} - -#[test] -fn clipboard_result_ok_names_counts() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Names, items: vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert_eq!(state.left.clipboard_feedback.unwrap().message, "2 names copied"); -} - -#[test] -fn clipboard_result_fallback_to_paths_names_the_platform_limitation() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: true, result: Ok(()) }); - let feedback = state.left.clipboard_feedback.unwrap(); - assert_eq!(feedback.message, "Paths copied (file objects unsupported here)"); - assert!(!feedback.is_error); -} - -#[test] -fn clipboard_result_err_shows_the_message_in_the_error_role() { - let state = test_state(UiPhase::Panels); - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Err("Clipboard busy — try again".to_string()) }); - let feedback = state.left.clipboard_feedback.unwrap(); - assert_eq!(feedback.message, "Clipboard busy — try again"); - assert!(feedback.is_error); -} - -#[test] -fn clipboard_feedback_expires_via_tick_once_the_deadline_passes() { - let mut state = test_state(UiPhase::Panels); - state.clock_ms = 0; - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from("a.txt")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert!(state.left.clipboard_feedback.is_some()); - - // Not yet at the deadline: still showing. - let (state, _) = update(state, Command::Tick(CLIPBOARD_FEEDBACK_MS - 1)); - assert!(state.left.clipboard_feedback.is_some(), "feedback holds until the deadline is reached"); - - // At the deadline: expired. - let (state, _) = update(state, Command::Tick(CLIPBOARD_FEEDBACK_MS)); - assert!(state.left.clipboard_feedback.is_none(), "feedback expires once the clock reaches expires_at_ms"); -} - -#[test] -fn clipboard_feedback_clears_on_the_next_key_before_the_deadline() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.clock_ms = 0; - let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from("a.txt")] }; - let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); - assert!(state.left.clipboard_feedback.is_some()); - - // Any other command — e.g. Down — counts as "the next key press" and - // clears the feedback immediately, well before the ~3s deadline. - let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); - assert_eq!(state.left.cursor, 1, "the movement itself still applies"); - assert!(state.left.clipboard_feedback.is_none(), "the mini-status shows the normal display again"); -} - -#[test] -fn files_menu_clipboard_group_dispatches_copy_to_clipboard() { - assert_eq!( - menu_action_command(MenuAction::ClipboardFiles, PanelSide::Left), - Some(Command::CopyToClipboard(ClipboardPayloadKind::Files)) - ); - assert_eq!( - menu_action_command(MenuAction::ClipboardPaths, PanelSide::Left), - Some(Command::CopyToClipboard(ClipboardPayloadKind::Paths)) - ); - assert_eq!( - menu_action_command(MenuAction::ClipboardNames, PanelSide::Left), - Some(Command::CopyToClipboard(ClipboardPayloadKind::Names)) - ); -} - -#[test] -fn files_menu_activate_on_copy_to_clipboard_runs_the_files_action() { - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::MenuOpen); - let (mut state, _) = update(state, Command::MenuHotkey('f')); // Files - let target = crate::menu::entries(crate::menu::MenuId::Files) - .iter() - .position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Copy to clipboard")) - .unwrap(); - state.menu.as_mut().unwrap().selected = target; - let (state, effects) = update(state, Command::MenuActivate); - assert!(state.menu.is_none(), "activating the item closes the whole menu overlay"); - assert_eq!( - effects, - vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] })] - ); -} - -#[test] -fn send_to_clipboard_action_menu_entry_targets_only_the_menus_entry() { - // The menu's target (`notes.txt`) is copied even though a different - // entry is selected and the cursor sits on a third one — the action - // menu always scopes to the entry it was opened on, never the panel's - // live selection or cursor (design D3 of `file-action-menu`). - let mut state = test_state(UiPhase::Panels); - state.left.cwd = PathBuf::from(r"C:\NORTON"); - state.left.entries = vec![file_entry("notes.txt", 1), file_entry("other.txt", 2)]; - state.left.selected.insert(OsString::from("other.txt")); - let state = opened_menu_state_from(state, "notes.txt"); - - let (state, effects) = update(state, Command::FileActionMenuHotkey('S')); - assert!(state.file_action_menu.is_none(), "the hotkey closes the menu"); - assert_eq!( - effects, - vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\notes.txt")] })] - ); - assert_eq!(state.left.selected.len(), 1, "the panel's selection is untouched"); -} - -/// Like `opened_menu_state`, but starting from a caller-built `state` -/// (entries/selection already set up) instead of building a fresh one — -/// needed by tests that must control the panel's selection independently -/// of the menu's target entry. -fn opened_menu_state_from(mut state: State, cursor_on: &str) -> State { - state.left.cursor = state.left.entries.iter().position(|e| e.name == cursor_on).unwrap(); - let (state, _) = update(state, Command::Enter); - assert!(state.file_action_menu.is_some(), "setup precondition: the menu must be open"); - state -} - -// --------------------------------------------------------------------- -// Mouse (mouse-basics, design D2) -// --------------------------------------------------------------------- - -/// mouse-input "Click focuses and places the cursor" — "Click on the -/// inactive panel". -#[test] -fn click_entry_focuses_the_clicked_panel_and_moves_its_cursor() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.right.entries = vec![file_entry("notes.txt", 1), file_entry("other.txt", 2)]; - let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Right, name: OsString::from("notes.txt"), mods: ClickMods::Plain }); - assert_eq!(state.active, PanelSide::Right); - assert_eq!(state.right.cursor, 0); -} - -/// mouse-input "Click on a selected entry keeps it selected" — a plain -/// click never touches the selection set, only the cursor. -#[test] -fn click_entry_plain_never_changes_selection() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.left.selected.insert(OsString::from("a.txt")); - let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("b.txt"), mods: ClickMods::Plain }); - assert_eq!(state.left.cursor, 1); - assert!(state.left.selected.contains(&OsString::from("a.txt")), "the existing selection survives an unrelated plain click"); - assert!(!state.left.selected.contains(&OsString::from("b.txt")), "a plain click never selects the clicked entry either"); -} - -/// mouse-input "Ctrl+click toggles selection" — "Toggle on". -#[test] -fn ctrl_click_toggles_selection_and_moves_the_cursor() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("a.txt"), mods: ClickMods::Ctrl }); - assert!(state.left.selected.contains(&OsString::from("a.txt"))); - assert_eq!(state.left.cursor, 0); - - let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("a.txt"), mods: ClickMods::Ctrl }); - assert!(!state.left.selected.contains(&OsString::from("a.txt")), "a second Ctrl+click toggles it back off"); -} - -/// mouse-input "Ctrl+click toggles selection" — "Parent entry ignored". -#[test] -fn ctrl_click_on_parent_entry_never_selects_it() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry { name: OsString::from(".."), kind: EntryKind::ParentDir, size: 0, modified: None }]; - let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from(".."), mods: ClickMods::Ctrl }); - assert!(state.left.selected.is_empty()); - assert_eq!(state.left.cursor, 0, "the cursor still moves to `..`"); -} - -/// A name the panel no longer lists is a silent no-op, not a panic — the -/// hit map can go briefly stale between a frame being drawn and the click -/// landing (e.g. a re-read completing in between). -#[test] -fn click_entry_on_a_vanished_name_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.right.entries = vec![file_entry("a.txt", 1)]; - let before_cursor = state.right.cursor; - let (state, effects) = update(state, Command::ClickEntry { side: PanelSide::Right, name: OsString::from("gone.txt"), mods: ClickMods::Plain }); - assert!(effects.is_empty()); - assert_eq!(state.right.cursor, before_cursor); - assert_eq!(state.active, PanelSide::Right, "focus still moves to the clicked side even though the name missed"); -} - -/// mouse-input "Click focuses and places the cursor" — clicking blank body -/// area or the title focuses without moving the cursor. -#[test] -fn focus_panel_switches_active_without_touching_the_cursor() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.right.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.right.cursor = 1; - let (state, effects) = update(state, Command::FocusPanel(PanelSide::Right)); - assert!(effects.is_empty()); - assert_eq!(state.active, PanelSide::Right); - assert_eq!(state.right.cursor, 1, "the cursor is untouched"); -} - -/// mouse-input "Wheel moves the cursor of the panel under the pointer" — -/// "Wheel over the inactive panel": the cursor of the scrolled panel moves, -/// the active panel does not change. -#[test] -fn scroll_panel_moves_the_cursor_of_the_named_side_without_changing_focus() { - let mut state = test_state(UiPhase::Panels); - state.active = PanelSide::Left; - state.right.entries = (0..10).map(|i| file_entry(&format!("f{i}.txt"), 1)).collect(); - let (state, _) = update(state, Command::ScrollPanel { side: PanelSide::Right, delta: 3 }); - assert_eq!(state.active, PanelSide::Left, "the active panel never changes"); - assert_eq!(state.right.cursor, 3, "three rows per notch"); -} - -#[test] -fn scroll_panel_upward_never_underflows_past_the_first_row() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = (0..10).map(|i| file_entry(&format!("f{i}.txt"), 1)).collect(); - state.left.cursor = 1; - let (state, _) = update(state, Command::ScrollPanel { side: PanelSide::Left, delta: -3 }); - assert_eq!(state.left.cursor, 0); -} - -/// mouse-input "Key bar, menu bar, pull-down items, and dialog buttons are -/// clickable" — "Key bar Copy". -#[test] -fn keybar_press_five_opens_the_copy_setup_dialog() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::KeybarPress(5)); - assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Copy, .. }))); -} - -#[test] -fn keybar_press_ten_requests_quit_exactly_like_f10() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::KeybarPress(10)); - assert!(state.quit_confirm); -} - -#[test] -fn keybar_press_out_of_range_is_a_no_op() { - let state = test_state(UiPhase::Panels); - let before = state.clone(); - let (state, effects) = update(state, Command::KeybarPress(0)); - assert!(effects.is_empty()); - assert_eq!(state, before); -} - -/// mouse-input "Key bar, menu bar, pull-down items, and dialog buttons are -/// clickable" — a menu-title click opens that pull-down. -#[test] -fn menu_title_click_opens_the_named_pulldown() { - let state = test_state(UiPhase::Panels); - let (state, _) = update(state, Command::MenuTitleClick(MenuId::Files)); - let menu = state.menu.expect("the bar opens"); - assert_eq!(menu.active, MenuId::Files); - assert!(menu.pulldown_open); -} - -/// A menu-title click while a different pull-down is already open switches -/// to the clicked one instead of stacking overlays. -#[test] -fn menu_title_click_switches_from_an_already_open_menu() { - let mut state = test_state(UiPhase::Panels); - state.menu = Some(MenuState::for_menu(MenuId::Left)); - let (state, _) = update(state, Command::MenuTitleClick(MenuId::Options)); - assert_eq!(state.menu.unwrap().active, MenuId::Options); -} - -/// mouse-input "Menu item activation" — clicking `Files` then `Delete` -/// starts the delete-confirmation flow exactly as F8 would. -#[test] -fn menu_item_click_activates_the_item_exactly_like_menu_activate() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - state.menu = Some(MenuState::for_menu(MenuId::Files)); - let delete_index = crate::menu::entries(MenuId::Files).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Delete")).unwrap(); - let (state, _) = update(state, Command::MenuItemClick(delete_index)); - assert!(state.menu.is_none(), "activating an item closes the bar"); - assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. }))); -} - -#[test] -fn menu_item_click_on_a_disabled_item_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.menu = Some(MenuState::for_menu(MenuId::Files)); - let disabled_index = crate::menu::entries(MenuId::Files).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "View")).unwrap(); - let (state, effects) = update(state, Command::MenuItemClick(disabled_index)); - assert!(state.menu.is_some(), "a disabled item never closes the bar"); - assert!(effects.is_empty()); -} - -#[test] -fn menu_item_click_with_no_menu_open_is_a_no_op() { - let state = test_state(UiPhase::Panels); - let before = state.clone(); - let (state, effects) = update(state, Command::MenuItemClick(0)); - assert!(effects.is_empty()); - assert_eq!(state, before); -} - -/// mouse-input "Dialog button" — clicking `Skip All` on the conflict dialog -/// applies the choice exactly as if selected by keyboard. -#[test] -fn dialog_button_click_conflict_skip_all_sends_the_same_effect_as_the_key() { - let progress_state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (state, _) = update(progress_state, Command::JobConflict(sample_conflict())); - assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. }), "setup precondition"); - - let (_, effects) = update(state.clone(), Command::FileOpConflictChoice(ConflictChoice::SkipAll)); - let (_, click_effects) = update(state, Command::DialogButtonClick(ButtonId::ConflictSkipAll)); - assert_eq!(effects, click_effects); -} - -/// mouse-input "Running job accepts Cancel only" — the Cancel button click -/// signals the same cancellation the keyboard path does. -#[test] -fn dialog_button_click_progress_cancel_cancels_the_job() { - let state = running_progress_state(JobKind::Copy, "/left", "/right"); - let (_, effects) = update(state, Command::DialogButtonClick(ButtonId::ProgressCancel)); - assert_eq!(effects, vec![Effect::CancelJob]); -} - -/// Quit-dialog buttons reach the same global `quit_confirm` handling the -/// keyboard's Y/N does, regardless of what phase is underneath. -#[test] -fn dialog_button_click_quit_yes_confirms_quit() { - let mut state = test_state(UiPhase::Panels); - state.quit_confirm = true; - let (state, effects) = update(state, Command::DialogButtonClick(ButtonId::QuitYes)); - assert!(effects.contains(&Effect::Quit)); - assert!(!state.quit_confirm); -} - -#[test] -fn dialog_button_click_quit_no_cancels_the_quit_dialog_and_restores_context() { - let mut state = test_state(UiPhase::Viewer(crate::viewer::ViewerState::new(PathBuf::from("f.txt"), 0))); - state.quit_confirm = true; - let (state, effects) = update(state, Command::DialogButtonClick(ButtonId::QuitNo)); - assert!(effects.is_empty()); - assert!(!state.quit_confirm); - assert!(matches!(state.phase, UiPhase::Viewer(_)), "cancelling only clears the flag, restoring whatever was underneath"); -} - -/// `Command::OpenActionMenuAt`: files get the same single-target menu -/// `handle_enter` builds; directories get the same menu minus View, Edit, -/// and Run (file-action-menu "Directory targets and selection-scoped -/// invocation"). -#[test] -fn open_action_menu_at_opens_the_menu_for_a_file() { - let mut state = test_state(UiPhase::Panels); - state.right.entries = vec![file_entry("notes.txt", 1)]; - let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Right, name: OsString::from("notes.txt") }); - assert_eq!(state.active, PanelSide::Right); - assert_eq!(state.right.cursor, 0); - let menu = state.file_action_menu.expect("the menu opens for a file target"); - assert_eq!(menu.target_name, OsString::from("notes.txt")); - assert!(!menu.selection_scoped, "not a member of any selection"); -} - -/// file-action-menu "Directory targets and selection-scoped invocation" — -/// "Directory menu contents". -#[test] -fn open_action_menu_at_on_a_directory_opens_the_menu_without_view_edit_or_run() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("src")]; - let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("src") }); - assert_eq!(state.left.cursor, 0, "the cursor moves to the target"); - let menu = state.file_action_menu.expect("the menu opens for a directory target too"); - assert_eq!( - menu.entries, - vec![ - FileActionMenuEntry::SendToClipboard, - FileActionMenuEntry::Copy, - FileActionMenuEntry::Rename, - FileActionMenuEntry::Move, - FileActionMenuEntry::Delete, - ] - ); -} - -#[test] -fn open_action_menu_at_on_the_parent_entry_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry { name: OsString::from(".."), kind: EntryKind::ParentDir, size: 0, modified: None }]; - let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("..") }); - assert!(state.file_action_menu.is_none(), "`..` is never a valid action-menu target"); -} - -/// file-action-menu "Directory targets and selection-scoped invocation" — -/// "Selection-scoped delete": right-clicking a member of a multi-entry -/// selection scopes Delete to the whole selection, not just the clicked -/// entry. -#[test] -fn open_action_menu_at_on_a_selected_entry_scopes_delete_to_the_whole_selection() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3), file_entry("d.txt", 4)]; - state.left.selected.insert(OsString::from("a.txt")); - state.left.selected.insert(OsString::from("b.txt")); - state.left.selected.insert(OsString::from("c.txt")); - let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("b.txt") }); - let menu = state.file_action_menu.as_ref().unwrap(); - assert!(menu.selection_scoped); - - let (state, _) = update(state, Command::FileActionMenuHotkey('D')); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { - assert_eq!(sources.len(), 3, "the dialog is scoped to the whole selection, naming the count"); - } - other => panic!("expected DeleteConfirm scoped to the selection, got {other:?}"), - } -} - -/// Right-clicking an entry that is *not* selected stays scoped to that one -/// entry, exactly like the pre-existing Enter behavior. -#[test] -fn open_action_menu_at_on_an_unselected_entry_stays_single_target() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.left.selected.insert(OsString::from("a.txt")); - let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("b.txt") }); - let menu = state.file_action_menu.as_ref().unwrap(); - assert!(!menu.selection_scoped); - - let (state, _) = update(state, Command::FileActionMenuHotkey('D')); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("b.txt")); - } - other => panic!("expected DeleteConfirm scoped to b.txt alone, got {other:?}"), - } -} - -/// file-action-menu "Enter stays single-target": the keyboard path never -/// scopes to the selection even when the cursor entry is itself selected. -#[test] -fn enter_on_a_selected_entry_still_opens_a_single_target_menu() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; - state.left.selected.insert(OsString::from("a.txt")); - state.left.selected.insert(OsString::from("b.txt")); - state.left.selected.insert(OsString::from("c.txt")); - state.left.cursor = 1; // b.txt, itself selected - let (state, _) = update(state, Command::Enter); - let menu = state.file_action_menu.as_ref().unwrap(); - assert!(!menu.selection_scoped, "Enter-key invocation SHALL remain single-target and file-only"); -} - -// --------------------------------------------------------------------- -// File-action-menu row clicks (file-action-menu-mouse-click; mouse-input -// "File-action menu entries are clickable") -// --------------------------------------------------------------------- - -/// Clicking a row activates it exactly like `Command::FileActionMenuConfirm` -/// would once the highlight is there — -/// `menu_item_click_activates_the_item_exactly_like_menu_activate`, mirrored -/// for the file-action menu. -#[test] -fn file_action_menu_item_click_activates_the_item_exactly_like_confirm() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let edit_index = state.file_action_menu.as_ref().unwrap().entries.iter().position(|e| *e == FileActionMenuEntry::Edit).unwrap(); - let (state, effects) = update(state, Command::FileActionMenuItemClick(edit_index)); - assert!(state.file_action_menu.is_none(), "activating an entry closes the menu"); - assert_eq!(effects, vec![Effect::OpenEditor { path: PathBuf::from("/left/notes.txt") }]); -} - -#[test] -fn file_action_menu_item_click_with_no_menu_open_is_a_no_op() { - let state = test_state(UiPhase::Panels); - let before = state.clone(); - let (state, effects) = update(state, Command::FileActionMenuItemClick(0)); - assert!(effects.is_empty()); - assert_eq!(state, before); -} - -/// A stale/out-of-range index (shouldn't normally happen given hit-testing, -/// but guarded anyway) leaves the menu untouched. -#[test] -fn file_action_menu_item_click_out_of_range_is_a_no_op() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); - let before = state.clone(); - let (state, effects) = update(state, Command::FileActionMenuItemClick(999)); - assert!(effects.is_empty()); - assert_eq!(state, before, "an out-of-range index leaves the menu untouched"); -} - -/// Clicking a row other than the highlighted one activates the clicked row, -/// not the previously-highlighted one (file-action-menu-mouse-click -/// "Clicking a different row than the highlighted one activates the clicked -/// row"). -#[test] -fn file_action_menu_item_click_on_a_non_highlighted_row_activates_the_clicked_row() { - let state = opened_menu_state(&["notes.txt"], "notes.txt"); // View highlighted first - assert_eq!(state.file_action_menu.as_ref().unwrap().selected(), FileActionMenuEntry::View, "setup precondition"); - let delete_index = state.file_action_menu.as_ref().unwrap().entries.iter().position(|e| *e == FileActionMenuEntry::Delete).unwrap(); - let (state, _) = update(state, Command::FileActionMenuItemClick(delete_index)); - assert!(state.file_action_menu.is_none()); - assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. })), "Delete was activated, not View"); -} - -// --------------------------------------------------------------------- -// Mouse drag-and-drop (mouse-panel-drag) -// --------------------------------------------------------------------- - -#[test] -fn drag_begin_on_a_selected_entry_scopes_to_the_whole_selection() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; - state.left.selected.insert(OsString::from("a.txt")); - state.left.selected.insert(OsString::from("c.txt")); - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let drag = state.drag.as_ref().expect("drag should have begun"); - let mut names: Vec<_> = drag.items.iter().map(|s| s.original_name.clone()).collect(); - names.sort(); - assert_eq!(names, vec![OsString::from("a.txt"), OsString::from("c.txt")]); - assert_eq!(drag.source, PanelSide::Left); - assert_eq!(drag.source_dir, PathBuf::from("/left")); - assert_eq!(drag.op, JobKind::Copy); - assert_eq!(drag.target, None); -} - -#[test] -fn drag_begin_on_an_unselected_entry_drags_only_that_entry_and_leaves_selection_unchanged() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; - state.left.selected.insert(OsString::from("a.txt")); - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("b.txt"), op: JobKind::Copy }); - let drag = state.drag.as_ref().expect("drag should have begun"); - assert_eq!(drag.items.len(), 1); - assert_eq!(drag.items[0].original_name, OsString::from("b.txt")); - assert!(state.left.selected.contains(&OsString::from("a.txt")), "selection must be unchanged"); - assert!(!state.left.selected.contains(&OsString::from("b.txt"))); -} - -#[test] -fn drag_begin_never_drags_the_parent_pseudo_entry() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from(".."), op: JobKind::Copy }); - assert!(state.drag.is_none(), "the parent-directory pseudo-entry SHALL never be dragged"); -} - -#[test] -fn drag_begin_on_an_entry_that_no_longer_exists_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("gone.txt"), op: JobKind::Copy }); - assert!(state.drag.is_none()); -} - -#[test] -fn drag_over_stores_a_valid_target_and_the_recomputed_verb() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Move, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - let drag = state.drag.as_ref().expect("drag must still be in progress"); - assert_eq!(drag.op, JobKind::Move, "the verb is recomputed on every DragOver (design D2)"); - assert_eq!(drag.target, Some(DropTarget::PanelDir(PanelSide::Right))); -} - -#[test] -fn drag_over_rejects_the_items_own_directory() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Left)) }); - assert_eq!(state.drag.as_ref().unwrap().target, None, "the items' own directory is never a valid target"); -} - -#[test] -fn drag_over_rejects_a_dragged_directory_onto_itself_or_its_own_descendant() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![dir_entry("sub")]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("sub"), op: JobKind::Copy }); - - // Onto itself: the `sub` row of the very panel it was dragged from. - let (state, _) = update( - state, - Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("sub") }) }, - ); - assert_eq!(state.drag.as_ref().unwrap().target, None, "a directory dropped onto itself must be invalid"); - - // Into a listing inside itself: the other panel has since navigated - // into a descendant of the dragged directory. - let mut state = state; - state.right.cwd = PathBuf::from("/left/sub/nested"); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - assert_eq!(state.drag.as_ref().unwrap().target, None, "a descendant of the dragged directory must be invalid"); -} - -#[test] -fn drag_over_rejects_info_and_quick_view_panels() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - state.right.display_mode = DisplayMode::Info; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - assert_eq!(state.drag.as_ref().unwrap().target, None, "Info-mode panels are never valid targets"); -} - -#[test] -fn drag_drop_opens_the_drop_dialog_prefilled_with_the_exact_target_path() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; - state.right.entries = vec![Entry::parent_dir(), dir_entry("OLD")]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update( - state, - Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, - ); - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(state.drag.is_none(), "the drag ends the moment the dialog opens"); - assert!(effects.is_empty(), "opening the dialog is not itself an effect"); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, input, buttons, .. }) => { - assert_eq!(kind, JobKind::Copy); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].original_name, OsString::from("a.txt")); - assert_eq!(input, PathBuf::from("/right").join("OLD").display().to_string()); - assert_eq!(buttons, Some(DropButtons { focused: JobKind::Copy })); - } - other => panic!("expected a drop-initiated DestinationInput dialog, got {other:?}"), - } -} - -#[test] -fn drag_drop_on_an_invalid_target_does_nothing() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - // No DragOver ever validated a target — `target` stays `None`. - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(matches!(state.phase, UiPhase::Panels), "no dialog opens over an invalid drop"); - assert!(effects.is_empty()); - assert!(state.drag.is_none()); -} - -#[test] -fn drag_drop_is_cancelled_when_the_source_panel_navigated_away() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - // The source panel navigates elsewhere mid-drag. - let mut state = state; - state.left.cwd = PathBuf::from("/left/elsewhere"); - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled, not just retargeted"); - assert!(effects.is_empty()); - assert!(state.drag.is_none()); -} - -/// mouse-drag "Valid drop targets" scenario "Same-panel subdirectory": a -/// subdirectory row in the *same* panel the drag started from is a valid -/// target (only the items' own directory and self/descendant drops are -/// rejected — a different sibling subdirectory of the same panel is fine). -#[test] -fn drag_over_accepts_a_subdirectory_row_in_the_same_panel_as_the_source() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir(), file_entry("notes.txt", 1), dir_entry("src")]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("notes.txt"), op: JobKind::Copy }); - let (state, _) = update( - state, - Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("src") }) }, - ); - assert_eq!( - state.drag.as_ref().unwrap().target, - Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("src") }), - "a sibling subdirectory in the source's own panel must be a valid target" - ); - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { - assert_eq!(input, PathBuf::from("/left").join("src").display().to_string()); - } - other => panic!("expected the same-panel subdirectory drop dialog, got {other:?}"), - } - assert!(effects.is_empty()); -} - -/// mouse-drag "Robust against listing changes": "the target row no longer -/// resolves to a directory" — a `SubDir` target validated by an earlier -/// `DragOver` must be re-checked at `DragDrop` time, since the entry it named -/// can have changed kind (or vanished) in between. -#[test] -fn drag_drop_is_cancelled_when_the_target_row_no_longer_resolves_to_a_directory() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - state.right.entries = vec![dir_entry("OLD")]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update( - state, - Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, - ); - assert!(state.drag.as_ref().unwrap().target.is_some(), "OLD is a directory, so the target should validate"); - - // Between the last `DragOver` and the button-up, `OLD` stops being a - // directory (e.g. a concurrent re-read replaced it with a same-named - // file). - let mut state = state; - state.right.entries = vec![file_entry("OLD", 4)]; - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled when the target no longer resolves to a directory"); - assert!(effects.is_empty()); - assert!(state.drag.is_none()); -} - -/// mouse-drag "Robust against listing changes": the same re-validation also -/// cancels the drop outright when the target row disappears entirely rather -/// than merely changing kind. -#[test] -fn drag_drop_is_cancelled_when_the_target_row_has_vanished() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - state.right.entries = vec![dir_entry("OLD")]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update( - state, - Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, - ); - let mut state = state; - state.right.entries = vec![]; // OLD is gone by the time the button comes up. - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled when the target row no longer exists"); - assert!(effects.is_empty()); - assert!(state.drag.is_none()); -} - -/// mouse-drag + operation-dialogs: a Move-proposing drag never runs the job -/// directly on drop — it only ever opens the drop dialog with `[ Move ]` -/// focused, and the job starts strictly from an explicit -/// `FileOpConfirmAs`/`FileOpConfirm` on that dialog (a button click, or Enter -/// while it's focused). This is the named-test complement to the -/// `drag_proptests` invariant that no drag-lifecycle command ever emits -/// `Effect::RunJob` directly. -#[test] -fn a_move_drag_drop_opens_the_dialog_and_never_runs_the_job_directly() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - // Right-button drag (design D1): proposes Move. - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Move }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Move, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - let (state, drop_effects) = update(state, Command::DragDrop { op: JobKind::Move }); - assert!(drop_effects.is_empty(), "DragDrop must never itself emit an effect, let alone RunJob"); - match &state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, buttons, .. }) => { - assert_eq!(*kind, JobKind::Move); - assert_eq!(*buttons, Some(DropButtons { focused: JobKind::Move }), "the dialog must open with [ Move ] focused, not run yet"); - } - other => panic!("expected the drop dialog with Move focused, got {other:?}"), - } - // Only now, via the dialog's own confirm, does the job actually start. - let (state, effects) = update(state, Command::FileOpConfirm); - assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); - match effects.as_slice() { - [Effect::RunJob(job)] => assert_eq!(job.kind, JobKind::Move), - other => panic!("expected exactly one RunJob(Move) from the dialog confirm, got {other:?}"), - } -} - -#[test] -fn drag_cancel_clears_the_drag_with_no_effect() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - assert!(state.drag.is_some()); - let (state, effects) = update(state, Command::DragCancel); - assert!(state.drag.is_none()); - assert!(effects.is_empty()); - assert!(matches!(state.phase, UiPhase::Panels)); -} - -/// mouse-drag "Cancel and phase-change clear the drag": "Pressing Esc during -/// a drag SHALL cancel it" (tasks.md 2.4). `input::map_panel_key` maps Esc to -/// `Command::DragCancel` while `state.drag` is `Some` (see -/// `filecommand-tui/src/input/mod.rs::map_panel_key`'s own test coverage) — -/// this closes the loop from the core side: a `DragDrop` that arrives *after* -/// the cancel (exactly what `MouseTracker` sends on the button-up that -/// follows a cancelled drag, since it only learns the drag ended once this -/// event lands) finds `state.drag` already `None` and is a pure no-op, so -/// "no dialog opens and nothing changes" holds end to end. -#[test] -fn a_drag_drop_that_arrives_after_esc_cancelled_the_drag_is_a_no_op() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - let (state, _) = update(state, Command::DragCancel); - assert!(state.drag.is_none(), "Esc must clear the drag immediately"); - - // The button-up event the TUI's `MouseTracker` still has queued arrives - // after the cancel — `resolve_drag_release` cannot know the drag was - // cancelled and sends `DragDrop` regardless (mouse-panel-drag input - // stage); core must not resurrect a dialog from it. - let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); - assert!(matches!(state.phase, UiPhase::Panels), "no dialog opens from a drop after Esc cancelled the drag"); - assert!(effects.is_empty()); - assert!(state.drag.is_none()); -} - -#[test] -fn keyboard_copy_dialog_still_has_no_button_row() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::RequestCopy); - match state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { buttons, .. }) => { - assert_eq!(buttons, None, "the keyboard F5 dialog has no button row"); - } - other => panic!("expected DestinationInput, got {other:?}"), - } -} - -/// operation-dialogs "Switching the verb in the dialog": clicking `[ Move ]` -/// on a dialog that opened proposing Copy starts a Move job, without first -/// needing to change which button is focused. -#[test] -fn file_op_confirm_as_overrides_the_dialogs_opened_verb() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); - let (state, _) = update(state, Command::DragDrop { op: JobKind::Copy }); - match &state.phase { - UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, buttons, .. }) => { - assert_eq!(*kind, JobKind::Copy); - assert_eq!(*buttons, Some(DropButtons { focused: JobKind::Copy })); - } - other => panic!("expected drop dialog, got {other:?}"), - } - let (state, effects) = update(state, Command::FileOpConfirmAs(JobKind::Move)); - assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); - match effects.as_slice() { - [Effect::RunJob(job)] => assert_eq!(job.kind, JobKind::Move), - other => panic!("expected exactly one RunJob(Move), got {other:?}"), - } -} - -/// `ButtonId::DropDialogCopy`/`DropDialogMove`/`DropDialogCancel` route -/// exactly like their keyboard equivalents via `button_command`. -#[test] -fn drop_dialog_button_ids_route_through_button_command() { - assert_eq!(button_command(ButtonId::DropDialogCopy), Some(Command::FileOpConfirmAs(JobKind::Copy))); - assert_eq!(button_command(ButtonId::DropDialogMove), Some(Command::FileOpConfirmAs(JobKind::Move))); - assert_eq!(button_command(ButtonId::DropDialogCancel), Some(Command::FileOpCancel)); -} - -#[test] -fn drag_clears_when_the_f9_menu_opens() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - assert!(state.drag.is_some()); - let (state, _) = update(state, Command::MenuOpen); - assert!(state.drag.is_none(), "opening an overlay must clear an in-progress drag"); -} - -#[test] -fn drag_clears_on_quit_request() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::RequestQuit); - assert!(state.drag.is_none()); -} - -#[test] -fn drag_clears_on_listing_failure() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::ListingFailed { panel: PanelSide::Right, message: "boom".to_string() }); - assert!(state.drag.is_none()); -} - -#[test] -fn drag_clears_on_resize_below_the_minimum() { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![file_entry("a.txt", 1)]; - let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); - let (state, _) = update(state, Command::Resize(10, 5)); - assert!(matches!(state.phase, UiPhase::Placeholder)); - assert!(state.drag.is_none()); -} - -mod drag_proptests { - use super::*; - use proptest::prelude::*; - - fn arb_side() -> impl Strategy { - prop_oneof![Just(PanelSide::Left), Just(PanelSide::Right)] - } - - fn arb_verb() -> impl Strategy { - prop_oneof![Just(JobKind::Copy), Just(JobKind::Move)] - } - - fn arb_name() -> impl Strategy { - prop_oneof![Just(OsString::from("a.txt")), Just(OsString::from("sub")), Just(OsString::from(".."))].prop_map(|n| n) - } - - fn arb_drop_target() -> impl Strategy { - prop_oneof![ - arb_side().prop_map(DropTarget::PanelDir), - (arb_side(), arb_name()).prop_map(|(side, name)| DropTarget::SubDir { side, name }), - (arb_side(), arb_name()).prop_map(|(side, name)| DropTarget::TreeNode { side, path: PathBuf::from(name) }), - (arb_side(), 0usize..3).prop_map(|(side, index)| DropTarget::Tab { side, index }), - ] - } - - fn arb_command() -> impl Strategy { - prop_oneof![ - (arb_side(), arb_name(), arb_verb()).prop_map(|(side, name, op)| Command::DragBegin { side, name, op }), - (arb_verb(), prop::option::of(arb_drop_target())).prop_map(|(op, target)| Command::DragOver { op, target }), - arb_verb().prop_map(|op| Command::DragDrop { op }), - Just(Command::DragCancel), - (arb_side(), arb_name()).prop_map(|(side, name)| Command::ClickEntry { side, name, mods: ClickMods::Plain }), - Just(Command::ToggleSelectAtCursor), - Just(Command::RequestCopy), - Just(Command::RequestMove), - // `ConfirmQuit`/`CancelQuit` are deliberately excluded: core - // assumes (via the TUI's mode-gating table, not enforced here) - // that they only ever arrive while `state.quit_confirm` is - // already `true` — `RequestQuit` alone already exercises the - // relevant drag-clearing path (see `drag_clears_on_quit_request` - // above). - Just(Command::RequestQuit), - Just(Command::MenuOpen), - Just(Command::MenuClose), - Just(Command::FileOpConfirm), - arb_verb().prop_map(Command::FileOpConfirmAs), - Just(Command::FileOpCancel), - arb_side().prop_map(|panel| Command::ListingFailed { panel, message: "boom".to_string() }), - (10u16..200, 4u16..80).prop_map(|(w, h)| Command::Resize(w, h)), - ] - } - - fn seeded_state() -> State { - let mut state = test_state(UiPhase::Panels); - state.left.entries = vec![Entry::parent_dir(), dir_entry("sub"), file_entry("a.txt", 1)]; - state.right.entries = vec![Entry::parent_dir(), dir_entry("sub"), file_entry("a.txt", 1)]; - state - } - - fn any_overlay_open(state: &State) -> bool { - state.menu.is_some() - || state.drive_select.is_some() - || state.fuzzy_jump.is_some() - || state.find_file.is_some() - || state.user_menu.is_some() - || state.theme_picker.is_some() - || state.file_action_menu.is_some() - || state.help.is_some() - || state.startup_warning.is_some() - || state.quit_confirm - } - - proptest! { - /// mouse-drag "Cancel and phase-change clear the drag", property- - /// tested over random command interleavings (design D5): `drag` is - /// never `Some` outside `UiPhase::Panels` with no overlay open, and - /// none of `DragBegin`/`DragOver`/`DragDrop`/`DragCancel` ever - /// directly emits `Effect::RunJob` — a job only ever starts through - /// the dialog's own `FileOpConfirm`/`FileOpConfirmAs`. - #[test] - fn drag_invariants_hold_over_random_command_interleavings(cmds in prop::collection::vec(arb_command(), 0..25)) { - let mut state = seeded_state(); - for cmd in cmds { - let is_drag_lifecycle_cmd = - matches!(cmd, Command::DragBegin { .. } | Command::DragOver { .. } | Command::DragDrop { .. } | Command::DragCancel); - let (next_state, effects) = update(state, cmd); - state = next_state; - if is_drag_lifecycle_cmd { - prop_assert!( - !effects.iter().any(|e| matches!(e, Effect::RunJob(_))), - "DragBegin/DragOver/DragDrop/DragCancel must never directly emit RunJob" - ); - } - if state.drag.is_some() { - prop_assert!(matches!(state.phase, UiPhase::Panels), "drag survived outside UiPhase::Panels"); - prop_assert!(!any_overlay_open(&state), "drag survived with an overlay open"); - } - } - } - } -} diff --git a/crates/filecommand-core/src/update/tests/editor.rs b/crates/filecommand-core/src/update/tests/editor.rs new file mode 100644 index 0000000..4265f1d --- /dev/null +++ b/crates/filecommand-core/src/update/tests/editor.rs @@ -0,0 +1,210 @@ +use super::*; + +// --------------------------------------------------------------------- +// pulldown-menu-completion: Commands → Menu file edit (task 2.5; user-menu +// "Edit the user menu file from the Commands menu"; design D4) +// --------------------------------------------------------------------- + +#[test] +fn edit_user_menu_file_uses_the_builtin_editor_when_none_is_configured() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_path = PathBuf::from("/profile/usermenu.toml"); + let (state, effects) = update(state, Command::EditUserMenuFile); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/profile/usermenu.toml") }]); + assert_eq!(state.user_menu_path, PathBuf::from("/profile/usermenu.toml")); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening usermenu.toml…"), "the active panel announces the open"); +} + +#[test] +fn edit_user_menu_file_uses_the_configured_external_editor() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.user_menu_path = PathBuf::from("/profile/usermenu.toml"); + let (state, effects) = update(state, Command::EditUserMenuFile); + assert_eq!(effects.len(), 1); + match &effects[0] { + Effect::RunExternalEditor(invocation, side) => { + assert_eq!(*side, PanelSide::Left); + assert_eq!(invocation.file_arg, OsString::from("/profile/usermenu.toml")); + assert_eq!(invocation.program, "notepad"); + } + other => panic!("expected RunExternalEditor, got {other:?}"), + } + assert!(state.user_menu().is_none(), "no overlay opened"); +} + +#[test] +fn user_menu_reloaded_applies_a_wellformed_file() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![config::UserMenuEntry { label: "Old".to_string(), command: "old".to_string() }]; + let result = config::UserMenuLoadResult { + entries: vec![config::UserMenuEntry { label: "New".to_string(), command: "new".to_string() }], + warnings: Vec::new(), + created_default: false, + malformed: false, + problem: None, + }; + let (state, effects) = update(state, Command::UserMenuReloaded(result)); + assert!(effects.is_empty()); + assert_eq!(state.user_menu_entries.len(), 1); + assert_eq!(state.user_menu_entries[0].label, "New", "the reloaded entries replace the old ones"); + assert_eq!(state.startup_warning(), None); +} + +/// configuration-files "Warnings after editing the user menu": a +/// well-formed reload with non-fatal warnings applies the entries *and* +/// shows the warning summary naming the file and line. +#[test] +fn user_menu_reloaded_with_warnings_applies_entries_and_shows_the_summary() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![config::UserMenuEntry { label: "Old".to_string(), command: "old".to_string() }]; + let parsed = config::parse_usermenu("[[entry]]\nlabel = \"New\"\ncommand = \"new\"\nicon = \"disk\"\n"); + assert!(!parsed.malformed && parsed.warnings.len() == 1, "fixture: one unknown-key warning"); + let result = + config::UserMenuLoadResult { entries: parsed.entries, warnings: parsed.warnings, created_default: false, malformed: false, problem: None }; + let (state, effects) = update(state, Command::UserMenuReloaded(result)); + assert!(effects.is_empty()); + assert_eq!(state.user_menu_entries.len(), 1); + assert_eq!(state.user_menu_entries[0].label, "New", "the edited entries replace the F2 menu"); + let warning = state.startup_warning().expect("the warnings raise the dialog"); + assert!(warning.starts_with("usermenu.toml line 4:"), "names the file and line: {warning}"); + assert!(warning.contains("icon"), "names the unknown key: {warning}"); +} + +#[test] +fn user_menu_reloaded_malformed_warns_and_keeps_the_previous_entries() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![config::UserMenuEntry { label: "Previous".to_string(), command: "prev".to_string() }]; + let result = config::UserMenuLoadResult { + entries: config::default_user_menu_entries(), + warnings: Vec::new(), + created_default: false, + malformed: true, + problem: Some(config::UserMenuProblem::InvalidToml), + }; + let (state, effects) = update(state, Command::UserMenuReloaded(result)); + assert!(effects.is_empty()); + assert_eq!(state.user_menu_entries[0].label, "Previous", "the previous entries are kept, not the defaults"); + let warning = state.startup_warning().expect("the malformed reload raises the warning dialog"); + assert!(warning.contains(config::USERMENU_FILE), "the warning names the file: {warning}"); +} + +#[test] +fn f4_with_no_editor_configured_opens_the_built_in_editor() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 10)]; + let (state, effects) = update(state, Command::RequestEditor); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/left/report.txt") }]); + assert_eq!(state.phase, UiPhase::Panels, "phase flips only once EditorOpened comes back — opening is I/O"); + assert_eq!(state.pending_open, Some(PendingOpen { request: 3, side: PanelSide::Left, kind: PendingOpenKind::Editor })); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening report.txt…")); +} + +#[test] +fn f4_with_an_external_editor_configured_takes_precedence_over_the_built_in_editor() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![file_entry("report.txt", 10)]; + let (_, effects) = update(state, Command::RequestEditor); + assert!( + matches!(effects.as_slice(), [Effect::RunExternalEditor(_, PanelSide::Left)]), + "external editor takes precedence: no OpenEditor effect, got {effects:?}" + ); +} + +#[test] +fn f4_on_a_directory_or_empty_panel_does_not_open_the_editor() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (_, effects) = update(state, Command::RequestEditor); + assert!(effects.is_empty()); + + let state = test_state(UiPhase::Panels); + let (_, effects) = update(state, Command::RequestEditor); + assert!(effects.is_empty()); +} + +#[test] +fn editor_opened_enters_the_editor_phase_with_the_loaded_buffer() { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let editor = editor_with(&["line one", "line two"]); + let (state, effects) = update(state, Command::EditorOpened { request: 3, editor: Box::new(editor) }); + assert!(effects.is_empty()); + assert_eq!(state.pending_open, None); + match state.phase { + UiPhase::Editor(e) => assert_eq!(e.lines, vec!["line one".to_string(), "line two".to_string(), String::new()]), + other => panic!("expected UiPhase::Editor, got {other:?}"), + } +} + +#[test] +fn editor_too_large_opens_the_viewer_instead_with_an_inline_notice() { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let (state, effects) = update(state, Command::EditorTooLarge { request: 3, path: PathBuf::from("/left/huge.log"), size: 20_000_000 }); + // The redirect is a fresh request for the same side (offload-blocking-io + // design D4): a late reply to the editor open can no longer match. + assert_eq!(effects, vec![Effect::OpenViewer { request: 4, path: PathBuf::from("/left/huge.log") }]); + assert_eq!(state.pending_open, Some(PendingOpen { request: 4, side: PanelSide::Left, kind: PendingOpenKind::Viewer })); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening huge.log…")); + assert_eq!(state.phase, UiPhase::Panels); + let message = state.left.last_error.expect("a notice explaining the redirect"); + assert!(message.contains("huge.log"), "{message}"); + assert!(message.contains("10 MB"), "{message}"); +} + +/// builtin-editor "UTF-16 file redirects to the viewer", "File containing a +/// NUL byte redirects to the viewer", "Non-UTF-8 file redirects to the +/// viewer": each reason lands its own notice on the active panel and opens +/// the viewer, never the editor (editor-encoding-safety design D2). +#[test] +fn editor_not_editable_opens_the_viewer_instead_with_a_reason_specific_notice() { + use crate::editor::NotEditableReason; + let cases = [ + (NotEditableReason::Utf16, "export.reg", "export.reg is UTF-16 text — the editor only edits UTF-8; opened in the viewer"), + ( + NotEditableReason::Binary, + "tool.exe", + "tool.exe looks binary (contains NUL bytes) — the editor only edits UTF-8 text; opened in the viewer", + ), + (NotEditableReason::NotUtf8, "setup.bat", "setup.bat is not valid UTF-8 — the editor only edits UTF-8 text; opened in the viewer"), + ]; + for (reason, name, expected) in cases { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let path = PathBuf::from(format!("/left/{name}")); + let (state, effects) = update(state, Command::EditorNotEditable { request: 3, path: path.clone(), reason }); + assert_eq!(effects, vec![Effect::OpenViewer { request: 4, path }], "{reason:?} opens the viewer for the same path under a fresh id"); + assert_eq!(state.phase, UiPhase::Panels, "{reason:?} never enters the editor phase"); + assert_eq!(state.left.last_error.as_deref(), Some(expected), "{reason:?} notice"); + assert_eq!(state.right.last_error, None, "the notice lands on the active panel only"); + } +} + +#[test] +fn editor_not_editable_notice_lands_on_the_right_panel_when_it_is_active() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + let state = with_pending_open(state, 3); + let (state, effects) = update( + state, + Command::EditorNotEditable { request: 3, path: PathBuf::from("/right/export.reg"), reason: crate::editor::NotEditableReason::Utf16 }, + ); + assert_eq!(effects, vec![Effect::OpenViewer { request: 4, path: PathBuf::from("/right/export.reg") }]); + assert!(state.right.last_error.as_deref().unwrap().contains("UTF-16")); + assert_eq!(state.left.last_error, None); +} + +/// builtin-editor "External editor receives non-UTF-8 files unchanged": the +/// reducer never inspects content, so with `editor =` configured F4 on a +/// UTF-16 `.reg` export dispatches the external editor and no +/// `Effect::OpenEditor` (and therefore no classification) ever runs. +#[test] +fn f4_with_an_external_editor_configured_never_classifies_the_file() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![file_entry("export.reg", 4096)]; + let (state, effects) = update(state, Command::RequestEditor); + assert_eq!(effects.len(), 1); + assert!(matches!(effects[0], Effect::RunExternalEditor(_, PanelSide::Left)), "got {effects:?}"); + assert!(!effects.iter().any(|e| matches!(e, Effect::OpenEditor { .. })), "no OpenEditor effect: {effects:?}"); + assert_eq!(state.left.last_error, None, "no redirect notice"); +} diff --git a/crates/filecommand-core/src/update/tests/file_op.rs b/crates/filecommand-core/src/update/tests/file_op.rs new file mode 100644 index 0000000..ca33622 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/file_op.rs @@ -0,0 +1,1861 @@ +use super::*; + +// --------------------------------------------------------------------- +// relative-destination-resolution: the F5/F6/F7 destination resolves +// against the source panel's directory (operation-dialogs "Relative +// destinations resolve against the source panel directory"). The drive +// forms are Windows-only `Path` semantics, so these tests are too. +// --------------------------------------------------------------------- + +/// Backspace the destination/name field's current text away and type +/// `replacement` — the same edits a user retyping the field makes. +#[cfg(windows)] +fn replace_destination_text(state: State, replacement: &str) -> State { + let len = match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => input.len_chars(), + other => panic!("expected the destination dialog, got {other:?}"), + }; + let state = (0..len).fold(state, |s, _| update(s, Command::TextEdit(TextEditOp::Backspace)).0); + replacement.chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0) +} + +/// Drive F6 from a left panel at `source_cwd` holding one file, retype the +/// destination field as `text`, and confirm with Enter. +#[cfg(windows)] +fn confirm_move_with_destination(source_cwd: &str, text: &str) -> (State, Vec) { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(source_cwd); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestMove); + let state = replace_destination_text(state, text); + update(state, Command::FileOpConfirm) +} + +/// Scenario "A relative name lands inside the source panel's directory". +#[cfg(windows)] +#[test] +fn a_relative_destination_lands_inside_the_source_panels_directory() { + let (_, effects) = confirm_move_with_destination(r"C:\work", "backup"); + match effects.as_slice() { + [Effect::RunJob(job)] => { + assert_eq!(job.kind, JobKind::Move); + assert_eq!(job.dest_dir, PathBuf::from(r"C:\work\backup"), "relative to the source panel, not the launch directory"); + } + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Scenario "A parent-relative path is resolved and normalized": the job +/// carries the normalized path, so the completion re-read finds the panel +/// showing it (`panels_matching` compares by exact `PathBuf` equality). +#[cfg(windows)] +#[test] +fn a_parent_relative_destination_is_resolved_and_normalized() { + let (state, effects) = confirm_move_with_destination(r"C:\work\proj", r"..\archive"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"C:\work\archive")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } + let mut state = state; + state.right.cwd = PathBuf::from(r"C:\work\archive"); + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from(r"C:\work\proj"), + dest_dir: PathBuf::from(r"C:\work\archive"), + }, + ); + assert!( + effects.contains(&start_listing(&state, PanelSide::Right, r"C:\work\archive")), + "the panel showing the resolved destination re-reads on completion, got {effects:?}" + ); +} + +/// Scenarios "A bare drive letter means that drive's root" and "A +/// drive-rooted path keeps the source panel's drive". +#[cfg(windows)] +#[test] +fn bare_drive_letter_and_drive_rooted_destinations_resolve() { + let (_, effects) = confirm_move_with_destination(r"C:\work", "D:"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"D:\")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } + let (_, effects) = confirm_move_with_destination(r"C:\work", r"\archive"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"C:\archive")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Scenario "A drive-relative path resolves against the source panel or +/// the drive root". +#[cfg(windows)] +#[test] +fn a_drive_relative_destination_resolves_against_the_source_panel_or_the_drive_root() { + let (_, effects) = confirm_move_with_destination(r"D:\work", "D:old"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"D:\work\old")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } + let (_, effects) = confirm_move_with_destination(r"C:\work", "D:old"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"D:\old")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Scenario "UNC and absolute destinations are used unchanged". +#[cfg(windows)] +#[test] +fn unc_and_absolute_destinations_are_used_unchanged() { + let (_, effects) = confirm_move_with_destination(r"C:\work", r"\\server\share\dest"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"\\server\share\dest")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } + let (_, effects) = confirm_move_with_destination(r"C:\work", r"E:\backup"); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.dest_dir, PathBuf::from(r"E:\backup")), + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Scenario "An edited drop destination resolves against the drag's source +/// panel": the drag begins on the *inactive* right panel, so `sub` is +/// relative to `D:\src`, not to the panel it was dropped on. +#[cfg(windows)] +#[test] +fn an_edited_drop_destination_resolves_against_the_drags_source_panel() { + let mut state = test_state(UiPhase::Panels); + state.right.cwd = PathBuf::from(r"D:\src"); + state.right.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Right, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Left)) }); + let (state, _) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!( + matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { buttons: Some(_), .. })), + "setup precondition: the drop dialog opened" + ); + let state = replace_destination_text(state, "sub"); + let (_, effects) = update(state, Command::FileOpConfirmAs(JobKind::Copy)); + match effects.as_slice() { + [Effect::RunJob(job)] => { + assert_eq!(job.kind, JobKind::Copy); + assert_eq!(job.dest_dir, PathBuf::from(r"D:\src\sub"), "resolved against the panel the drag started from"); + } + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Scenario "A make-directory name with a parent path creates it where it +/// points": the job carries the resolved parent and the bare name, so a +/// panel showing that parent re-reads on completion. +#[cfg(windows)] +#[test] +fn a_make_directory_name_with_a_parent_path_creates_it_where_it_points() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work\proj"); + state.right.cwd = PathBuf::from(r"C:\work"); + let (state, _) = update(state, Command::RequestMkdir); + let state = replace_destination_text(state, r"..\shared"); + let (state, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => { + assert_eq!(job.kind, JobKind::Mkdir); + assert_eq!(job.dest_dir, PathBuf::from(r"C:\work")); + assert_eq!(job.new_dir_name, Some(OsString::from("shared"))); + } + other => panic!("expected exactly one RunJob, got {other:?}"), + } + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from(r"C:\work\proj"), + dest_dir: PathBuf::from(r"C:\work"), + }, + ); + assert!( + effects.contains(&start_listing(&state, PanelSide::Right, r"C:\work")), + "a panel showing the new directory's parent re-reads on completion, got {effects:?}" + ); +} + +/// A plain F7 name builds exactly the job F7 has always built: the panel's +/// own directory as `dest_dir`, the bare name as `new_dir_name` (design D3). +#[cfg(windows)] +#[test] +fn a_plain_make_directory_name_builds_the_unchanged_job() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work"); + let (state, _) = update(state, Command::RequestMkdir); + let state = replace_destination_text(state, "sub"); + let (_, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!( + job, + &Job { + kind: JobKind::Mkdir, + sources: vec![], + source_dir: PathBuf::from(r"C:\work"), + dest_dir: PathBuf::from(r"C:\work"), + new_dir_name: Some(OsString::from("sub")), + }, + "a plain name yields exactly today's job" + ), + other => panic!("expected exactly one RunJob, got {other:?}"), + } +} + +/// Design D3: a make-directory name that resolves to something with no +/// final component (a drive root) names nothing to create — the dialog +/// stays open, exactly as for an empty field. +#[cfg(windows)] +#[test] +fn a_make_directory_name_resolving_to_a_drive_root_keeps_the_dialog_open() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work"); + let (state, _) = update(state, Command::RequestMkdir); + let state = replace_destination_text(state, "D:"); + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(effects.is_empty(), "no operation starts, got {effects:?}"); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Mkdir, .. }))); +} + +/// Scenario "An unresolvable destination keeps the dialog open": `..` at a +/// drive root resolves to nothing, so the dialog stays open with the text +/// intact and no operation starts. +#[cfg(windows)] +#[test] +fn an_unresolvable_destination_keeps_the_dialog_open() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\"); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestMove); + let state = replace_destination_text(state, ".."); + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(effects.is_empty(), "no operation starts, got {effects:?}"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { ref input, .. }) => { + assert_eq!(input.text(), "..", "the field keeps the entered text"); + } + other => panic!("expected the dialog to stay open, got {other:?}"), + } +} + +#[test] +fn delete_confirm_requires_second_confirmation_for_a_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, effects) = update(state, Command::RequestDelete); + assert!(effects.is_empty()); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { needs_second_confirm: true, confirmed_once: false, .. }))); + + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(effects.is_empty(), "first confirm just arms the second confirmation"); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { needs_second_confirm: true, confirmed_once: true, .. }))); + + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(matches!(effects.as_slice(), [Effect::RunJob(Job { kind: JobKind::Delete, .. })])); + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); +} + +#[test] +fn delete_confirm_single_file_needs_only_one_confirmation() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestDelete); + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(matches!(effects.as_slice(), [Effect::RunJob(_)])); + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); +} + +#[test] +fn job_progress_event_updates_progress_dialog() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let info = ProgressInfo { files_done: 1, files_total: 3, bytes_done: 10, bytes_total: 30, current_file: OsString::from("a.txt") }; + let (state, effects) = update(state, Command::JobProgress(info.clone())); + assert!(effects.is_empty()); + match state.phase { + UiPhase::FileOpRunning { dialog: RunningDialog::Progress { progress, .. }, .. } => assert_eq!(progress, info), + other => panic!("expected Progress dialog, got {other:?}"), + } +} + +#[test] +fn job_conflict_event_switches_to_conflict_dialog_and_reply_returns_to_progress() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update(state, Command::JobConflict(sample_conflict())); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. })); + + let (state, effects) = update(state, Command::FileOpConflictChoice(ConflictChoice::Overwrite)); + assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Overwrite)]); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); +} + +#[test] +fn conflict_rename_flow_composes_a_name_then_replies() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update(state, Command::JobConflict(sample_conflict())); + let (state, _) = update(state, Command::FileOpBeginRename); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('b'))); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('.'))); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('c'))); + let (state, effects) = update(state, Command::FileOpConfirm); + assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Rename(OsString::from("b.c")))]); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); +} + +#[test] +fn job_error_event_switches_to_error_dialog_and_reply_returns_to_progress() { + let state = running_progress_state(JobKind::Delete, "/left", "/left"); + let info = ErrorInfo { path: PathBuf::from("/left/a.txt"), message: "permission denied".to_string() }; + let (state, _) = update(state, Command::JobError(info)); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { .. }, .. })); + let (state, effects) = update(state, Command::FileOpErrorChoice(ErrorChoice::Skip)); + assert_eq!(effects, vec![Effect::SendErrorReply(ErrorChoice::Skip)]); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); +} + +#[test] +fn job_cancel_sends_cancel_effect_without_leaving_progress_dialog() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, effects) = update(state, Command::FileOpCancelJob); + assert_eq!(effects, vec![Effect::CancelJob]); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); +} + +#[test] +fn job_done_with_no_skips_rereads_matching_panels_and_returns_to_panels() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left"), start_listing(&state, PanelSide::Right, "/right"),] + ); +} + +#[test] +fn job_done_with_skips_shows_summary_instead_of_panels() { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/left"), + dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(3, 30) }, + }); + state.left.cwd = PathBuf::from("/left"); + state.right.cwd = PathBuf::from("/right"); // unaffected panel: must not be re-read + let skipped = vec![SkippedItem::skipped(PathBuf::from("/left/a.txt"), "denied".to_string())]; + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: skipped.clone() }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/left"), + }, + ); + assert_eq!(state.phase, UiPhase::FileOpSummary(skipped)); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left")]); + + let (state, _) = update(state, Command::FileOpConfirm); + assert_eq!(state.phase, UiPhase::Panels); +} + +/// application-shell "Panic hook restores the terminal before reporting" +/// scenario "A panicked job ends and closes its dialog" +/// (process-lifecycle-hardening task 5.5): the worker's `catch_unwind` +/// reply is a `Cancelled` outcome carrying one internal-error skipped item +/// (design D6). The reducer needs no special case — the item takes the +/// job to the summary, which names the error, and the panels re-read. +#[test] +fn job_done_cancelled_with_an_internal_error_item_shows_the_summary_and_rereads_panels() { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + dialog: RunningDialog::Progress { kind: JobKind::Copy, progress: ProgressInfo::starting(3, 30) }, + }); + state.left.cwd = PathBuf::from("/left"); + state.right.cwd = PathBuf::from("/right"); + let skipped = vec![SkippedItem::skipped(PathBuf::from("/left"), "internal error: index out of bounds".to_string())]; + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Cancelled { skipped: skipped.clone() }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert_eq!(state.phase, UiPhase::FileOpSummary(skipped), "the progress dialog is gone and the summary names the internal error"); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left"), start_listing(&state, PanelSide::Right, "/right"),], + "both affected panels re-read" + ); + + // "a new file operation can be started afterwards": dismissing the + // summary returns to the panels. + let (state, _) = update(state, Command::FileOpConfirm); + assert_eq!(state.phase, UiPhase::Panels); +} + +/// application-shell scenario "A panicked listing ends as a failed listing" +/// (process-lifecycle-hardening task 5.5): a `ListingFailed` that arrives +/// after some chunks have streamed in clears the `Streaming` state — the +/// panel stops showing `Reading… N` and shows the error instead. +#[test] +fn listing_failed_after_chunks_clears_the_streaming_state() { + let mut state = test_state(UiPhase::Panels); + state.left.progress = crate::panel::ListingProgress::Streaming { count: 0 }; + let (state, _) = + update(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![file_entry("a.txt", 1), file_entry("b.txt", 2)], request: 1 }); + assert!(state.left.progress.is_streaming(), "setup precondition: still reading after the first chunk"); + + let (state, _) = + update(state, Command::ListingFailed { panel: PanelSide::Left, message: "Internal error while reading /left: boom".to_string(), request: 1 }); + assert!(!state.left.progress.is_streaming(), "no longer `Reading… N`: {:?}", state.left.progress); + assert_eq!(state.left.progress.count(), state.left.entries.len(), "the entries that did arrive are kept and counted"); + assert_eq!(state.left.last_error.as_deref(), Some("Internal error while reading /left: boom")); +} + +/// operation-dialogs "End-of-job skipped-files summary" scenario "Summary +/// shown for warnings alone" (error-diagnostics): a `JobDone` whose +/// `skipped` vector holds only warnings still moves to the summary phase +/// and still emits the panel re-reads. +#[test] +fn job_done_with_only_warnings_shows_summary_and_rereads_panels() { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + dialog: RunningDialog::Progress { kind: JobKind::Copy, progress: ProgressInfo::starting(1, 10) }, + }); + state.left.cwd = PathBuf::from("/left"); + state.right.cwd = PathBuf::from("/right"); + let warnings = vec![SkippedItem::warning(PathBuf::from("/right/report.docx"), "modification time not restored: denied".to_string())]; + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: warnings.clone() }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert_eq!(state.phase, UiPhase::FileOpSummary(warnings)); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left"), start_listing(&state, PanelSide::Right, "/right"),] + ); +} + +/// The unchanged half of "No summary when nothing was skipped": a job with +/// neither skips nor warnings returns to the panels with no summary. +#[test] +fn job_done_with_no_skips_and_no_warnings_shows_no_summary() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert_eq!(state.phase, UiPhase::Panels, "no summary when nothing was skipped and no warnings were recorded"); + assert!(!matches!(state.phase, UiPhase::FileOpSummary(_))); +} + +#[test] +fn job_done_only_rereads_panels_whose_cwd_matches_source_or_dest() { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/somewhere/else"), + dialog: RunningDialog::Progress { kind: JobKind::Copy, progress: ProgressInfo::starting(1, 1) }, + }); + state.left.cwd = PathBuf::from("/left"); + state.right.cwd = PathBuf::from("/right"); // does not match dest_dir + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/somewhere/else"), + }, + ); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left")]); +} + +#[test] +fn job_done_delete_rereads_both_panels_when_both_browse_the_same_directory() { + // BIG-162 base case: both panels' *active* tabs already sat on the + // directory a Delete completed in — this must keep re-reading both + // immediately, unchanged by the background-tab staleness work + // (file-operations "The opposite panel sharing the affected directory + // also refreshes"). + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/shared"), + dest_dir: PathBuf::from("/shared"), + dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(1, 1) }, + }); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.cwd = PathBuf::from("/shared"); + state.right.cwd = PathBuf::from("/shared"); + // Both panels sit partway down the same 30-entry listing; the delete + // removes "e10". + for side in [PanelSide::Left, PanelSide::Right] { + let p = state.panel_mut(side); + p.sort_mode = SortMode::Unsorted; + p.entries = (0..30).map(|i| file_entry(&format!("e{i:02}"), 0)).collect(); + p.progress = crate::panel::ListingProgress::Complete { count: 30 }; + p.cursor_user_moved = true; + } + state.left.cursor = 10; // on the deleted "e10" + state.left.scroll_offset = 5; + state.right.cursor = 20; // on "e20", which survives + state.right.scroll_offset = 8; + + let (mut state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/shared"), + dest_dir: PathBuf::from("/shared"), + }, + ); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/shared"), start_listing(&state, PanelSide::Right, "/shared"),] + ); + + // Each panel's re-read streams back the same 29 survivors; each keeps + // its own place (file-operations "Post-delete re-read keeps the user's + // place" — on *both* panels browsing the affected directory). + let survivors: Vec = (0..30).filter(|i| *i != 10).map(|i| file_entry(&format!("e{i:02}"), 0)).collect(); + for side in [PanelSide::Left, PanelSide::Right] { + let (next, _) = listing_chunk(state, side, survivors.clone()); + state = next; + let (next, _) = listing_complete(state, side, 29); + state = next; + } + + assert_eq!( + state.left.entries[state.left.cursor].name, + OsString::from("e11"), + "the active panel's cursor lands on the entry that followed the deleted one" + ); + assert_eq!(state.left.cursor, 10); + assert_eq!(state.left.scroll_offset, 5, "the active panel's window stays put"); + assert_eq!(state.right.entries[state.right.cursor].name, OsString::from("e20"), "the inactive panel's cursor stays on its own (surviving) entry"); + assert_eq!(state.right.cursor, 19, "e20 shifted up one row in the 29-entry list"); + assert_eq!(state.right.scroll_offset, 8, "the inactive panel's window stays put too"); +} + +#[test] +fn job_done_marks_background_tab_stale_without_eager_reread() { + // A background tab (not the active one) sitting on the deleted-from + // directory must be marked stale, not eagerly re-read — the read is + // deferred until it becomes active (file-operations "A background tab + // on the affected directory is marked stale, not eagerly re-read"). + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/left"), + dialog: RunningDialog::Progress { kind: JobKind::Delete, progress: ProgressInfo::starting(1, 1) }, + }); + state.left.cwd = PathBuf::from("/left"); + state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" + state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere; tab 0 remains at "/left" + state.right.cwd = PathBuf::from("/right"); // unaffected panel + + let (state, effects) = update( + state, + Command::JobDone { outcome: JobOutcome::Completed { skipped: vec![] }, source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/left") }, + ); + assert_eq!(state.phase, UiPhase::Panels); + assert!(without_git_info_effects(effects).is_empty(), "a background tab must not be eagerly re-read"); + assert_eq!(state.left.tabs.len(), 1); + assert!(state.left.tabs[0].stale, "the background tab on the deleted-from directory must be marked stale"); +} + +#[test] +fn listing_complete_reconciles_selection_against_fresh_entries() { + let mut state = test_state(UiPhase::Panels); + state.left.selected.insert(OsString::from("gone.txt")); + state.left.selected.insert(OsString::from("stays.txt")); + state.left.entries = vec![file_entry("stays.txt", 1)]; + let (state, _) = listing_complete(state, PanelSide::Left, 1); + assert_eq!(state.left.selected, std::collections::HashSet::from([OsString::from("stays.txt")])); +} + +#[test] +fn selection_commands_operate_on_active_panel() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + let (state, _) = update(state, Command::ToggleSelectAtCursor); + assert!(state.left.selected.contains(&OsString::from("a.txt"))); + let (state, _) = update(state, Command::InvertSelection); + assert_eq!(state.left.selected, std::collections::HashSet::from([OsString::from("b.txt")])); + let (state, _) = update(state, Command::GroupSelectAll); + assert_eq!(state.left.selected.len(), 2); + let (state, _) = update(state, Command::GroupDeselectAll); + assert!(state.left.selected.is_empty()); +} + +#[test] +fn reread_reissues_start_listing_and_clears_the_error() { + let mut state = test_state(UiPhase::Panels); + state.left.last_error = Some("boom".to_string()); + state.left.cwd = PathBuf::from("/left"); + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left")]); + assert!(state.left.last_error.is_none()); +} + +// --------------------------------------------------------------------- +// filter-selection-safety: hidden-selection disclosure (R-17; selection +// "Operations disclose selected entries hidden by the quick filter") +// --------------------------------------------------------------------- + +/// Five selected entries, two of them (`x.log`, `y.log`) hidden by the +/// `.txt` quick filter. +fn five_selected_two_hidden_state() -> State { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 1), file_entry("c.txt", 1), file_entry("x.log", 1), file_entry("y.log", 1)]; + for name in ["a.txt", "b.txt", "c.txt", "x.log", "y.log"] { + state.left.selected.insert(OsString::from(name)); + } + state.left.set_quick_filter(Some(".txt".to_string())); + state +} + +/// selection "Delete confirmation states the hidden count" (tasks.md 4.5). +#[test] +fn delete_confirm_discloses_selected_entries_hidden_by_the_filter() { + let state = five_selected_two_hidden_state(); + let (state, effects) = update(state, Command::RequestDelete); + assert!(effects.is_empty()); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, hidden_by_filter, .. }) => { + assert_eq!(sources.len(), 5); + assert_eq!(hidden_by_filter, 2, "x.log and y.log are selected but hidden by the filter"); + } + other => panic!("expected FileOpSetup::DeleteConfirm, got {other:?}"), + } + // Confirming still deletes all five (user decision: the operation + // reaches the whole selection; the dialog only discloses). + let (state, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.sources.len(), 5), + other => panic!("expected one RunJob over all five sources, got {other:?}"), + } + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); +} + +/// selection "Destination dialog states the hidden count" (tasks.md 4.5). +#[test] +fn destination_dialog_discloses_selected_entries_hidden_by_the_filter() { + let state = five_selected_two_hidden_state(); + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, ref sources, hidden_by_filter, .. }) => { + assert_eq!(kind, JobKind::Copy); + assert_eq!(sources.len(), 5); + assert_eq!(hidden_by_filter, 2); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } + // Accepting the destination copies all five. + let (_, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.sources.len(), 5), + other => panic!("expected one RunJob over all five sources, got {other:?}"), + } +} + +/// selection "No hidden selection leaves the dialogs unchanged" +/// (tasks.md 4.5). +#[test] +fn no_filter_means_no_hidden_selection_disclosure() { + let mut state = five_selected_two_hidden_state(); + state.left.quick_filter = None; + let (state, _) = update(state, Command::RequestDelete); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { hidden_by_filter, .. }) => assert_eq!(hidden_by_filter, 0), + other => panic!("expected FileOpSetup::DeleteConfirm, got {other:?}"), + } + let mut state = five_selected_two_hidden_state(); + state.left.quick_filter = None; + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { hidden_by_filter, .. }) => assert_eq!(hidden_by_filter, 0), + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +/// A drop begun on the *inactive* panel computes the count against that +/// (source) panel's own filter and selection (design D2; tasks.md 4.5). +#[test] +fn a_drop_computes_the_hidden_count_against_the_source_panel() { + let mut state = test_state(UiPhase::Panels); + state.right.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 1), file_entry("x.log", 1)]; + for name in ["a.txt", "b.txt", "x.log"] { + state.right.selected.insert(OsString::from(name)); + } + state.right.set_quick_filter(Some(".txt".to_string())); // hides x.log + state.left.entries = vec![dir_entry("dest")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Right, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("dest") }) }, + ); + let (state, _) = update(state, Command::DragDrop { op: JobKind::Copy }); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { ref sources, hidden_by_filter, .. }) => { + assert_eq!(sources.len(), 3); + assert_eq!(hidden_by_filter, 1, "the count comes from the source panel's filter and selection"); + } + other => panic!("expected the drop dialog, got {other:?}"), + } +} + +/// A right-clicked, selection-scoped file-action-menu Delete reports the +/// same count F8 does; the Enter-opened (single-target) menu reports 0 +/// (design D2; tasks.md 4.5). +#[test] +fn a_selection_scoped_menu_delete_discloses_the_hidden_count() { + let state = five_selected_two_hidden_state(); + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("a.txt") }); + assert!(state.file_action_menu().unwrap().selection_scoped, "setup precondition: right-click on a selected entry"); + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, hidden_by_filter, .. }) => { + assert_eq!(sources.len(), 5); + assert_eq!(hidden_by_filter, 2); + } + other => panic!("expected FileOpSetup::DeleteConfirm, got {other:?}"), + } +} + +#[test] +fn an_enter_opened_menu_delete_is_single_target_with_no_disclosure() { + let mut state = five_selected_two_hidden_state(); + state.left.cursor = 0; // a.txt, visible under the filter and itself selected + let (state, _) = update(state, Command::Enter); + assert!(state.file_action_menu().is_some()); + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, hidden_by_filter, .. }) => { + assert_eq!(sources.len(), 1, "the Enter-opened menu stays single-target"); + assert_eq!(hidden_by_filter, 0, "its one target is a visible entry"); + } + other => panic!("expected FileOpSetup::DeleteConfirm, got {other:?}"), + } +} + +/// A command-line `del` of an unselected entry the filter hides reports 0: +/// the typed target is not a selected entry of the panel, so the "selected +/// … hidden by filter" wording would not be true of it (design D2; +/// tasks.md 4.5). +#[test] +fn a_command_line_del_of_an_unselected_hidden_entry_reports_zero() { + let mut state = five_selected_two_hidden_state(); + state.left.entries.push(file_entry("zz.log", 1)); // hidden by the ".txt" filter, not selected + let state = type_line(state, "del zz.log"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty()); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, hidden_by_filter, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(hidden_by_filter, 0); + } + other => panic!("expected FileOpSetup::DeleteConfirm, got {other:?}"), + } +} + +// --------------------------------------------------------------------- +// file-action-menu: in-place Rename +// --------------------------------------------------------------------- + +#[test] +fn rename_pre_fills_the_current_name() { + let state = opened_menu_state(&["draft.txt"], "draft.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('R')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::RenameInput { original_name, input, .. }) => { + assert_eq!(original_name, OsString::from("draft.txt")); + assert_eq!(input.text(), "draft.txt"); + } + other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), + } +} + +#[test] +fn rename_confirm_dispatches_a_rename_job_scoped_to_the_target_in_its_own_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("draft.txt", 1)]; + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::FileActionMenuHotkey('R')); + // Clear the "draft.txt" pre-fill via Backspace, then type the new name — + // driven the same way a real edit of the input would be (file-action-menu + // "Accepting the dialog SHALL rename the entry ... with the value + // `final.txt`"). + let state = (0.."draft.txt".len()).fold(state, |s, _| update(s, Command::TextEdit(TextEditOp::Backspace)).0); + let state = "final.txt".chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::RenameInput { input, .. }) => assert_eq!(input.text(), "final.txt"), + other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), + } + let (state, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => { + assert_eq!(job.kind, JobKind::Rename); + assert_eq!(job.sources.len(), 1); + assert_eq!(job.sources[0].original_name, OsString::from("draft.txt")); + assert_eq!(job.sources[0].path, PathBuf::from(r"C:\NORTON\draft.txt")); + assert_eq!(job.source_dir, PathBuf::from(r"C:\NORTON")); + assert_eq!(job.dest_dir, PathBuf::from(r"C:\NORTON"), "the entry stays in the same directory"); + assert_eq!(job.new_dir_name, Some(OsString::from("final.txt"))); + } + other => panic!("expected exactly one RunJob effect, got {other:?}"), + } + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { kind: JobKind::Rename, .. }, .. })); +} + +/// The reducer carries a case-only edit of the pre-filled name through to +/// the job exactly as typed, without normalizing it away — the identity- +/// aware case-only rename itself happens in the job engine, once dispatched +/// (`fs_ops::worker::run_rename`; see `rename_case_only_change_succeeds_via_ +/// identity_check` in `fs_ops::worker::tests`) (file-action-menu "Case-only +/// rename works"). +#[test] +fn rename_case_only_edit_reaches_the_job_unchanged() { + let state = opened_menu_state(&["readme.md"], "readme.md"); + let (state, _) = update(state, Command::FileActionMenuHotkey('R')); + let state = (0.."readme.md".len()).fold(state, |s, _| update(s, Command::TextEdit(TextEditOp::Backspace)).0); + let state = "README.md".chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0); + let (_, effects) = update(state, Command::FileOpConfirm); + match effects.as_slice() { + [Effect::RunJob(job)] => { + assert_eq!(job.sources[0].original_name, OsString::from("readme.md")); + assert_eq!(job.new_dir_name, Some(OsString::from("README.md"))); + } + other => panic!("expected exactly one RunJob effect, got {other:?}"), + } +} + +#[test] +fn rename_esc_renames_nothing() { + let state = opened_menu_state(&["draft.txt"], "draft.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('R')); + let (state, effects) = update(state, Command::FileOpCancel); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty(), "Esc must not dispatch a job"); +} + +#[test] +fn rename_collision_and_error_surface_through_the_existing_operation_dialogs() { + // Once dispatched, a Rename job is just another `Job` running through + // the ordinary `FileOpRunning` machinery — `JobConflict`/`JobError` + // fold into `RunningDialog::Conflict`/`RunningDialog::Error` exactly as + // they do for Copy/Move/Delete, regardless of `kind` (file-action-menu + // "Rename collisions/failures must surface through the existing + // overwrite-conflict and error-recovery dialogs"). + let running = running_progress_state(JobKind::Rename, "/left", "/left"); + let (state, _) = update(running, Command::JobConflict(sample_conflict())); + assert!( + matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { kind: JobKind::Rename, .. }, .. }), + "a rename collision surfaces the existing conflict dialog, got {:?}", + state.phase + ); + + let running = running_progress_state(JobKind::Rename, "/left", "/left"); + let err = ErrorInfo { path: PathBuf::from("/left/draft.txt"), message: "access denied".to_string() }; + let (state, _) = update(running, Command::JobError(err)); + assert!( + matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { kind: JobKind::Rename, .. }, .. }), + "a rename error surfaces the existing error dialog, got {:?}", + state.phase + ); +} + +#[test] +fn rename_success_rereads_the_affected_panel() { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/left"), + dialog: RunningDialog::Progress { kind: JobKind::Rename, progress: ProgressInfo::starting(1, 0) }, + }); + state.left.cwd = PathBuf::from("/left"); + let (state, effects) = update( + state, + Command::JobDone { outcome: JobOutcome::Completed { skipped: vec![] }, source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/left") }, + ); + assert_eq!(state.phase, UiPhase::Panels); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, path, .. } if path.as_path() == std::path::Path::new("/left"))), + "the panel showing the renamed entry's directory is re-read, got {effects:?}" + ); +} + +#[test] +fn ctrl_enter_pastes_the_cursor_filename_and_ctrl_bracket_the_full_path() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("README.md", 1)]; + + let (name_state, _) = update(state.clone(), Command::PasteCursorName); + assert_eq!(name_state.command_line.text(), "README.md"); + + let (path_state, _) = update(state, Command::PasteCursorPath); + assert_eq!(path_state.command_line.text(), PathBuf::from(r"C:\NORTON").join("README.md").display().to_string()); +} + +#[test] +fn pasting_onto_a_non_empty_buffer_separates_with_a_space() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let state = type_line(state, "type"); + let (state, _) = update(state, Command::PasteCursorName); + assert_eq!(state.command_line.text(), "type a.txt"); +} + +#[test] +fn pasting_from_an_empty_panel_is_a_noop() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::PasteCursorName); + assert!(state.command_line.is_empty()); +} + +#[test] +fn ctrl_o_asks_the_tui_for_the_host_scrollback() { + let (_, effects) = update(test_state(UiPhase::Panels), Command::ShowScrollback); + assert_eq!(effects, vec![Effect::ShowScrollback]); +} + +#[test] +fn cd_navigates_the_panel_instead_of_spawning_a_shell() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/left"); + // The new synchronous existence check (command-line "cd navigates the + // active panel or rejects a nonexistent target") is satisfied here via + // the panel's own listing rather than real I/O — `sub` is a + // currently-visible directory entry. + state.left.entries = vec![dir_entry("sub")]; + let state = type_line(state, "cd sub"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); + assert!( + !effects.iter().any(|e| matches!(e, Effect::RunShellCommand(..))), + "`cd` in a fresh child would be a no-op, so it must not reach the shell" + ); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left/sub"))); + // command-line "A listed subdirectory needs no filesystem check": the + // listing answers in the Enter itself, with no probe round trip. + assert!(!effects.iter().any(|e| matches!(e, Effect::ProbePath { .. })), "the listed-entry fast path emits no probe: {effects:?}"); + assert_eq!(state.command_probe, None); + assert_eq!(state.history, vec!["cd sub".to_string()], "`cd` is still a history entry"); +} + +/// application-shell "A command-line target outside the listing produces a +/// probe request, not a disk read" and command-line "cd to a nonexistent +/// directory is rejected without navigating": the reducer emits the probe +/// and leaves the panel alone — the outcome cannot depend on the test +/// machine's disk — and the `Missing` reply then rejects the command exactly +/// as the synchronous check used to. +#[test] +fn cd_to_a_nonexistent_directory_is_rejected_without_navigating() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.last_error = Some("an older error".to_string()); + let state = type_line(state, r"cd \NOSUCHDIR"); + let (state, effects) = update(state, Command::Enter); + let expected_path = PathBuf::from(r"C:\NOSUCHDIR"); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: expected_path.clone() }], "the check is a probe request, not a disk read"); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the active panel's cwd is left completely unchanged"); + assert_eq!( + state.command_probe, + Some(PendingProbe { + request: 3, + side: PanelSide::Left, + cwd: PathBuf::from(r"C:\NORTON"), + path: expected_path.clone(), + intent: ProbeIntent::Cd { text: r"cd \NOSUCHDIR".to_string() } + }) + ); + assert_eq!(state.left.pending_note.as_deref(), Some(format!("Checking {}…", expected_path.display()).as_str())); + assert_eq!(state.left.last_error, None, "a new command supersedes an old error (design D3)"); + + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Missing }); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON")); + assert!(effects.is_empty(), "no listing read is attempted, got {effects:?}"); + let expected = format!("{} not found", expected_path.display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str()), "the panel shows an error indicating the target was not found"); + assert!(state.history.is_empty(), "a rejected cd is not recorded in history"); + assert_eq!(state.command_probe, None, "the pending entry is cleared"); + assert_eq!(state.left.pending_note, None, "and so is the note"); +} + +/// application-shell "The probe reply completes the command": a `Dir` +/// reply navigates and records history exactly as a synchronous check +/// would have. +#[test] +fn a_dir_probe_reply_navigates_and_records_history() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"cd \\nas\share"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: PathBuf::from(r"\\nas\share") }]); + assert_eq!(state.left.pending_note.as_deref(), Some(r"Checking \\nas\share…")); + + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + assert_eq!(state.left.cwd, PathBuf::from(r"\\nas\share"), "the panel navigates to the probed directory"); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, r"\\nas\share")), "{effects:?}"); + assert_eq!(state.history, vec![r"cd \\nas\share".to_string()], "the line is recorded in command history"); + assert!(effects.iter().any(|e| matches!(e, Effect::PersistHistory(f) if f.commands == state.history)), "history is persisted once: {effects:?}"); + assert_eq!(state.command_probe, None); + assert_eq!(state.left.pending_note, None); +} + +/// A `File` reply gives the same rejection the listed-entry path gives for +/// a listed file. +#[test] +fn a_file_probe_reply_rejects_cd_as_not_a_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"cd \tools\readme.txt"); + let (state, _) = update(state, Command::Enter); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON")); + let expected = format!("{} is not a directory", PathBuf::from(r"C:\tools\readme.txt").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); + assert!(state.history.is_empty()); +} + +/// application-shell "A superseded probe reply is discarded": a second `cd` +/// replaces the pending probe, so the first reply changes nothing — no +/// navigation and no error. +#[test] +fn a_probe_reply_superseded_by_a_newer_cd_is_discarded() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"cd \\slow\share"); + let (state, _) = update(state, Command::Enter); + let state = type_line(state, r"cd \\fast\share"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(effects, vec![Effect::ProbePath { request: 4, path: PathBuf::from(r"\\fast\share") }]); + assert_eq!(state.left.pending_note.as_deref(), Some(r"Checking \\fast\share…"), "the newer check owns the note"); + + let before = state.clone(); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + assert!(effects.is_empty(), "the stale reply starts nothing: {effects:?}"); + assert_eq!(state, before, "the stale reply changes nothing — the newer check is still outstanding with its note"); + + let (state, _) = update(state, Command::PathProbed { request: 4, kind: ProbeKind::Dir }); + assert_eq!(state.left.cwd, PathBuf::from(r"\\fast\share"), "the current reply still applies"); +} + +/// command-line "A late answer after navigating elsewhere is discarded": +/// the panel no longer shows the directory the command was entered for. +#[test] +fn a_probe_reply_after_the_panel_navigated_is_discarded() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![dir_entry("docs")]; + let state = type_line(state, r"cd \\slow-server\share"); + let (state, _) = update(state, Command::Enter); + assert!(state.command_probe.is_some()); + // Enter on the listed `docs` directory navigates the panel meanwhile. + let (state, _) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON\docs")); + assert_eq!(state.left.pending_note, None, "a new listing takes the note down"); + + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Missing }); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON\docs"), "the late answer does not navigate"); + assert_eq!(state.left.last_error, None, "and sets no error"); + assert_eq!(state.command_probe, None, "the stale entry is cleared"); +} + +#[test] +fn cd_to_a_file_is_rejected() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("readme.txt", 1)]; + let state = type_line(state, "cd readme.txt"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the active panel's cwd is left unchanged"); + assert!(effects.is_empty(), "no listing read is attempted, got {effects:?}"); + let expected = format!("{} is not a directory", PathBuf::from(r"C:\NORTON\readme.txt").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); +} + +#[test] +fn cd_accepts_a_manually_entered_unc_path() { + // A manually-typed UNC path resolves as absolute rather than being + // joined onto the panel's cwd (this is `dispatch_cd`'s pure path + // arithmetic, shared with `del`/`rmdir`); a fabricated share can't + // additionally satisfy the new existence check in a unit test (no real + // network I/O here), so that half is covered separately by the + // synchronous-rejection and race-scenario tests below. + assert_eq!(resolve_cd_target(Path::new(r"C:\NORTON"), r"\\server\share"), Some(PathBuf::from(r"\\server\share"))); +} + +#[test] +fn a_cd_target_that_fails_asynchronously_after_passing_the_sync_check_surfaces_the_panel_error_state() { + // Different from `cd_to_a_nonexistent_directory_is_rejected_without_ + // navigating`: this is the race where the target existed at + // synchronous-check time (so the check passed and a listing began) but + // became unreachable before the read completed — the read itself runs + // on a worker thread, and its failure comes back through the same + // event path as any other listing error (design.md "a race where a + // target existed at check time but became unreachable before the read + // completed"). + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("share")]; + let state = type_line(state, "cd share"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { .. })), "the sync check passed, so a listing began"); + let (state, _) = listing_failed(state, PanelSide::Left, "network path not found".into()); + assert_eq!(state.left.last_error.as_deref(), Some("network path not found")); +} + +/// history-persistence-hardening design D4: a successful `cd` emits exactly +/// one `Effect::PersistHistory` (the duplicate from `dispatch_cd` is gone), +/// and that one snapshot already holds the new command and the new visit. +#[test] +fn a_successful_cd_emits_exactly_one_persist_history_holding_the_command_and_the_visit() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("share")]; + state.epoch_ms = 1_787_000_000_000; + let state = type_line(state, "cd share"); + let (state, effects) = update(state, Command::Enter); + let persists: Vec<&config::HistoryFile> = effects.iter().filter_map(|e| if let Effect::PersistHistory(f) = e { Some(f) } else { None }).collect(); + assert_eq!(persists.len(), 1, "exactly one persist, got {effects:?}"); + assert_eq!(persists[0].commands, vec!["cd share".to_string()]); + assert_eq!(persists[0].directories, vec![history_entry("/left/share", 1, 1_787_000_000_000)]); + assert_eq!(persists[0].commands, state.history); + assert_eq!(persists[0].directories, state.dir_history); +} + +#[test] +fn cd_dotdot_and_bare_drive_letters_resolve() { + assert_eq!(resolve_cd_target(Path::new("/a/b"), ".."), Some(PathBuf::from("/a"))); + assert_eq!(resolve_cd_target(Path::new("/a/b"), "."), Some(PathBuf::from("/a/b"))); + assert_eq!(resolve_cd_target(Path::new("/a"), "D:"), Some(PathBuf::from(r"D:\"))); + assert_eq!(parse_cd("dir"), None); + assert_eq!(parse_cd("cd \"C:\\Program Files\" "), Some(r"C:\Program Files".to_string())); + assert_eq!(parse_cd("CD sub"), Some("sub".to_string()), "builtin verbs are case-insensitive"); +} + +// --------------------------------------------------------------------- +// Clipboard export (clipboard-export) +// --------------------------------------------------------------------- + +#[test] +fn copy_to_clipboard_uses_cursor_entry_when_nothing_explicitly_selected() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("README.md", 10)]; + let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Paths)); + assert_eq!( + effects, + vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Paths, items: vec![PathBuf::from(r"C:\NORTON\README.md")] })] + ); + assert!(state.left.clipboard_feedback.is_none(), "feedback waits for the ClipboardResult reply"); +} + +#[test] +fn copy_to_clipboard_uses_explicit_selection_over_cursor() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; + state.left.selected.insert(OsString::from("a.txt")); + state.left.selected.insert(OsString::from("c.txt")); + state.left.cursor = 1; // b.txt: not selected, must not be used + + let (_, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Files)); + let Some(Effect::SetClipboard(payload)) = effects.into_iter().next() else { panic!("expected a SetClipboard effect") }; + assert_eq!(payload.kind, ClipboardPayloadKind::Files); + let mut items = payload.items; + items.sort(); + assert_eq!(items, vec![PathBuf::from(r"C:\NORTON\a.txt"), PathBuf::from(r"C:\NORTON\c.txt")]); +} + +#[test] +fn copy_to_clipboard_on_parent_dir_with_no_selection_reports_nothing_to_copy() { + // The parent-directory pseudo-entry is never a valid clipboard source + // (clipboard-export "Parent entry is never copied"), exactly like F5. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; + state.left.cursor = 0; // ".." + let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Files)); + assert!(effects.is_empty(), "nothing is written to the clipboard"); + let feedback = state.left.clipboard_feedback.expect("the mini-status reports there is nothing to copy"); + assert_eq!(feedback.message, "Nothing to copy"); + assert!(!feedback.is_error); +} + +#[test] +fn copy_to_clipboard_with_empty_panel_reports_nothing_to_copy() { + let state = test_state(UiPhase::Panels); + let (state, effects) = update(state, Command::CopyToClipboard(ClipboardPayloadKind::Names)); + assert!(effects.is_empty()); + assert_eq!(state.left.clipboard_feedback.unwrap().message, "Nothing to copy"); +} + +#[test] +fn clipboard_result_ok_files_reports_the_plural_template_even_for_one() { + let state = test_state(UiPhase::Panels); + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; + let (state, effects) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert!(effects.is_empty()); + let feedback = state.left.clipboard_feedback.expect("success sets feedback"); + assert_eq!(feedback.message, "1 files copied to clipboard"); + assert!(!feedback.is_error); +} + +#[test] +fn clipboard_result_ok_paths_singular_names_the_path() { + let state = test_state(UiPhase::Panels); + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Paths, items: vec![PathBuf::from(r"C:\NORTON\README.md")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert_eq!(state.left.clipboard_feedback.unwrap().message, r"Path copied: C:\NORTON\README.md"); +} + +#[test] +fn clipboard_result_ok_paths_plural_counts() { + let state = test_state(UiPhase::Panels); + let payload = + ClipboardPayload { kind: ClipboardPayloadKind::Paths, items: vec![PathBuf::from(r"C:\NORTON\a.txt"), PathBuf::from(r"C:\NORTON\b.txt")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert_eq!(state.left.clipboard_feedback.unwrap().message, "2 paths copied"); +} + +#[test] +fn clipboard_result_ok_names_counts() { + let state = test_state(UiPhase::Panels); + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Names, items: vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert_eq!(state.left.clipboard_feedback.unwrap().message, "2 names copied"); +} + +#[test] +fn clipboard_result_fallback_to_paths_names_the_platform_limitation() { + let state = test_state(UiPhase::Panels); + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: true, result: Ok(()) }); + let feedback = state.left.clipboard_feedback.unwrap(); + assert_eq!(feedback.message, "Paths copied (file objects unsupported here)"); + assert!(!feedback.is_error); +} + +#[test] +fn clipboard_result_err_shows_the_message_in_the_error_role() { + let state = test_state(UiPhase::Panels); + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] }; + let (state, _) = + update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Err("Clipboard busy — try again".to_string()) }); + let feedback = state.left.clipboard_feedback.unwrap(); + assert_eq!(feedback.message, "Clipboard busy — try again"); + assert!(feedback.is_error); +} + +#[test] +fn clipboard_feedback_expires_via_tick_once_the_deadline_passes() { + let mut state = test_state(UiPhase::Panels); + state.clock_ms = 0; + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from("a.txt")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert!(state.left.clipboard_feedback.is_some()); + + // Not yet at the deadline: still showing. + let (state, _) = update(state, Command::Tick { now_ms: CLIPBOARD_FEEDBACK_MS - 1, epoch_ms: 0 }); + assert!(state.left.clipboard_feedback.is_some(), "feedback holds until the deadline is reached"); + + // At the deadline: expired. + let (state, _) = update(state, Command::Tick { now_ms: CLIPBOARD_FEEDBACK_MS, epoch_ms: 0 }); + assert!(state.left.clipboard_feedback.is_none(), "feedback expires once the clock reaches expires_at_ms"); +} + +#[test] +fn clipboard_feedback_clears_on_the_next_key_before_the_deadline() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.clock_ms = 0; + let payload = ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from("a.txt")] }; + let (state, _) = update(state, Command::ClipboardResult { payload, fell_back_to_paths: false, result: Ok(()) }); + assert!(state.left.clipboard_feedback.is_some()); + + // Any other command — e.g. Down — counts as "the next key press" and + // clears the feedback immediately, well before the ~3s deadline. + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert_eq!(state.left.cursor, 1, "the movement itself still applies"); + assert!(state.left.clipboard_feedback.is_none(), "the mini-status shows the normal display again"); +} + +#[test] +fn files_menu_clipboard_group_dispatches_copy_to_clipboard() { + assert_eq!(menu_action_command(MenuAction::ClipboardFiles, PanelSide::Left), Some(Command::CopyToClipboard(ClipboardPayloadKind::Files))); + assert_eq!(menu_action_command(MenuAction::ClipboardPaths, PanelSide::Left), Some(Command::CopyToClipboard(ClipboardPayloadKind::Paths))); + assert_eq!(menu_action_command(MenuAction::ClipboardNames, PanelSide::Left), Some(Command::CopyToClipboard(ClipboardPayloadKind::Names))); +} + +#[test] +fn files_menu_activate_on_copy_to_clipboard_runs_the_files_action() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::MenuOpen); + let (mut state, _) = update(state, Command::MenuHotkey('f')); // Files + let target = crate::menu::entries(crate::menu::MenuId::Files) + .iter() + .position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Copy to clipboard")) + .unwrap(); + state.menu_mut().unwrap().selected = target; + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none(), "activating the item closes the whole menu overlay"); + assert_eq!( + effects, + vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\a.txt")] })] + ); +} + +#[test] +fn send_to_clipboard_action_menu_entry_targets_only_the_menus_entry() { + // The menu's target (`notes.txt`) is copied even though a different + // entry is selected and the cursor sits on a third one — the action + // menu always scopes to the entry it was opened on, never the panel's + // live selection or cursor (design D3 of `file-action-menu`). + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("notes.txt", 1), file_entry("other.txt", 2)]; + state.left.selected.insert(OsString::from("other.txt")); + let state = opened_menu_state_from(state, "notes.txt"); + + let (state, effects) = update(state, Command::FileActionMenuHotkey('S')); + assert!(state.file_action_menu().is_none(), "the hotkey closes the menu"); + assert_eq!( + effects, + vec![Effect::SetClipboard(ClipboardPayload { kind: ClipboardPayloadKind::Files, items: vec![PathBuf::from(r"C:\NORTON\notes.txt")] })] + ); + assert_eq!(state.left.selected.len(), 1, "the panel's selection is untouched"); +} + +/// Like `opened_menu_state`, but starting from a caller-built `state` +/// (entries/selection already set up) instead of building a fresh one — +/// needed by tests that must control the panel's selection independently +/// of the menu's target entry. +fn opened_menu_state_from(mut state: State, cursor_on: &str) -> State { + state.left.cursor = state.left.entries.iter().position(|e| e.name == cursor_on).unwrap(); + let (state, _) = update(state, Command::Enter); + assert!(state.file_action_menu().is_some(), "setup precondition: the menu must be open"); + state +} + +// --------------------------------------------------------------------- +// Mouse drag-and-drop (mouse-panel-drag) +// --------------------------------------------------------------------- + +#[test] +fn drag_begin_on_a_selected_entry_scopes_to_the_whole_selection() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; + state.left.selected.insert(OsString::from("a.txt")); + state.left.selected.insert(OsString::from("c.txt")); + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let drag = state.drag.as_ref().expect("drag should have begun"); + let mut names: Vec<_> = drag.items.iter().map(|s| s.original_name.clone()).collect(); + names.sort(); + assert_eq!(names, vec![OsString::from("a.txt"), OsString::from("c.txt")]); + assert_eq!(drag.source, PanelSide::Left); + assert_eq!(drag.source_dir, PathBuf::from("/left")); + assert_eq!(drag.op, JobKind::Copy); + assert_eq!(drag.target, None); +} + +#[test] +fn drag_begin_on_an_unselected_entry_drags_only_that_entry_and_leaves_selection_unchanged() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.left.selected.insert(OsString::from("a.txt")); + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("b.txt"), op: JobKind::Copy }); + let drag = state.drag.as_ref().expect("drag should have begun"); + assert_eq!(drag.items.len(), 1); + assert_eq!(drag.items[0].original_name, OsString::from("b.txt")); + assert!(state.left.selected.contains(&OsString::from("a.txt")), "selection must be unchanged"); + assert!(!state.left.selected.contains(&OsString::from("b.txt"))); +} + +#[test] +fn drag_begin_never_drags_the_parent_pseudo_entry() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from(".."), op: JobKind::Copy }); + assert!(state.drag.is_none(), "the parent-directory pseudo-entry SHALL never be dragged"); +} + +#[test] +fn drag_begin_on_an_entry_that_no_longer_exists_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("gone.txt"), op: JobKind::Copy }); + assert!(state.drag.is_none()); +} + +#[test] +fn drag_over_stores_a_valid_target_and_the_recomputed_verb() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Move, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + let drag = state.drag.as_ref().expect("drag must still be in progress"); + assert_eq!(drag.op, JobKind::Move, "the verb is recomputed on every DragOver (design D2)"); + assert_eq!(drag.target, Some(DropTarget::PanelDir(PanelSide::Right))); +} + +#[test] +fn drag_over_rejects_the_items_own_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Left)) }); + assert_eq!(state.drag.as_ref().unwrap().target, None, "the items' own directory is never a valid target"); +} + +#[test] +fn drag_over_rejects_a_dragged_directory_onto_itself_or_its_own_descendant() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("sub"), op: JobKind::Copy }); + + // Onto itself: the `sub` row of the very panel it was dragged from. + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("sub") }) }, + ); + assert_eq!(state.drag.as_ref().unwrap().target, None, "a directory dropped onto itself must be invalid"); + + // Into a listing inside itself: the other panel has since navigated + // into a descendant of the dragged directory. + let mut state = state; + state.right.cwd = PathBuf::from("/left/sub/nested"); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + assert_eq!(state.drag.as_ref().unwrap().target, None, "a descendant of the dragged directory must be invalid"); +} + +#[test] +fn drag_over_rejects_info_and_quick_view_panels() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.display_mode = DisplayMode::Info; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + assert_eq!(state.drag.as_ref().unwrap().target, None, "Info-mode panels are never valid targets"); +} + +#[test] +fn drag_drop_opens_the_drop_dialog_prefilled_with_the_exact_target_path() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir(), file_entry("a.txt", 1)]; + state.right.entries = vec![Entry::parent_dir(), dir_entry("OLD")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, + ); + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(state.drag.is_none(), "the drag ends the moment the dialog opens"); + assert!(effects.is_empty(), "opening the dialog is not itself an effect"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, input, buttons, .. }) => { + assert_eq!(kind, JobKind::Copy); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("a.txt")); + assert_eq!(input.text(), PathBuf::from("/right").join("OLD").display().to_string()); + assert_eq!(buttons, Some(DropButtons { focused: JobKind::Copy })); + } + other => panic!("expected a drop-initiated DestinationInput dialog, got {other:?}"), + } +} + +#[test] +fn drag_drop_on_an_invalid_target_does_nothing() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + // No DragOver ever validated a target — `target` stays `None`. + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(matches!(state.phase, UiPhase::Panels), "no dialog opens over an invalid drop"); + assert!(effects.is_empty()); + assert!(state.drag.is_none()); +} + +#[test] +fn drag_drop_is_cancelled_when_the_source_panel_navigated_away() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + // The source panel navigates elsewhere mid-drag. + let mut state = state; + state.left.cwd = PathBuf::from("/left/elsewhere"); + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled, not just retargeted"); + assert!(effects.is_empty()); + assert!(state.drag.is_none()); +} + +/// mouse-drag "Valid drop targets" scenario "Same-panel subdirectory": a +/// subdirectory row in the *same* panel the drag started from is a valid +/// target (only the items' own directory and self/descendant drops are +/// rejected — a different sibling subdirectory of the same panel is fine). +#[test] +fn drag_over_accepts_a_subdirectory_row_in_the_same_panel_as_the_source() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir(), file_entry("notes.txt", 1), dir_entry("src")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("notes.txt"), op: JobKind::Copy }); + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("src") }) }, + ); + assert_eq!( + state.drag.as_ref().unwrap().target, + Some(DropTarget::SubDir { side: PanelSide::Left, name: OsString::from("src") }), + "a sibling subdirectory in the source's own panel must be a valid target" + ); + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert_eq!(input.text(), PathBuf::from("/left").join("src").display().to_string()); + } + other => panic!("expected the same-panel subdirectory drop dialog, got {other:?}"), + } + assert!(effects.is_empty()); +} + +/// mouse-drag "Robust against listing changes": "the target row no longer +/// resolves to a directory" — a `SubDir` target validated by an earlier +/// `DragOver` must be re-checked at `DragDrop` time, since the entry it named +/// can have changed kind (or vanished) in between. +#[test] +fn drag_drop_is_cancelled_when_the_target_row_no_longer_resolves_to_a_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.entries = vec![dir_entry("OLD")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, + ); + assert!(state.drag.as_ref().unwrap().target.is_some(), "OLD is a directory, so the target should validate"); + + // Between the last `DragOver` and the button-up, `OLD` stops being a + // directory (e.g. a concurrent re-read replaced it with a same-named + // file). + let mut state = state; + state.right.entries = vec![file_entry("OLD", 4)]; + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled when the target no longer resolves to a directory"); + assert!(effects.is_empty()); + assert!(state.drag.is_none()); +} + +/// mouse-drag "Robust against listing changes": the same re-validation also +/// cancels the drop outright when the target row disappears entirely rather +/// than merely changing kind. +#[test] +fn drag_drop_is_cancelled_when_the_target_row_has_vanished() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.entries = vec![dir_entry("OLD")]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update( + state, + Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::SubDir { side: PanelSide::Right, name: OsString::from("OLD") }) }, + ); + let mut state = state; + state.right.entries = vec![]; // OLD is gone by the time the button comes up. + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(matches!(state.phase, UiPhase::Panels), "the drop is cancelled when the target row no longer exists"); + assert!(effects.is_empty()); + assert!(state.drag.is_none()); +} + +/// mouse-drag + operation-dialogs: a Move-proposing drag never runs the job +/// directly on drop — it only ever opens the drop dialog with `[ Move ]` +/// focused, and the job starts strictly from an explicit +/// `FileOpConfirmAs`/`FileOpConfirm` on that dialog (a button click, or Enter +/// while it's focused). This is the named-test complement to the +/// `drag_proptests` invariant that no drag-lifecycle command ever emits +/// `Effect::RunJob` directly. +#[test] +fn a_move_drag_drop_opens_the_dialog_and_never_runs_the_job_directly() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + // Right-button drag (design D1): proposes Move. + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Move }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Move, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + let (state, drop_effects) = update(state, Command::DragDrop { op: JobKind::Move }); + assert!(drop_effects.is_empty(), "DragDrop must never itself emit an effect, let alone RunJob"); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, buttons, .. }) => { + assert_eq!(*kind, JobKind::Move); + assert_eq!(*buttons, Some(DropButtons { focused: JobKind::Move }), "the dialog must open with [ Move ] focused, not run yet"); + } + other => panic!("expected the drop dialog with Move focused, got {other:?}"), + } + // Only now, via the dialog's own confirm, does the job actually start. + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.kind, JobKind::Move), + other => panic!("expected exactly one RunJob(Move) from the dialog confirm, got {other:?}"), + } +} + +#[test] +fn drag_cancel_clears_the_drag_with_no_effect() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + assert!(state.drag.is_some()); + let (state, effects) = update(state, Command::DragCancel); + assert!(state.drag.is_none()); + assert!(effects.is_empty()); + assert!(matches!(state.phase, UiPhase::Panels)); +} + +/// mouse-drag "Cancel and phase-change clear the drag": "Pressing Esc during +/// a drag SHALL cancel it" (tasks.md 2.4). `input::map_panel_key` maps Esc to +/// `Command::DragCancel` while `state.drag` is `Some` (see +/// `filecommand-tui/src/input/mod.rs::map_panel_key`'s own test coverage) — +/// this closes the loop from the core side: a `DragDrop` that arrives *after* +/// the cancel (exactly what `MouseTracker` sends on the button-up that +/// follows a cancelled drag, since it only learns the drag ended once this +/// event lands) finds `state.drag` already `None` and is a pure no-op, so +/// "no dialog opens and nothing changes" holds end to end. +#[test] +fn a_drag_drop_that_arrives_after_esc_cancelled_the_drag_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + let (state, _) = update(state, Command::DragCancel); + assert!(state.drag.is_none(), "Esc must clear the drag immediately"); + + // The button-up event the TUI's `MouseTracker` still has queued arrives + // after the cancel — `resolve_drag_release` cannot know the drag was + // cancelled and sends `DragDrop` regardless (mouse-panel-drag input + // stage); core must not resurrect a dialog from it. + let (state, effects) = update(state, Command::DragDrop { op: JobKind::Copy }); + assert!(matches!(state.phase, UiPhase::Panels), "no dialog opens from a drop after Esc cancelled the drag"); + assert!(effects.is_empty()); + assert!(state.drag.is_none()); +} + +#[test] +fn keyboard_copy_dialog_still_has_no_button_row() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { buttons, .. }) => { + assert_eq!(buttons, None, "the keyboard F5 dialog has no button row"); + } + other => panic!("expected DestinationInput, got {other:?}"), + } +} + +/// operation-dialogs "Switching the verb in the dialog": clicking `[ Move ]` +/// on a dialog that opened proposing Copy starts a Move job, without first +/// needing to change which button is focused. +#[test] +fn file_op_confirm_as_overrides_the_dialogs_opened_verb() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::DragOver { op: JobKind::Copy, target: Some(DropTarget::PanelDir(PanelSide::Right)) }); + let (state, _) = update(state, Command::DragDrop { op: JobKind::Copy }); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, buttons, .. }) => { + assert_eq!(*kind, JobKind::Copy); + assert_eq!(*buttons, Some(DropButtons { focused: JobKind::Copy })); + } + other => panic!("expected drop dialog, got {other:?}"), + } + let (state, effects) = update(state, Command::FileOpConfirmAs(JobKind::Move)); + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); + match effects.as_slice() { + [Effect::RunJob(job)] => assert_eq!(job.kind, JobKind::Move), + other => panic!("expected exactly one RunJob(Move), got {other:?}"), + } +} + +/// `ButtonId::DropDialogCopy`/`DropDialogMove`/`DropDialogCancel` route +/// exactly like their keyboard equivalents via `button_command`. +#[test] +fn drop_dialog_button_ids_route_through_button_command() { + assert_eq!(button_command(ButtonId::DropDialogCopy), Some(Command::FileOpConfirmAs(JobKind::Copy))); + assert_eq!(button_command(ButtonId::DropDialogMove), Some(Command::FileOpConfirmAs(JobKind::Move))); + assert_eq!(button_command(ButtonId::DropDialogCancel), Some(Command::FileOpCancel)); +} + +#[test] +fn drag_clears_when_the_f9_menu_opens() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + assert!(state.drag.is_some()); + let (state, _) = update(state, Command::MenuOpen); + assert!(state.drag.is_none(), "opening an overlay must clear an in-progress drag"); +} + +#[test] +fn drag_clears_on_quit_request() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::RequestQuit); + assert!(state.drag.is_none()); +} + +#[test] +fn drag_clears_on_listing_failure() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = listing_failed(state, PanelSide::Right, "boom".to_string()); + assert!(state.drag.is_none()); +} + +#[test] +fn drag_clears_on_resize_below_the_minimum() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + let (state, _) = update(state, Command::Resize(10, 5)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels, "the panels survive the shrink; only the drag is cleared"); + assert!(state.drag.is_none()); +} + +mod drag_proptests { + use super::*; + use proptest::prelude::*; + + fn arb_side() -> impl Strategy { + prop_oneof![Just(PanelSide::Left), Just(PanelSide::Right)] + } + + fn arb_verb() -> impl Strategy { + prop_oneof![Just(JobKind::Copy), Just(JobKind::Move)] + } + + fn arb_name() -> impl Strategy { + prop_oneof![Just(OsString::from("a.txt")), Just(OsString::from("sub")), Just(OsString::from(".."))].prop_map(|n| n) + } + + fn arb_drop_target() -> impl Strategy { + prop_oneof![ + arb_side().prop_map(DropTarget::PanelDir), + (arb_side(), arb_name()).prop_map(|(side, name)| DropTarget::SubDir { side, name }), + (arb_side(), arb_name()).prop_map(|(side, name)| DropTarget::TreeNode { side, path: PathBuf::from(name) }), + (arb_side(), 0usize..3).prop_map(|(side, index)| DropTarget::Tab { side, index }), + ] + } + + fn arb_command() -> impl Strategy { + prop_oneof![ + (arb_side(), arb_name(), arb_verb()).prop_map(|(side, name, op)| Command::DragBegin { side, name, op }), + (arb_verb(), prop::option::of(arb_drop_target())).prop_map(|(op, target)| Command::DragOver { op, target }), + arb_verb().prop_map(|op| Command::DragDrop { op }), + Just(Command::DragCancel), + (arb_side(), arb_name()).prop_map(|(side, name)| Command::ClickEntry { side, name, mods: ClickMods::Plain }), + Just(Command::ToggleSelectAtCursor), + Just(Command::RequestCopy), + Just(Command::RequestMove), + // `ConfirmQuit`/`CancelQuit` are deliberately excluded: core + // assumes (via the TUI's mode-gating table, not enforced here) + // that they only ever arrive while `state.quit_confirm` is + // already `true` — `RequestQuit` alone already exercises the + // relevant drag-clearing path (see `drag_clears_on_quit_request` + // above). + Just(Command::RequestQuit), + Just(Command::MenuOpen), + Just(Command::MenuClose), + Just(Command::FileOpConfirm), + arb_verb().prop_map(Command::FileOpConfirmAs), + Just(Command::FileOpCancel), + // Both the seeded panels' current listing ids (1 and 2, from + // `test_state`) and a superseded one: an accepted failure clears + // the drag, a stale one is dropped — neither may break the + // invariant (listing-request-guard design D2). + (arb_side(), prop_oneof![Just(1u64), Just(2u64), Just(999u64)]).prop_map(|(panel, request)| Command::ListingFailed { + panel, + message: "boom".to_string(), + request + }), + (10u16..200, 4u16..80).prop_map(|(w, h)| Command::Resize(w, h)), + ] + } + + fn seeded_state() -> State { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir(), dir_entry("sub"), file_entry("a.txt", 1)]; + state.right.entries = vec![Entry::parent_dir(), dir_entry("sub"), file_entry("a.txt", 1)]; + state + } + + fn any_overlay_open(state: &State) -> bool { + state.menu().is_some() + || state.drive_select().is_some() + || state.fuzzy_jump().is_some() + || state.find_file().is_some() + || state.user_menu().is_some() + || state.theme_picker().is_some() + || state.file_action_menu().is_some() + || state.help().is_some() + || state.startup_warning().is_some() + || state.quit_confirm + } + + proptest! { + /// mouse-drag "Cancel and phase-change clear the drag", property- + /// tested over random command interleavings (design D5): `drag` is + /// never `Some` outside `UiPhase::Panels` with no overlay open, and + /// none of `DragBegin`/`DragOver`/`DragDrop`/`DragCancel` ever + /// directly emits `Effect::RunJob` — a job only ever starts through + /// the dialog's own `FileOpConfirm`/`FileOpConfirmAs`. + #[test] + fn drag_invariants_hold_over_random_command_interleavings(cmds in prop::collection::vec(arb_command(), 0..25)) { + let mut state = seeded_state(); + for cmd in cmds { + let is_drag_lifecycle_cmd = + matches!(cmd, Command::DragBegin { .. } | Command::DragOver { .. } | Command::DragDrop { .. } | Command::DragCancel); + let (next_state, effects) = update(state, cmd); + state = next_state; + if is_drag_lifecycle_cmd { + prop_assert!( + !effects.iter().any(|e| matches!(e, Effect::RunJob(_))), + "DragBegin/DragOver/DragDrop/DragCancel must never directly emit RunJob" + ); + } + if state.drag.is_some() { + prop_assert!(matches!(state.phase, UiPhase::Panels), "drag survived outside UiPhase::Panels"); + prop_assert!(!any_overlay_open(&state), "drag survived with an overlay open"); + } + } + } + } +} diff --git a/crates/filecommand-core/src/update/tests/listing.rs b/crates/filecommand-core/src/update/tests/listing.rs new file mode 100644 index 0000000..2346f4a --- /dev/null +++ b/crates/filecommand-core/src/update/tests/listing.rs @@ -0,0 +1,1479 @@ +use super::*; + +// --------------------------------------------------------------------- +// Stale background tab refresh on activation (refresh-inactive-panel-on- +// delete; BIG-162) +// --------------------------------------------------------------------- + +#[test] +fn switch_tab_to_stale_tab_issues_fresh_read_and_clears_the_flag() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.cwd = PathBuf::from("/left"); + // The listing had finished when the tab was left — a tab stashed + // mid-listing is stale for a second reason (listing-request-guard). + state.left.progress = crate::panel::ListingProgress::Complete { count: 0 }; + state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" + state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere + let (state, _) = listing_complete(state, PanelSide::Left, 0); + let mut state = state; + state.left.mark_background_tabs_stale(Path::new("/left")); + assert!(state.left.tabs[0].stale); + + let (state, effects) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "activated the previously-stale background tab"); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left")], + "activating a stale tab must issue a fresh read" + ); + // The fresh read finishes before the tab is left again. + let (state, _) = listing_complete(state, PanelSide::Left, 0); + + // The flag must be consumed, not left set: switching away and back + // must not trigger a second read. + let (state, _) = update(state, Command::SwitchTab(2)); + assert_eq!(state.left.cwd, PathBuf::from("/left/other")); + let (_, effects) = update(state, Command::SwitchTab(1)); + assert!(without_git_info_effects(effects).is_empty(), "the stale flag must not persist past its one consuming activation"); +} + +#[test] +fn close_tab_falling_back_to_stale_neighbor_issues_fresh_read() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.cwd = PathBuf::from("/left"); + state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" + state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere + state.left.mark_background_tabs_stale(Path::new("/left")); + + let (state, effects) = update(state, Command::CloseTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 1, "closed the active tab, falling back to the one remaining tab"); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "fell back to the previously-stale background tab"); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left")], + "falling back to a stale neighbor must issue a fresh read" + ); +} + +#[test] +fn switch_tab_to_non_stale_tab_issues_no_listing_effects() { + // panel-tabs "Switching to a tab with no pending staleness is + // unchanged": untouched by any job *and* fully listed when it was left. + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.cwd = PathBuf::from("/left"); + state.left.progress = crate::panel::ListingProgress::Complete { count: 0 }; + state.left.open_tab(); + state.left.begin_new_listing(PathBuf::from("/left/other")); + // No mark_background_tabs_stale call: tab 0 is not stale. + assert!(!state.left.tabs[0].stale, "a tab left with a complete listing is not stale"); + + let (state, effects) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left")); + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), "no listing at all is started, got {effects:?}"); + assert!(without_git_info_effects(effects).is_empty(), "switching to a tab with no pending staleness must not re-read (unchanged behavior)"); +} + +#[test] +fn stale_tab_refresh_records_no_history_and_persists_nothing() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.cwd = PathBuf::from("/left"); + state.left.open_tab(); // tab 0 stashed at "/left" (background); tab 1 (active) also starts at "/left" + state.left.begin_new_listing(PathBuf::from("/left/other")); // active tab moves elsewhere + state.left.mark_background_tabs_stale(Path::new("/left")); + let history_before = state.dir_history.clone(); + + let (state, effects) = update(state, Command::SwitchTab(1)); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. })), "the fresh read still happens"); + assert!(effects.iter().all(|e| !matches!(e, Effect::PersistHistory(_))), "a stale-activation refresh persists nothing, got {effects:?}"); + assert_eq!(state.dir_history, history_before, "a stale-activation refresh records no fuzzy-jump visit — the user switched tabs, not directories"); +} + +#[test] +fn close_tab_stale_neighbor_refresh_records_no_history_and_persists_nothing() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.cwd = PathBuf::from("/left"); + state.left.open_tab(); + state.left.begin_new_listing(PathBuf::from("/left/other")); + state.left.mark_background_tabs_stale(Path::new("/left")); + let history_before = state.dir_history.clone(); + + let (state, effects) = update(state, Command::CloseTab(PanelSide::Left)); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "fell back to the stale neighbor"); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. })), "the fresh read still happens"); + assert!(effects.iter().all(|e| !matches!(e, Effect::PersistHistory(_))), "a stale-activation refresh persists nothing, got {effects:?}"); + assert_eq!(state.dir_history, history_before, "a stale-activation refresh records no fuzzy-jump visit"); +} + +#[test] +fn history_up_recalls_previous_commands_newest_first() { + let mut state = test_state(UiPhase::Panels); + state.history = vec!["first".to_string(), "second".to_string(), "third".to_string()]; + let state = type_line(state, "x"); + + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "third"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "second"); + let (state, _) = update(state, Command::CommandLineHistoryNext); + assert_eq!(state.command_line.text(), "third"); +} + +#[test] +fn history_up_stops_at_the_oldest_entry() { + let mut state = test_state(UiPhase::Panels); + state.history = vec!["only".to_string()]; + let state = type_line(state, "x"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "only"); +} + +#[test] +fn history_navigation_never_moves_the_panel_cursor() { + let mut state = test_state(UiPhase::Panels); + state.history = vec!["dir".to_string()]; + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + state.left.cursor = 1; + let state = type_line(state, "x"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.left.cursor, 1); +} + +#[test] +fn history_recall_with_empty_history_is_a_noop() { + let state = type_line(test_state(UiPhase::Panels), "x"); + let (state, effects) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "x"); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// M5: git_info — generation-key staleness guard (design D3; git-info +// "Query re-issued on navigation", "Silent absence on timeout and +// stale-result discarding") +// --------------------------------------------------------------------- + +#[test] +fn initial_state_issues_a_git_info_query_for_each_panel() { + let (state, effects) = State::initial(Theme::classic(), (80, 24), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), false); + let left_request = state.left.git_request.expect("left panel mints a git-info request on startup"); + let right_request = state.right.git_request.expect("right panel mints a git-info request on startup"); + assert_ne!(left_request, right_request); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/l"), request: left_request })); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Right, path: PathBuf::from("/r"), request: right_request })); +} + +#[test] +fn navigating_reissues_the_git_info_query_and_clears_the_previous_directorys_info() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + state.left.git_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); + assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "the previous directory's git info is cleared, not carried over"); + let request = state.left.git_request.expect("navigating mints a fresh git-info request"); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/left/sub"), request })); +} + +#[test] +fn a_reread_mints_a_fresh_git_info_request_even_for_the_same_path() { + let state = test_state(UiPhase::Panels); + let first_request = state.left.git_request; + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + let second_request = state.left.git_request.expect("re-reading mints a request id"); + assert_ne!(Some(second_request), first_request); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: PathBuf::from("/left"), request: second_request })); +} + +#[test] +fn a_resolved_git_info_result_is_applied_in_place() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let request = state.left.git_request.unwrap(); + let info = crate::git_info::GitInfo { + branch: Some("main".to_string()), + statuses: std::collections::HashMap::from([(OsString::from("a.txt"), crate::git_info::FileStatus::Modified)]), + }; + let (state, effects) = update( + state, + Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, info: info.clone(), timed_out_root: None }, + ); + assert!(effects.is_empty()); + assert_eq!(state.left.git_info, info); +} + +#[test] +fn a_git_info_result_for_a_directory_the_panel_left_is_discarded() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let request = state.left.git_request.unwrap(); + let info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; + let (state, _) = + update(state, Command::GitInfoResolved { panel: PanelSide::Left, path: PathBuf::from("/elsewhere"), request, info, timed_out_root: None }); + assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "a result for another directory is dropped"); +} + +#[test] +fn a_stale_git_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies() { + // Mirrors `a_stale_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies`: + // two RereadPanel commands for the same path mint two different + // request ids, so `path` equality alone can't tell the stale reply + // apart from the current one — this is also how a timed-out query's + // late reply is safely dropped (git-info "Silent absence on timeout + // and stale-result discarding"). + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let first_request = state.left.git_request.unwrap(); + + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let second_request = state.left.git_request.unwrap(); + assert_ne!(first_request, second_request, "re-reading mints a fresh request id even for the same path"); + + let stale_info = crate::git_info::GitInfo { branch: Some("stale-branch".to_string()), ..Default::default() }; + let (state, _) = update( + state, + Command::GitInfoResolved { + panel: PanelSide::Left, + path: PathBuf::from("/left"), + request: first_request, + info: stale_info, + timed_out_root: None, + }, + ); + assert_eq!(state.left.git_info, crate::git_info::GitInfo::none(), "the stale (first-request) answer is dropped"); + + let fresh_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; + let (state, _) = update( + state, + Command::GitInfoResolved { + panel: PanelSide::Left, + path: PathBuf::from("/left"), + request: second_request, + info: fresh_info.clone(), + timed_out_root: None, + }, + ); + assert_eq!(state.left.git_info, fresh_info, "the current request's answer applies"); +} + +#[test] +fn a_timed_out_query_answered_late_with_no_info_is_silently_dropped_once_superseded() { + // A worker-thread timeout is, from the reducer's point of view, just + // another reply — it degrades to `GitInfo::none()` — so it goes + // through the exact same generation-key guard as any other late + // reply: once a fresher request is outstanding, the timed-out query's + // eventual answer (of any content) must not clobber it. + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let timed_out_request = state.left.git_request.unwrap(); + + let (mut state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let current_request = state.left.git_request.unwrap(); + state.left.git_info = crate::git_info::GitInfo { branch: Some("main".to_string()), ..Default::default() }; + + let (state, _) = update( + state, + Command::GitInfoResolved { + panel: PanelSide::Left, + path: PathBuf::from("/left"), + request: timed_out_request, + info: GitInfo::none(), + timed_out_root: None, + }, + ); + assert_eq!( + state.left.git_info.branch.as_deref(), + Some("main"), + "the timed-out query's late reply must not overwrite the current request's already-applied result" + ); + let _ = current_request; +} + +// --------------------------------------------------------------------- +// git-info-timeout-memory: a timed-out query marks its repository "no +// info" for the rest of the session (git-info "Silent absence on timeout +// and stale-result discarding"; design D1) +// --------------------------------------------------------------------- + +/// The spec scenarios spell their repositories with Windows paths +/// (`D:\mono`); the same shapes are spelled `/mono` on Unix so the +/// component-wise prefix checks exercise identically on both. +#[cfg(windows)] +fn mono_path(sub: &str) -> PathBuf { + let mut p = PathBuf::from(r"D:\mono"); + if !sub.is_empty() { + p.push(sub); + } + p +} + +/// Unix spelling of the spec scenarios' repository (see above). +#[cfg(not(windows))] +fn mono_path(sub: &str) -> PathBuf { + let mut p = PathBuf::from("/mono"); + if !sub.is_empty() { + p.push(sub); + } + p +} + +/// A directory inside a *different* repository (`C:\work`, or `/work`). +#[cfg(windows)] +fn work_path(sub: &str) -> PathBuf { + let mut p = PathBuf::from(r"C:\work"); + if !sub.is_empty() { + p.push(sub); + } + p +} + +/// Unix spelling of the other repository (see above). +#[cfg(not(windows))] +fn work_path(sub: &str) -> PathBuf { + let mut p = PathBuf::from("/work"); + if !sub.is_empty() { + p.push(sub); + } + p +} + +/// Feed a timed-out reply for `path` with root `root` — the worker's report +/// when a query exceeded its timeout (design D2). +fn report_timeout(state: State, panel: PanelSide, path: PathBuf, root: PathBuf) -> State { + let (state, effects) = update(state, Command::GitInfoResolved { panel, path, request: 1, info: GitInfo::none(), timed_out_root: Some(root) }); + assert!(effects.is_empty(), "recording a timeout emits no effects"); + state +} + +#[test] +fn a_timed_out_repository_is_not_queried_again_for_the_session() { + // git-info "Timed-out repository is not queried again" + the narrowed + // "Query re-issued on navigation": once the query for `D:\mono\src` + // times out, navigating to `D:\mono\docs` and re-reading there issue no + // git query and leave the panel without git info. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = mono_path("src"); + let mut state = report_timeout(state, PanelSide::Left, mono_path("src"), mono_path("")); + assert_eq!(state.git_no_info_roots, vec![mono_path("")]); + + // Navigation into another directory of the same repository ... + state.left.cwd = mono_path(""); + state.left.entries = vec![dir_entry("docs")]; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, mono_path("docs")); + assert!(!effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { .. })), "no query under a remembered root: {effects:?}"); + assert_eq!(state.left.git_info, GitInfo::none()); + assert_eq!(state.left.git_request, None, "no request is minted for a suppressed query"); + + // ... and a re-read there neither. + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + assert!(!effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { .. })), "no query on re-read either: {effects:?}"); + assert_eq!(state.left.git_info, GitInfo::none()); +} + +#[test] +fn other_repositories_are_still_queried_after_a_timeout_elsewhere() { + // git-info "Other repositories are still queried". + let mut state = test_state(UiPhase::Panels); + state.left.cwd = mono_path("src"); + let mut state = report_timeout(state, PanelSide::Left, mono_path("src"), mono_path("")); + + state.left.cwd = work_path(""); + state.left.entries = vec![dir_entry("app")]; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, work_path("app")); + let request = state.left.git_request.expect("a query is minted for the other repository"); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: work_path("app"), request })); +} + +#[test] +fn a_stale_timeout_reply_still_records_the_root() { + // git-info "Timeout recorded after the panel moved on" + "Late result + // after navigation is dropped": the panel has already left `D:\mono\src` + // when the timeout reply arrives; the root is recorded anyway and the + // current directory's rendering state is untouched. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = work_path("app"); + state.left.git_info = GitInfo { branch: Some("main".to_string()), ..Default::default() }; + state.left.git_request = Some(9); // the fresher, still-outstanding request + let (state, effects) = update( + state, + Command::GitInfoResolved { + panel: PanelSide::Left, + path: mono_path("src"), + request: 4, // the superseded, timed-out request + info: GitInfo::none(), + timed_out_root: Some(mono_path("")), + }, + ); + assert!(effects.is_empty()); + assert_eq!(state.git_no_info_roots, vec![mono_path("")], "the root is recorded even though the reply is stale"); + assert_eq!(state.left.git_info.branch.as_deref(), Some("main"), "the current panel is unchanged"); + assert_eq!(state.left.git_request, Some(9)); + + // And the recorded root has teeth: a later navigation into it starts no + // query. + let mut state = state; + state.left.cwd = mono_path(""); + state.left.entries = vec![dir_entry("docs")]; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, mono_path("docs")); + assert!(!effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { .. })), "{effects:?}"); +} + +#[test] +fn a_timeout_before_the_repository_was_located_marks_only_the_queried_directory() { + // git-info "Repository not located before the timeout": the queried + // directory itself is the remembered root, so its descendants are + // covered but its parent (and siblings) are not. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = work_path("proj"); + let mut state = report_timeout(state, PanelSide::Left, work_path("proj"), work_path("proj")); + + // A descendant of the queried directory: no query. + state.left.entries = vec![dir_entry("sub")]; + let (mut state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, work_path("proj/sub")); + assert!(!effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { .. })), "{effects:?}"); + + // The parent directory is outside the remembered root: still queried. + state.left.cwd = work_path("proj"); + state.left.entries = vec![Entry::parent_dir()]; + state.left.cursor = 0; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, work_path("")); + let request = state.left.git_request.expect("the parent is still queried"); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Left, path: work_path(""), request })); +} + +#[test] +fn swapping_a_panel_into_a_timed_out_repository_issues_no_query_and_clears_its_info() { + // git-info "Swapping a panel into a timed-out repository": the right + // panel resolved branch info inside `D:\mono` before the timeout; + // Ctrl+U must not let it keep showing that info, and must not start a + // query for it. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = work_path("app"); + state.right.cwd = mono_path("lib"); + state.right.git_info = GitInfo { branch: Some("main".to_string()), ..Default::default() }; + state.right.git_request = Some(12); + complete_panel_listings(&mut state); + let state = report_timeout(state, PanelSide::Left, mono_path("src"), mono_path("")); + + let (state, effects) = update(state, Command::SwapPanels); + // The panel now showing `D:\mono\lib` (the left panel after the swap) + // gets no query and shows no git info ... + assert_eq!(state.left.cwd, mono_path("lib")); + assert_eq!(state.left.git_info, GitInfo::none(), "branch info resolved before the timeout is cleared"); + assert_eq!(state.left.git_request, None); + assert!( + !effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { panel, path, .. } if *panel == PanelSide::Left || *path == mono_path("lib"))), + "no query for the panel inside the timed-out repository: {effects:?}" + ); + // ... while the other panel, outside it, is queried as usual. + let request = state.right.git_request.expect("the other panel is re-queried after the swap"); + assert!(effects.contains(&Effect::QueryGitInfo { panel: PanelSide::Right, path: work_path("app"), request })); +} + +#[test] +fn a_second_timeout_at_a_covered_root_is_not_recorded_twice() { + // Two panels can have a query in flight in the same repository; both + // may time out. The set stays deduplicated. + let state = test_state(UiPhase::Panels); + let state = report_timeout(state, PanelSide::Left, mono_path("src"), mono_path("")); + let state = report_timeout(state, PanelSide::Right, mono_path("docs"), mono_path("")); + assert_eq!(state.git_no_info_roots, vec![mono_path("")]); + let state = report_timeout(state, PanelSide::Right, mono_path("docs"), mono_path("docs")); + assert_eq!(state.git_no_info_roots, vec![mono_path("")], "a root under a remembered root is already covered"); +} + +// --------------------------------------------------------------------- +// M5: directory frecency recording + persistence (fuzzy-jump "Navigation +// records history") +// --------------------------------------------------------------------- + +#[test] +fn navigating_into_a_directory_records_and_persists_frecency() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + // Visits are stamped with the wall-clock reading (history-persistence- + // hardening design D1), not the monotonic one. + state.epoch_ms = 5_000; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.dir_history, vec![history_entry("/left/sub", 1, 5_000)]); + assert!(effects.iter().any(|e| matches!(e, Effect::PersistHistory(file) if file.directories == state.dir_history))); +} + +#[test] +fn revisiting_a_directory_increments_its_frecency_entry_rather_than_duplicating() { + let mut state = test_state(UiPhase::Panels); + state.dir_history = vec![history_entry(r"C:\a", 1, 0)]; + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); // /left, a fresh entry + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); // /left again + let count = state.dir_history.iter().find(|e| e.path.as_path() == Path::new("/left")).unwrap().visit_count; + assert_eq!(count, 2, "re-reading the same directory increments the same entry rather than adding a duplicate"); +} + +#[test] +fn tree_cursor_preview_of_the_opposite_panel_does_not_record_frecency() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + let (state, _) = + update(state, Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }); + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert!(state.dir_history.is_empty(), "browsing the tree previews the opposite panel; it does not count as a visit"); +} + +// --------------------------------------------------------------------- +// Panel-scrolling: reducer-level viewport reconciliation (task 1.5) +// +// `panel::tests::scroll_offset_tests` exercises `ensure_cursor_visible` +// directly at fixed row counts; these tests instead drive the reducer end +// to end so each wiring site in `update.rs` (task 1.4) is proven to +// actually call it, at term sizes chosen so `panel_viewport_rows` produces +// a known, hand-verifiable row count (visible here via `super::*`, the same +// private helper `update`'s own reducer arms use). +// --------------------------------------------------------------------- + +#[test] +fn full_mode_viewport_rows_at_80x24_are_19() { + // Every test below is built on this figure: panels_h = 24 - 2 = 22, no + // tab strip at 1 tab (reserved = 2), body_h = 20, Full/Tree = body_h - + // 1 (header row) = 19. + assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Full, 1), 19); +} + +#[test] +fn quick_filter_narrowing_re_clamps_the_offset_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + state.left.cursor = 25; // "e25" -- one of only three entries containing "5" + state.left.scroll_offset = 15; // a stale offset from before filtering narrowed the list + + let (state, _) = update(state, Command::QuickFilterStart(PanelSide::Left)); + let (state, _) = update(state, Command::QuickFilterChar('5')); + + let visible: Vec = state.left.visible_indices().into_iter().map(|i| state.left.entries[i].name.to_string_lossy().into_owned()).collect(); + assert_eq!(visible, vec!["e5", "e15", "e25"], "only entries containing \"5\" remain"); + assert_eq!(state.left.cursor, 25, "\"e25\" stays selected — it's still visible under the filter"); + assert_eq!(state.left.scroll_offset, 2, "\"e25\" is now at visible position 2; the stale offset (15) must re-clamp to it (panel-navigation \"Quick-filter narrowing re-clamps the offset\")"); +} + +#[test] +fn re_sort_re_clamps_the_offset_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + // Descending by size, so "e00" (the largest) starts at position 0. + state.left.entries = (0..30).map(|i| file_entry(&format!("e{i:02}"), 29 - i as u64)).collect(); + state.left.cursor = 0; // "e00" + state.left.scroll_offset = 0; + + let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); + + assert_eq!(state.left.entries.last().unwrap().name, OsString::from("e00"), "ascending-by-size moves the largest entry to the very end"); + assert_eq!(state.left.cursor, 29, "the cursor re-anchors onto \"e00\"'s new position"); + assert_eq!(state.left.scroll_offset, 11, "29 + 1 - 19 = 11 (panel-navigation \"Re-sort keeps the cursor's entry in view\")"); +} + +#[test] +fn terminal_shrinking_re_clamps_the_offset_on_resize() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + state.left.cursor = 18; + state.left.scroll_offset = 0; + + let (state, _) = update(state, Command::Resize(80, 24)); + assert_eq!(state.left.scroll_offset, 0, "no shrink yet: the cursor is already inside the 19-row window"); + + // 80x14: panels_h = 12, no tab strip, body_h = 10, Full rows = 9. + assert_eq!(panel_viewport_rows((80, 14), DisplayMode::Full, 1), 9); + let (state, _) = update(state, Command::Resize(80, 14)); + assert_eq!(state.left.scroll_offset, 10, "18 + 1 - 9 = 10 (panel-navigation \"Terminal resize re-clamps\")"); +} + +#[test] +fn tab_restore_re_clamps_against_the_current_viewport_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + state.left.cursor = 18; + state.left.scroll_offset = 10; // valid at 19 rows: window [10, 29) + // The listing had finished before the tab was stashed: a tab stashed + // mid-listing is stale and re-read on activation (listing-request-guard), + // which would reset the cursor this test is about. + state.left.progress = crate::panel::ListingProgress::Complete { count: 30 }; + + // Stash this as tab 1; the new (now-active) tab inherits the same + // cursor/offset and is left untouched from here on. + let (state, _) = update(state, Command::OpenTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 2); + + // Shrink drastically enough that the stashed tab's offset (10) will no + // longer keep its cursor (18) in view once restored. + let (state, _) = update(state, Command::Resize(80, 10)); + // 80x10, 2 tabs: panels_h = 8, tab strip visible (reserved = 3), + // body_h = 5, Full rows = 4. + assert_eq!(panel_viewport_rows((80, 10), DisplayMode::Full, 2), 4); + + let (state, _) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "tab 1 is the originally-stashed tab"); + assert_eq!(state.left.cursor, 18, "the restored cursor position round-trips exactly"); + assert_eq!( + state.left.scroll_offset, 15, + "the stashed offset (10) no longer fits a 4-row window around cursor 18; 18 + 1 - 4 = 15 (panel-navigation \"Tab restore re-clamps against the current viewport\")" + ); +} + +#[test] +fn streamed_chunk_keeps_the_offset_pinned_to_zero_via_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Name; + let entries: Vec = (0..30).map(|i| file_entry(&format!("e{i:02}"), 0)).collect(); + + let (state, _) = listing_chunk(state, PanelSide::Left, entries); + + assert_eq!(state.left.cursor, 0, "the cursor stays pinned to the top while the user hasn't moved it"); + assert_eq!( + state.left.scroll_offset, 0, + "the window stays pinned to the top right along with the cursor (panel-navigation \"Streamed listing keeps the top pinned\")" + ); +} + +#[test] +fn find_file_settle_on_listing_complete_lands_the_cursor_in_view() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + state.left.pending_cursor_target = Some(OsString::from("e25")); + let entries: Vec = (0..30).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + + let (state, _) = listing_chunk(state, PanelSide::Left, entries); + let (state, _) = listing_complete(state, PanelSide::Left, 30); + + assert_eq!( + state.left.entries[state.left.cursor].name, + OsString::from("e25"), + "find-file's deferred settle lands the cursor on the matched entry" + ); + assert_eq!(state.left.cursor, 25); + assert_eq!( + state.left.scroll_offset, 7, + "25 + 1 - 19 = 7: the settled cursor lands inside the window (panel-navigation \"Scroll offset is core panel state\" -- find-file's deferred cursor settle re-clamps)" + ); +} + +// --------------------------------------------------------------------- +// Same-directory re-read preserves cursor and scroll position +// (reread-preserves-cursor; panel-navigation "Same-directory re-read +// preserves cursor and scroll position") +// --------------------------------------------------------------------- + +/// A panel mid-delete-job browsing `dir` with `names` as its completed +/// listing, mirroring `running_progress_state` but with the opposite panel +/// parked on an unaffected directory so only this one re-reads. +fn deleting_state(dir: &str, names: &[String], cursor: usize, scroll_offset: usize) -> State { + let mut state = running_progress_state(JobKind::Delete, dir, dir); + state.term_size = (80, 25); // Full-mode body: 20 rows (25 - 2 panels frame - 2 reserved - 1 header) + assert_eq!(panel_viewport_rows((80, 25), DisplayMode::Full, 1), 20); + state.right.cwd = PathBuf::from("/right"); // unaffected panel: must not be re-read + let left = &mut state.left; + left.cwd = PathBuf::from(dir); + left.sort_mode = SortMode::Unsorted; + left.entries = names.iter().map(|n| file_entry(n, 0)).collect(); + left.progress = crate::panel::ListingProgress::Complete { count: names.len() }; + left.cursor = cursor; + left.cursor_user_moved = true; + left.scroll_offset = scroll_offset; + state +} + +/// Finish the delete: `JobDone`, then stream the surviving `survivors` back +/// as the automatic re-read (single chunk + completion). +fn finish_delete(state: State, dir: &str, survivors: &[String]) -> State { + let (state, effects) = update( + state, + Command::JobDone { outcome: JobOutcome::Completed { skipped: vec![] }, source_dir: PathBuf::from(dir), dest_dir: PathBuf::from(dir) }, + ); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, dir)], "the deleted-from panel re-reads"); + let entries: Vec = survivors.iter().map(|n| file_entry(n, 0)).collect(); + let total = entries.len(); + let (state, _) = listing_chunk(state, PanelSide::Left, entries); + let (state, _) = listing_complete(state, PanelSide::Left, total); + state +} + +fn names_of(prefix: &str, n: usize) -> Vec { + (0..n).map(|i| format!("{prefix}{i:02}")).collect() +} + +#[test] +fn job_done_delete_of_the_cursored_entry_lands_on_the_following_entry() { + // panel-navigation "Deleting the cursored file lands on the next entry"; + // file-operations "Post-delete re-read keeps the user's place". + let names: Vec = ["a.txt", "b.txt", "c.txt"].iter().map(|s| s.to_string()).collect(); + let state = deleting_state("/left", &names, 1, 0); // cursor on "b.txt" + let survivors: Vec = ["a.txt", "c.txt"].iter().map(|s| s.to_string()).collect(); + + let state = finish_delete(state, "/left", &survivors); + + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("c.txt"), "the cursor lands on the entry that followed the deleted one"); + assert_eq!(state.left.cursor, 1); + assert_eq!(state.left.scroll_offset, 0, "the scroll offset is unchanged"); + assert!(state.left.reread_anchor.is_none(), "the anchor is consumed on completion"); +} + +#[test] +fn job_done_delete_of_the_last_entry_lands_on_the_previous_one() { + // panel-navigation "Deleting the last entry lands on the previous one". + let names: Vec = ["a.txt", "b.txt", "c.txt"].iter().map(|s| s.to_string()).collect(); + let state = deleting_state("/left", &names, 2, 0); // cursor on "c.txt", the last entry + let survivors: Vec = ["a.txt", "b.txt"].iter().map(|s| s.to_string()).collect(); + + let state = finish_delete(state, "/left", &survivors); + + assert_eq!( + state.left.entries[state.left.cursor].name, + OsString::from("b.txt"), + "the cursor lands on the entry that preceded the deleted one, now the last" + ); + assert_eq!(state.left.cursor, 1); +} + +#[test] +fn job_done_multi_selection_delete_lands_on_the_first_survivor_at_or_after_the_cursor() { + // panel-navigation "Multi-selection delete lands on the first survivor + // at or after the cursor": {b, c, d} selected with the cursor on c. + let names: Vec = ["a", "b", "c", "d", "e"].iter().map(|s| s.to_string()).collect(); + let mut state = deleting_state("/left", &names, 2, 0); // cursor on "c" + for n in ["b", "c", "d"] { + state.left.selected.insert(OsString::from(n)); + } + let survivors: Vec = ["a", "e"].iter().map(|s| s.to_string()).collect(); + + let state = finish_delete(state, "/left", &survivors); + + assert_eq!( + state.left.entries[state.left.cursor].name, + OsString::from("e"), + "b, c, and d are all gone; the first survivor at or after the cursor's old row is e" + ); +} + +#[test] +fn job_done_delete_in_a_scrolled_window_keeps_the_window_where_it_was() { + // panel-navigation "Scrolled window stays put": 60 entries in a 20-row + // body, offset 30, cursor at position 40. + let names = names_of("e", 60); + let state = deleting_state("/left", &names, 40, 30); + let survivors = names_of("e", 60).into_iter().filter(|n| n != "e40").collect::>(); + + let state = finish_delete(state, "/left", &survivors); + + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("e41"), "the cursor lands on the entry that followed the deleted one"); + assert_eq!(state.left.cursor, 40, "which is back at position 40 in the 59-entry list"); + assert_eq!(state.left.scroll_offset, 30, "the scrolled window stays put"); +} + +#[test] +fn job_done_delete_at_the_very_bottom_pulls_the_window_up_by_one() { + // panel-navigation "Deleting at the very bottom pulls the window up by + // one" and "Re-read anchor restore re-clamps against the shorter list". + let names = names_of("e", 60); + let state = deleting_state("/left", &names, 59, 40); // cursor on the last entry + let survivors = names_of("e", 59); + + let state = finish_delete(state, "/left", &survivors); + + assert_eq!(state.left.entries.len(), 59); + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("e58"), "the cursor lands on the new last entry"); + assert_eq!(state.left.cursor, 58); + assert_eq!(state.left.scroll_offset, 39, "the offset re-clamps to 59 - 20 so no blank trailing row shows"); +} + +#[test] +fn reread_panel_keeps_the_cursors_entry_and_offset() { + // panel-navigation "Ctrl+R keeps the cursor's entry". + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + let mut names = names_of("e", 30); + names[15] = "report.txt".to_string(); + state.left.entries = names.iter().map(|n| file_entry(n, 0)).collect(); + state.left.progress = crate::panel::ListingProgress::Complete { count: 30 }; + state.left.cursor = 15; // "report.txt", partway down + state.left.cursor_user_moved = true; + state.left.scroll_offset = 5; + + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left")]); + assert!(state.left.reread_anchor.is_some(), "a same-directory re-read captures the anchor before resetting"); + + // The unchanged directory streams back. + let entries: Vec = names.iter().map(|n| file_entry(n, 0)).collect(); + let (state, _) = listing_chunk(state, PanelSide::Left, entries); + let (state, _) = listing_complete(state, PanelSide::Left, 30); + + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("report.txt")); + assert_eq!(state.left.cursor, 15); + assert_eq!(state.left.scroll_offset, 5, "the scroll offset is unchanged"); +} + +#[test] +fn the_first_chunk_containing_the_anchored_entry_already_places_the_cursor_on_it() { + // panel-navigation "No jump while the re-read streams". + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Full-mode body: 19 rows + state.left.sort_mode = SortMode::Unsorted; + let names = names_of("e", 30); + state.left.entries = names.iter().map(|n| file_entry(n, 0)).collect(); + state.left.progress = crate::panel::ListingProgress::Complete { count: 30 }; + state.left.cursor = 12; // "e12" + state.left.cursor_user_moved = true; + state.left.scroll_offset = 4; + + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + + // First chunk: e00..e14 — it contains the anchored "e12". + let first: Vec = names[..15].iter().map(|n| file_entry(n, 0)).collect(); + let (state, _) = listing_chunk(state, PanelSide::Left, first); + assert_eq!( + state.left.entries[state.left.cursor].name, + OsString::from("e12"), + "the cursor is on the anchored entry as soon as its chunk lands, not on the first entry" + ); + assert_ne!(state.left.cursor, 0); + + // The rest of the listing must not move it again. + let rest: Vec = names[15..].iter().map(|n| file_entry(n, 0)).collect(); + let (state, _) = listing_chunk(state, PanelSide::Left, rest); + let (state, _) = listing_complete(state, PanelSide::Left, 30); + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("e12")); + assert_eq!(state.left.cursor, 12); + assert_eq!(state.left.scroll_offset, 4, "the offset is restored once the whole list is back"); +} + +#[test] +fn navigating_into_a_subdirectory_after_a_reread_still_starts_at_the_top() { + // panel-navigation "Navigating to a different directory still starts at + // the top". + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); + state.left.sort_mode = SortMode::Unsorted; + let mut entries: Vec = names_of("e", 30).iter().map(|n| file_entry(n, 0)).collect(); + entries.push(dir_entry("sub")); + state.left.entries = entries; + state.left.progress = crate::panel::ListingProgress::Complete { count: 31 }; + state.left.cursor = 20; + state.left.cursor_user_moved = true; + state.left.scroll_offset = 10; + + // A re-read happens first (and its anchor is consumed on completion)... + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let mut again: Vec = names_of("e", 30).iter().map(|n| file_entry(n, 0)).collect(); + again.push(dir_entry("sub")); + let (state, _) = listing_chunk(state, PanelSide::Left, again); + let (state, _) = listing_complete(state, PanelSide::Left, 31); + assert_eq!(state.left.scroll_offset, 10, "the re-read kept the user's place"); + + // ...then Enter on "sub" navigates away: cursor and offset reset, and no + // anchor rides along into the new directory. + let mut state = state; + state.left.cursor = state.left.entries.iter().position(|e| e.name == "sub").unwrap(); + let (state, _) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); + assert_eq!(state.left.cursor, 0); + assert_eq!(state.left.scroll_offset, 0); + assert!(state.left.reread_anchor.is_none(), "navigation to a different directory clears any anchor"); + + // The streamed fresh listing stays pinned to the top. + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("x", 1), file_entry("y", 2)]); + assert_eq!(state.left.cursor, 0); + assert_eq!(state.left.scroll_offset, 0); +} + +#[test] +fn reread_issued_while_the_previous_listing_is_still_streaming_captures_no_anchor() { + // reread-preserves-cursor design D5: anchoring to a half-loaded list is + // worse than keeping the old jump-to-top behavior. + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = names_of("e", 30).iter().map(|n| file_entry(n, 0)).collect(); + state.left.progress = crate::panel::ListingProgress::Streaming { count: 30 }; // still streaming + state.left.cursor = 20; + state.left.cursor_user_moved = true; + state.left.scroll_offset = 10; + + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + assert!(state.left.reread_anchor.is_none(), "no anchor is captured from an incomplete listing"); + assert_eq!(state.left.cursor, 0, "the re-read resets exactly as it always has"); + assert_eq!(state.left.scroll_offset, 0); + + // ...and the fresh stream stays pinned to the top, as before. + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("a", 1), file_entry("b", 2)]); + assert_eq!(state.left.cursor, 0); + assert_eq!(state.left.scroll_offset, 0); +} + +// --------------------------------------------------------------------- +// Panel-scrolling: Brief column-window and Tree reconciliation (task 2.3) +// +// These drive the reducer end to end, proving `reconcile_panel_viewport`'s +// Brief/Tree branches (task 2.1/2.2) are actually wired into +// `Command::MoveCursor` and `Command::TreeNodeExpanded`, on top of +// `panel::tests::brief_scroll_tests`/`tree_scroll_tests`'s pure coverage of +// the underlying clamp math. +// --------------------------------------------------------------------- + +#[test] +fn brief_mode_interior_width_and_column_count_at_80x24_default_split() { + // split 50/50 at width 80 -> left_w = 40, interior = 38 -> 3 columns + // (byte-identical to `filecommand-tui::views::panel::render_brief_body`'s + // `(inner_w / 12).max(1)`). + let interior = panel_interior_width((80, 24), panel_split::DEFAULT_SPLIT_PERCENT, PanelSide::Left); + assert_eq!(interior, 38); + assert_eq!(brief_column_count(interior), 3); + // Brief rows_h = the full body (no header row): panels_h = 22, no tab + // strip at 1 tab, body_h = 20. + assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Brief, 1), 20); +} + +#[test] +fn brief_mode_cursor_past_the_last_column_shifts_the_window_one_column_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Brief-mode: 20 rows_h, 3 columns (60-position window) + state.left.display_mode = DisplayMode::Brief; + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = (0..100).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + state.left.cursor = 59; // column 2 (last of the window), row 19 -- the window's last position + state.left.scroll_offset = 0; + + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + + assert_eq!(state.left.cursor, 60, "column 3, row 0: one step past the window's last column"); + assert_eq!( + state.left.scroll_offset, 20, + "shifts by exactly one column (20 positions), not further (additional-panel-modes \"Cursor past the last visible column shifts the window one column\")" + ); + assert_eq!( + state.left.scroll_offset % 20, + 0, + "the offset stays on a rows_h-multiple column boundary (additional-panel-modes \"Window start stays on a column boundary\")" + ); +} + +#[test] +fn brief_mode_quick_filter_re_clamps_the_column_window_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Brief-mode: 20 rows_h, 3 columns + state.left.display_mode = DisplayMode::Brief; + state.left.sort_mode = SortMode::Unsorted; + state.left.entries = (0..100).map(|i| file_entry(&format!("e{i}"), 0)).collect(); + state.left.cursor = 65; // column 3 + state.left.scroll_offset = 60; // window starts at column 3, matching the cursor + + let (state, _) = update(state, Command::QuickFilterStart(PanelSide::Left)); + // Only "e6" and "e65"/"e6x" family remain a small handful of matches + // near the front of the list, forcing the (now far too large) stale + // offset to pull back. + let (state, _) = update(state, Command::QuickFilterChar('e')); + let (state, _) = update(state, Command::QuickFilterChar('6')); + + let visible = state.left.visible_indices(); + assert!(!visible.is_empty()); + let pos = visible.iter().position(|&i| i == state.left.cursor).unwrap(); + let start_col = state.left.scroll_offset / 20; + let pos_col = pos / 20; + assert!( + start_col <= pos_col && pos_col < start_col + 3, + "the cursor's column ({pos_col}) must be inside the re-clamped window starting at column {start_col} (panel-navigation \"Quick-filter narrowing re-clamps the offset\", extended to Brief's column space)" + ); + assert_eq!(state.left.scroll_offset % 20, 0, "offset stays on a column boundary after re-clamping"); +} + +#[test] +fn tree_mode_cursor_below_the_bottom_scrolls_the_node_window_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 24); // Tree-mode body: 19 rows (body_h - 1 header) + assert_eq!(panel_viewport_rows((80, 24), DisplayMode::Tree, 1), 19); + state.left.display_mode = DisplayMode::Tree; + let mut tree = TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full); + let children: Vec = (0..30).map(|i| dir_entry(&format!("d{i}"))).collect(); + tree.insert_children(&PathBuf::from(r"C:\"), children); // 31 nodes total (root + 30) + tree.cursor = 18; // last visible row of a 19-row window starting at 0 + state.left.tree = Some(tree); + + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + + let tree = state.left.tree.as_ref().unwrap(); + assert_eq!(tree.cursor, 19); + assert_eq!( + tree.scroll_offset, 1, + "shifts by exactly one row, mirroring Full mode (additional-panel-modes \"Tree cursor below the bottom scrolls the nodes\")" + ); +} + +#[test] +fn tree_node_expanded_re_clamps_the_tree_offset_through_the_reducer() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (80, 12); // a small Tree-mode body forces a small row count + let rows = panel_viewport_rows((80, 12), DisplayMode::Tree, 1); + state.left.display_mode = DisplayMode::Tree; + let mut tree = TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full); + let children: Vec = (0..15).map(|i| dir_entry(&format!("d{i}"))).collect(); + tree.insert_children(&PathBuf::from(r"C:\"), children); // 16 nodes total (root + 15) + tree.cursor = 12; + tree.scroll_offset = 0; // stale at the current (small) row count: 12 is not in [0, rows) + state.left.tree = Some(tree); + + // Expand a *different* node than the cursor's; it must not move the + // cursor, but the node-list mutation still funnels through + // reconciliation (additional-panel-modes "Tree mode scrolling"). + let more = vec![dir_entry("extra")]; + let (state, _) = update(state, Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\d0"), children: more }); + + let tree = state.left.tree.as_ref().unwrap(); + assert_eq!(tree.cursor, 12, "expanding a different node doesn't move the cursor"); + assert_eq!( + tree.scroll_offset, + 12 + 1 - rows, + "the stale offset re-clamps to the minimal-shift window around the cursor once TreeNodeExpanded re-runs reconciliation" + ); +} + +// --------------------------------------------------------------------- +// listing-request-guard: superseded listings are discarded and cancelled +// (panel-navigation "Superseded directory listings are discarded and +// cancelled"; panel-tabs "Switching or closing a tab mid-listing never +// mixes listings"; tasks 5.2–5.7) +// --------------------------------------------------------------------- + +fn entry_names(state: &State, side: PanelSide) -> Vec { + state.panel(side).entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect() +} + +/// A left panel showing a complete listing of `/left` with the one +/// directory `big` in it, so `Enter` starts a listing that the test then +/// leaves unfinished. +fn complete_left_with_big() -> State { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.entries = vec![dir_entry("big")]; + state.left.progress = crate::panel::ListingProgress::Complete { count: 1 }; + state +} + +#[test] +fn initial_state_tags_both_first_listings_with_distinct_request_ids() { + let (state, effects) = State::initial(Theme::classic(), (80, 24), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), false); + let left = state.left.listing_request.expect("the left panel's first listing is tagged"); + let right = state.right.listing_request.expect("the right panel's first listing is tagged"); + assert_ne!(left, right); + assert!(effects.contains(&Effect::StartListing { panel: PanelSide::Left, path: PathBuf::from("/l"), request: left })); + assert!(effects.contains(&Effect::StartListing { panel: PanelSide::Right, path: PathBuf::from("/r"), request: right })); +} + +#[test] +fn backspace_out_of_a_slow_directory_mid_listing_drops_every_reply_of_the_old_listing() { + // panel-navigation "Backspace out of a slow directory mid-listing" and + // "Superseded completion does not complete the new listing". + let (state, _) = update(complete_left_with_big(), Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/big")); + let old = listing_request(&state, PanelSide::Left); + + // Backspace on the empty command line before `/left/big` finished. + let (state, effects) = update(state, Command::ParentDir); + assert_eq!(state.left.cwd, PathBuf::from("/left")); + let new = listing_request(&state, PanelSide::Left); + assert_ne!(old, new, "the parent listing is a new generation"); + assert!(effects.contains(&Effect::CancelRequest(old)), "the read of /left/big is signalled to stop: {effects:?}"); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left"))); + + // The old worker's chunk — including its own `..` — arrives late. + let stale = vec![Entry::parent_dir(), file_entry("in-big.txt", 1)]; + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: stale, request: old }); + assert!(state.left.entries.is_empty(), "no entry of /left/big, not even its `..`, reaches /left"); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Streaming { count: 0 }, "the Reading… count covers only /left's entries"); + + // Its completion must not complete the new listing either. + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 2, unreadable: 0, request: old }); + assert!(state.left.progress.is_streaming(), "the mini-status keeps showing Reading… for the new listing"); + + // The new listing's own replies are applied as usual. + let (state, _) = listing_chunk(state, PanelSide::Left, vec![Entry::parent_dir(), dir_entry("big")]); + let (state, _) = listing_complete(state, PanelSide::Left, 2); + assert_eq!(entry_names(&state, PanelSide::Left), vec!["..", "big"]); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 2 }); +} + +#[test] +fn a_stale_listing_failure_sets_no_error_and_leaves_an_active_drag_alone() { + // panel-navigation "Superseded failure does not mark the new listing + // failed"; design D2: only an accepted failure clears a drag. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + assert!(state.drag.is_some()); + + // The right panel re-reads; the listing it supersedes then fails late. + let old = listing_request(&state, PanelSide::Right); + let (state, _) = update(state, Command::RereadPanel(PanelSide::Right)); + let (state, _) = + update(state, Command::ListingFailed { panel: PanelSide::Right, message: "Cannot read old: access denied".into(), request: old }); + assert_eq!(state.right.last_error, None, "no error is shown for the current directory"); + assert!(state.right.progress.is_streaming(), "the current listing continues normally"); + assert!(state.drag.is_some(), "a stale failure from a directory the user left does not cancel the drag"); + + // The current listing's own failure still does both. + let (state, _) = listing_failed(state, PanelSide::Right, "Cannot read right: access denied".into()); + assert_eq!(state.right.last_error.as_deref(), Some("Cannot read right: access denied")); + assert!(state.drag.is_none(), "an accepted listing failure clears the drag (mouse-drag)"); +} + +#[test] +fn two_quick_rereads_list_each_entry_exactly_once() { + // panel-navigation "Two quick re-reads do not duplicate entries". + let mut state = test_state(UiPhase::Panels); + state.left.progress = crate::panel::ListingProgress::Complete { count: 0 }; + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + assert!(effects.iter().all(|e| !matches!(e, Effect::CancelRequest(_))), "a complete listing has no reader left to stop: {effects:?}"); + let first = listing_request(&state, PanelSide::Left); + + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + let second = listing_request(&state, PanelSide::Left); + assert_ne!(first, second); + assert!(effects.contains(&Effect::CancelRequest(first)), "the first re-read, still streaming, is cancelled: {effects:?}"); + + let entries: Vec = (0..500).map(|i| file_entry(&format!("e{i:03}"), 0)).collect(); + // Both readers deliver everything before the first one noticed its flag. + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: entries.clone(), request: first }); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 500, unreadable: 0, request: first }); + assert!(state.left.entries.is_empty() && state.left.progress.is_streaming(), "the first generation's replies are dropped"); + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries, request: second }); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 500, unreadable: 0, request: second }); + + let names = entry_names(&state, PanelSide::Left); + assert_eq!(names.len(), 500); + let distinct: std::collections::HashSet<&String> = names.iter().collect(); + assert_eq!(distinct.len(), 500, "each of the 500 entries is listed exactly once"); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 500 }); +} + +#[test] +fn switching_tabs_mid_listing_cancels_it_and_the_interrupted_tab_rereads_on_return() { + // panel-tabs "Switching tabs mid-listing" and "Returning to an + // interrupted tab re-reads it". + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.left.entries = vec![dir_entry("big"), dir_entry("work")]; + state.left.progress = crate::panel::ListingProgress::Complete { count: 2 }; + // Tab 2 (opened with Ctrl+T) goes to /left/work and finishes listing it. + let (state, _) = update(state, Command::OpenTab(PanelSide::Left)); + let mut state = state; + state.left.cursor = 1; // "work" + let (state, _) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/work")); + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("w.txt", 1)]); + let (state, _) = listing_complete(state, PanelSide::Left, 1); + // Back on tab 1 (complete, untouched: no read), enter /left/big and + // leave it streaming. + let (state, effects) = update(state, Command::SwitchTab(1)); + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. } | Effect::CancelRequest(_))), "tab 1 was complete: {effects:?}"); + let (state, _) = update(state, Command::Enter); // cursor 0: "big" + assert_eq!(state.left.cwd, PathBuf::from("/left/big")); + let old = listing_request(&state, PanelSide::Left); + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("b1.txt", 1)]); + + // Alt+2 while /left/big is still streaming. + let (state, effects) = update(state, Command::SwitchTab(2)); + assert_eq!(state.left.cwd, PathBuf::from("/left/work")); + assert!(effects.contains(&Effect::CancelRequest(old)), "the read of /left/big is stopped: {effects:?}"); + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), "tab 2 was fully listed and is not re-read: {effects:?}"); + assert!(state.left.tabs[0].stale, "the interrupted tab is marked stale"); + assert_eq!(state.left.tabs[0].listing_request, Some(old), "the stashed tab keeps the id of the listing it was reading"); + + // A late chunk of /left/big never reaches tab 2. + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![file_entry("b2.txt", 1)], request: old }); + assert_eq!(entry_names(&state, PanelSide::Left), vec!["w.txt"], "tab 2 shows only /left/work's entries"); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 1 }); + + // Alt+1: the interrupted tab starts a fresh read rather than showing a + // partial listing that never completes. + let history_before = state.dir_history.clone(); + let (state, effects) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left/big")); + let fresh = listing_request(&state, PanelSide::Left); + assert_ne!(fresh, old); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left/big")), "a fresh read starts: {effects:?}"); + assert!(effects.iter().all(|e| !matches!(e, Effect::PersistHistory(_))), "the refresh is not a navigation: {effects:?}"); + assert_eq!(state.dir_history, history_before, "no fuzzy-jump visit is recorded"); + assert!(state.left.entries.is_empty(), "the partial listing is not shown"); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Streaming { count: 0 }, "Reading… until the fresh read completes"); +} + +#[test] +fn an_interrupted_reread_still_keeps_the_users_place_when_its_tab_is_refreshed() { + // panel-tabs "An interrupted re-read still keeps the user's place": + // the anchor the interrupted re-read captured survives the stale-tab + // refresh (design D6: `capture_anchor: false` for a streaming tab). + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.term_size = (80, 24); + state.left.sort_mode = SortMode::Unsorted; + let mut names = names_of("e", 30); + names[15] = "report.txt".to_string(); + let entries: Vec = names.iter().map(|n| file_entry(n, 0)).collect(); + state.left.entries = entries.clone(); + state.left.progress = crate::panel::ListingProgress::Complete { count: 30 }; + state.left.cursor = 15; + state.left.cursor_user_moved = true; + state.left.scroll_offset = 5; + // A second, complete tab to switch to, then back on tab 1. + let (state, _) = update(state, Command::OpenTab(PanelSide::Left)); + let (state, _) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cursor, 15); + + // Ctrl+R, then Alt+2 before the re-read finishes. + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let old = listing_request(&state, PanelSide::Left); + assert!(state.left.reread_anchor.is_some(), "the re-read captured the anchor"); + let (state, effects) = update(state, Command::SwitchTab(2)); + assert!(effects.contains(&Effect::CancelRequest(old))); + assert!( + effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), + "switching to a fully listed, non-stale tab emits no StartListing: {effects:?}" + ); + assert!(state.left.tabs[0].stale && state.left.tabs[0].reread_anchor.is_some(), "the stashed tab keeps its anchor"); + + // Alt+1: the refresh keeps the anchor and the fresh listing restores it. + let (state, effects) = update(state, Command::SwitchTab(1)); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left"))); + assert!(state.left.reread_anchor.is_some(), "the refresh does not clear the interrupted re-read's anchor"); + let (state, _) = listing_chunk(state, PanelSide::Left, entries); + let (state, _) = listing_complete(state, PanelSide::Left, 30); + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("report.txt")); + assert_eq!(state.left.cursor, 15); + assert_eq!(state.left.scroll_offset, 5, "the scroll offset from before Ctrl+R is restored"); +} + +#[test] +fn a_tab_interrupted_mid_navigation_refreshes_from_the_top() { + // design D6: navigation cleared any anchor, so the fresh read of a + // directory that never finished listing starts at the top. + let mut state = complete_left_with_big(); + state.term_size = (80, 24); + let (state, _) = update(state, Command::OpenTab(PanelSide::Left)); + let (state, _) = update(state, Command::SwitchTab(1)); + let (state, _) = update(state, Command::Enter); // /left/big, streaming + let partial: Vec = (0..10).map(|i| file_entry(&format!("b{i}"), 0)).collect(); + let (state, _) = listing_chunk(state, PanelSide::Left, partial); + let mut state = state; + state.left.cursor = 5; + state.left.cursor_user_moved = true; + assert!(state.left.reread_anchor.is_none()); + + let (state, _) = update(state, Command::SwitchTab(2)); + let (state, effects) = update(state, Command::SwitchTab(1)); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left/big"))); + assert!(state.left.reread_anchor.is_none()); + assert!(state.left.entries.is_empty()); + assert_eq!((state.left.cursor, state.left.scroll_offset), (0, 0), "a never-completed directory starts from the top"); +} + +#[test] +fn closing_a_tab_mid_listing_cancels_it_and_the_neighbor_receives_none_of_it() { + // panel-tabs "Closing a tab mid-listing". + let (state, _) = update(complete_left_with_big(), Command::OpenTab(PanelSide::Left)); + let (state, _) = update(state, Command::Enter); // tab 2 → /left/big, streaming + let old = listing_request(&state, PanelSide::Left); + + let (state, effects) = update(state, Command::CloseTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 1); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "the neighbor becomes active"); + assert!(effects.contains(&Effect::CancelRequest(old)), "the closed tab's read is stopped: {effects:?}"); + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), "the complete neighbor is not re-read: {effects:?}"); + + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![file_entry("in-big.txt", 1)], request: old }); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 1, unreadable: 0, request: old }); + assert_eq!(entry_names(&state, PanelSide::Left), vec!["big"], "the neighbor receives none of the closed tab's entries"); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 1 }); + + // Ctrl+W on the last remaining tab is a no-op and stops nothing. + let (state, _) = update(state, Command::Enter); // /left/big again, streaming + let (state, effects) = update(state, Command::CloseTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 1); + assert!(effects.is_empty(), "nothing happens when only one tab remains: {effects:?}"); +} + +#[test] +fn opening_a_tab_mid_listing_keeps_the_listing_flowing_into_the_new_tab() { + // panel-tabs "New tab mid-listing keeps the listing". + let (state, _) = update(complete_left_with_big(), Command::Enter); // /left/big, streaming + let old = listing_request(&state, PanelSide::Left); + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("b1.txt", 1)]); + + let (state, effects) = update(state, Command::OpenTab(PanelSide::Left)); + assert!(effects.iter().all(|e| !matches!(e, Effect::CancelRequest(_))), "Ctrl+T stops nothing: {effects:?}"); + assert_eq!(state.left.tab_count(), 2); + assert_eq!(listing_request(&state, PanelSide::Left), old, "the new tab inherits the in-progress listing"); + assert!(state.left.tabs[0].stale, "the original tab, now in the background, is stale"); + + // Later chunks fill the new tab until it completes. + let (state, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![file_entry("b2.txt", 1)], request: old }); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 2, unreadable: 0, request: old }); + assert_eq!(entry_names(&state, PanelSide::Left), vec!["b1.txt", "b2.txt"]); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 2 }); + + // Activating the original tab re-reads /left/big instead of showing the + // partial listing it held when Ctrl+T was pressed. + let (state, effects) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left/big")); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/left/big")), "{effects:?}"); + assert!(state.left.entries.is_empty()); +} + +#[test] +fn swap_panels_is_still_refused_while_a_panel_streams_even_though_replies_carry_ids() { + // Regression for the unchanged in-flight rule (design D7): the request + // ids would make a mid-stream swap safe, but the refusal is kept by spec. + let state = test_state(UiPhase::Panels); // both panels still streaming + let before = state.clone(); + let (state, effects) = update(state, Command::SwapPanels); + assert_eq!(state, before); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// link-aware-listing: directory links navigate like directories, skipped +// entries are reported, and deleting a link asks once (panel-navigation +// "Directory links list as directories", "One unreadable entry does not +// fail a listing"; tasks 4.6–4.10) +// --------------------------------------------------------------------- + +fn dir_link_entry(name: &str) -> Entry { + Entry::new(OsString::from(name), EntryKind::Directory, 0, None, false, true) +} + +fn dangling_link_entry(name: &str) -> Entry { + Entry::new(OsString::from(name), EntryKind::File, 0, None, false, true) +} + +#[test] +fn enter_descends_into_a_directory_link_and_parent_navigation_returns_to_the_links_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/proj/node_modules"); + state.left.entries = vec![dir_link_entry("lodash")]; + state.left.progress = crate::panel::ListingProgress::Complete { count: 1 }; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/proj/node_modules/lodash"), "the link's own path becomes the panel's directory"); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/proj/node_modules/lodash")), "{effects:?}"); + assert!(state.file_action_menu().is_none(), "Enter navigates rather than opening the file-action menu"); + + // Ctrl+PgUp goes back to the directory containing the link, not to the + // target's parent. + let (state, effects) = update(state, Command::ParentDir); + assert_eq!(state.left.cwd, PathBuf::from("/proj/node_modules")); + assert!(effects.contains(&start_listing(&state, PanelSide::Left, "/proj/node_modules")), "{effects:?}"); +} + +#[test] +fn enter_on_a_dangling_link_does_not_start_a_listing() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dangling_link_entry("old")]; + state.left.progress = crate::panel::ListingProgress::Complete { count: 1 }; + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "the panel's directory is unchanged"); + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), "no listing starts: {effects:?}"); + assert!(state.file_action_menu().is_some(), "a dangling link is a file entry: Enter opens the file-action menu"); +} + +#[test] +fn listing_complete_with_skipped_entries_reports_the_count_until_the_next_listing() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = (0..49).map(|i| file_entry(&format!("e{i:02}"), 0)).collect(); + let request = listing_request(&state, PanelSide::Left); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 49, unreadable: 1, request }); + assert_eq!(state.left.last_error.as_deref(), Some("1 entry could not be read")); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 49 }, "the other 49 entries are listed"); + assert_eq!(state.left.entries.len(), 49); + + // The count is comma-grouped and pluralized. + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + assert_eq!(state.left.last_error, None, "the message clears the moment the panel lists again"); + let request = listing_request(&state, PanelSide::Left); + let (state, _) = update(state, Command::ListingComplete { panel: PanelSide::Left, total: 0, unreadable: 1234, request }); + assert_eq!(state.left.last_error.as_deref(), Some("1,234 entries could not be read")); + + // A re-read that reads everything leaves no message. + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let (state, _) = listing_complete(state, PanelSide::Left, 0); + assert_eq!(state.left.last_error, None); +} + +#[test] +fn listing_failed_still_sets_the_inline_read_error_state() { + let state = test_state(UiPhase::Panels); + let (state, _) = listing_failed(state, PanelSide::Left, "Cannot read left: access denied".into()); + assert_eq!(state.left.last_error.as_deref(), Some("Cannot read left: access denied")); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 0 }); +} + +#[test] +fn deleting_a_directory_link_asks_once_while_a_real_directory_still_asks_twice() { + // F8 on the junction: one confirmation, because Delete removes only the + // link (design D5). The source still reports `is_dir`, as the job + // engine's reparse-point handling expects. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_link_entry("lodash"), dir_entry("real")]; + state.left.cursor = 0; + let (state, _) = update(state, Command::RequestDelete); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, needs_second_confirm, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("lodash")); + assert!(sources[0].is_dir); + assert!(!needs_second_confirm, "a directory link does not raise the non-empty-directory prompt"); + } + other => panic!("expected the delete-confirm dialog, got {other:?}"), + } + + // F8 on the real directory next to it still asks twice. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_link_entry("lodash"), dir_entry("real")]; + state.left.cursor = 1; + let (state, _) = update(state, Command::RequestDelete); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { needs_second_confirm, .. }) => { + assert!(needs_second_confirm, "a real directory requires the existing second confirmation"); + } + other => panic!("expected the delete-confirm dialog, got {other:?}"), + } + + // A selection mixing the link with a real directory asks twice for the + // real directory's sake. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_link_entry("lodash"), dir_entry("real")]; + state.left.selected.insert(OsString::from("lodash")); + state.left.selected.insert(OsString::from("real")); + let (state, _) = update(state, Command::RequestDelete); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, needs_second_confirm, .. }) => { + assert_eq!(sources.len(), 2); + assert!(needs_second_confirm); + } + other => panic!("expected the delete-confirm dialog, got {other:?}"), + } +} diff --git a/crates/filecommand-core/src/update/tests/mod.rs b/crates/filecommand-core/src/update/tests/mod.rs new file mode 100644 index 0000000..05c9388 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/mod.rs @@ -0,0 +1,227 @@ +use super::*; +use crate::drives::DriveEntry; +use crate::listing::{Entry, EntryKind}; +use crate::menu::MenuEntry; +use std::ffi::OsString; +// --------------------------------------------------------------------- +// M5: F4 built-in editor +// --------------------------------------------------------------------- + +use crate::editor::{EditorMove, EditorState, EntryMode, ReplacePrompt}; + +mod editor; +mod file_op; +mod listing; +mod overlays; +mod panels; +mod routing; +mod viewer; + +fn test_state(phase: UiPhase) -> State { + // Both panels carry a listing request id, as `State::initial` would have + // minted for their first listing (listing-request-guard design D1), so + // the listing helpers below have an id to echo; `request_seq` sits past + // them so the next minted id never collides. + let mut left = PanelState::new(PathBuf::from("/left")); + left.listing_request = Some(1); + let mut right = PanelState::new(PathBuf::from("/right")); + right.listing_request = Some(2); + State { left, right, phase, request_seq: 2, ..State::empty(Theme::classic()) } +} + +/// The id of `side`'s current listing — what a worker reply must echo to be +/// applied (listing-request-guard design D8). +fn listing_request(state: &State, side: PanelSide) -> u64 { + state.panel(side).listing_request.expect("the panel has a current listing request") +} + +/// Feed one `ListingChunk` for `side`'s *current* listing — the request id +/// the panel recorded when the listing began — so the test exercises the +/// accepted path exactly as the worker for that listing would (listing- +/// request-guard design D8). Tests about superseded replies construct their +/// mismatched `Command::ListingChunk { request, .. }` explicitly. +fn listing_chunk(state: State, side: PanelSide, entries: Vec) -> (State, Vec) { + let request = listing_request(&state, side); + update(state, Command::ListingChunk { panel: side, entries, request }) +} + +/// `ListingComplete` for `side`'s current listing; see [`listing_chunk`]. +fn listing_complete(state: State, side: PanelSide, total: usize) -> (State, Vec) { + let request = listing_request(&state, side); + update(state, Command::ListingComplete { panel: side, total, unreadable: 0, request }) +} + +/// `ListingFailed` for `side`'s current listing; see [`listing_chunk`]. +fn listing_failed(state: State, side: PanelSide, message: String) -> (State, Vec) { + let request = listing_request(&state, side); + update(state, Command::ListingFailed { panel: side, message, request }) +} + +/// The `Effect::StartListing` the reducer just issued for `side`: its +/// `request` is whatever `begin_listing_inner` recorded on the panel, read +/// back from the post-`update` state (listing-request-guard design D8). +fn start_listing(state: &State, side: PanelSide, path: impl Into) -> Effect { + Effect::StartListing { panel: side, path: path.into(), request: listing_request(state, side) } +} + +fn file_entry(name: &str, size: u64) -> Entry { + Entry::new(OsString::from(name), EntryKind::File, size, None, false, false) +} + +fn dir_entry(name: &str) -> Entry { + Entry::new(OsString::from(name), EntryKind::Directory, 0, None, false, false) +} + +/// Every navigation/re-read now also issues `Effect::QueryGitInfo` +/// alongside `Effect::StartListing` (git-info "Query re-issued on +/// navigation"), `Effect::PersistHistory` (fuzzy-jump "Navigation +/// records history"; design D6), and — when the panel's previous listing +/// was still streaming, as `test_state`'s fresh panels always are — +/// `Effect::CancelRequest` for that listing (listing-request-guard design +/// D4). Tests that predate those changes and only care about the +/// listing-related effects filter all three out with this helper rather +/// than asserting on request ids/frecency bookkeeping that are incidental +/// to what they're testing; tests about cancellation read the raw effects. +fn without_git_info_effects(effects: Vec) -> Vec { + effects.into_iter().filter(|e| !matches!(e, Effect::QueryGitInfo { .. } | Effect::PersistHistory(_) | Effect::CancelRequest(_))).collect() +} + +/// Record an outstanding F3/F4 open on the active panel under `request`, +/// exactly as `handle_request_viewer`/`handle_request_editor` do, so a +/// reply a test injects directly is the current one and is applied rather +/// than discarded as stale (offload-blocking-io design D4). The id counter +/// is advanced past `request` so anything minted afterwards is fresh. +fn with_pending_open(mut state: State, request: u64) -> State { + state.request_seq = state.request_seq.max(request); + state.pending_open = Some(PendingOpen { request, side: state.active, kind: PendingOpenKind::Viewer }); + state +} + +fn running_progress_state(kind: JobKind, source_dir: &str, dest_dir: &str) -> State { + let mut state = test_state(UiPhase::FileOpRunning { + source_dir: PathBuf::from(source_dir), + dest_dir: PathBuf::from(dest_dir), + dialog: RunningDialog::Progress { kind, progress: ProgressInfo::starting(3, 30) }, + }); + state.left.cwd = PathBuf::from(source_dir); + state.right.cwd = PathBuf::from(dest_dir); + state +} + +fn sample_conflict() -> ConflictInfo { + ConflictInfo { + source_name: OsString::from("a.txt"), + source_size: 1, + source_modified: None, + target_path: PathBuf::from("/right/a.txt"), + target_size: 2, + target_modified: None, + } +} + +// --------------------------------------------------------------------- +// M3: command line +// --------------------------------------------------------------------- + +fn type_line(state: State, text: &str) -> State { + text.chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0) +} + +// --------------------------------------------------------------------- +// file-action-menu: navigation, dismissal, selection independence, +// precedence over a non-empty command buffer, and per-action routing. +// --------------------------------------------------------------------- + +/// Cursor on `cursor_on` among `names`, menu already open. +fn opened_menu_state(names: &[&str], cursor_on: &str) -> State { + let mut state = test_state(UiPhase::Panels); + state.left.entries = names.iter().map(|n| file_entry(n, 1)).collect(); + state.left.cursor = names.iter().position(|n| *n == cursor_on).unwrap(); + let (state, _) = update(state, Command::Enter); + assert!(state.file_action_menu().is_some(), "setup precondition: the menu must be open"); + state +} + +// --------------------------------------------------------------------- +// pulldown-menu-completion: Swap panels (task 2.5; panel-navigation "Swap +// panels (Ctrl+U)"; design D3) +// --------------------------------------------------------------------- + +/// Both panels' listings marked Complete, the state every swap test starts +/// from (a Streaming panel refuses the swap). +fn complete_panel_listings(state: &mut State) { + state.left.progress = crate::panel::ListingProgress::Complete { count: state.left.entries.len() }; + state.right.progress = crate::panel::ListingProgress::Complete { count: state.right.entries.len() }; +} + +fn opened_viewer_state(file_len: u64) -> State { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let (state, _) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/f.txt"), file_len }); + state +} + +/// Run a real F7 search-confirm sequence so the returned state carries the +/// actual outstanding `search_request` id, mirroring how the TUI event loop +/// would drive it (`ViewerSearchStart` -> typed chars -> `ViewerSearchConfirm`). +fn viewer_with_pattern_confirmed(state: State, pattern: &str) -> (State, u64) { + let (state, _) = update(state, Command::ViewerSearchStart); + let mut state = state; + for c in pattern.chars() { + let (s, _) = update(state, Command::ViewerSearchChar(c)); + state = s; + } + let (state, _) = update(state, Command::ViewerSearchConfirm); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + let request = v.search_request.expect("search confirm must record an outstanding request id"); + (state, request) +} + +fn editor_with(lines: &[&str]) -> EditorState { + let text = lines.join("\n"); + let mut bytes = text.into_bytes(); + if !lines.is_empty() { + bytes.push(b'\n'); + } + EditorState::from_bytes(PathBuf::from("/left/edit.txt"), &bytes).expect("test fixture is valid UTF-8") +} + +/// A real on-disk file to back tests that exercise an actual +/// `EditorState::save` round trip (the `Effect::SaveEditor` reply tests), +/// rather than a bare in-memory `from_bytes` buffer whose path doesn't +/// exist. +fn editor_with_temp_file(name: &str, lines: &[&str]) -> EditorState { + let dir = std::env::temp_dir().join(format!("filecommand-update-test-editor-{}-{}", std::process::id(), name)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("file.txt"); + let mut text = lines.join("\n"); + if !lines.is_empty() { + text.push('\n'); + } + std::fs::write(&path, &text).unwrap(); + match EditorState::open(&path).unwrap() { + crate::editor::LoadResult::Loaded(e) => *e, + crate::editor::LoadResult::TooLarge { .. } => panic!("test fixture unexpectedly exceeds the editor size cap"), + crate::editor::LoadResult::NotEditable { reason } => panic!("test fixture unexpectedly not editable: {reason:?}"), + } +} + +fn editor_state(editor: EditorState) -> State { + State { phase: UiPhase::Editor(editor), ..test_state(UiPhase::Panels) } +} + +// --------------------------------------------------------------------- +// M5: Tree display mode (design D7; additional-panel-modes) +// --------------------------------------------------------------------- + +fn dir_child(name: &str) -> Entry { + dir_entry(name) +} + +// --------------------------------------------------------------------- +// M5: Ctrl+J fuzzy jump +// --------------------------------------------------------------------- + +fn history_entry(path: &str, count: u32, ms: u64) -> crate::quicksearch::FrecencyEntry { + crate::quicksearch::FrecencyEntry { path: PathBuf::from(path), visit_count: count, last_visited_ms: ms } +} diff --git a/crates/filecommand-core/src/update/tests/overlays.rs b/crates/filecommand-core/src/update/tests/overlays.rs new file mode 100644 index 0000000..4fdc019 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/overlays.rs @@ -0,0 +1,1746 @@ +use super::*; + +#[test] +fn file_action_menu_does_not_consume_or_alter_the_selection() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = + vec![file_entry("a.txt", 1), file_entry("report.txt", 2), file_entry("c.txt", 3), file_entry("d.txt", 4), file_entry("e.txt", 5)]; + for n in ["a.txt", "report.txt", "c.txt", "d.txt", "e.txt"] { + state.left.selected.insert(OsString::from(n)); + } + state.left.cursor = 1; // report.txt + let selected_before = state.left.selected.clone(); + + let (state, _) = update(state, Command::Enter); + let menu = state.file_action_menu().expect("menu opens"); + assert_eq!(menu.target_name, OsString::from("report.txt"), "the menu targets only the cursor entry"); + assert_eq!(state.left.selected, selected_before, "opening the menu does not touch the selection"); + assert_eq!(state.left.selected.len(), 5); + + let (state, _) = update(state, Command::FileActionMenuCancel); + assert_eq!(state.left.selected, selected_before, "closing the menu leaves the selection intact"); +} + +#[test] +fn file_action_menu_does_not_open_when_the_command_buffer_is_non_empty() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("readme.txt", 1)]; + let state = type_line(state, "cd nosuchdir"); + let (state, effects) = update(state, Command::Enter); + assert!(state.file_action_menu().is_none(), "a non-empty command buffer takes precedence over the menu"); + // The unlisted target is checked asynchronously (offload-blocking-io): + // the Enter itself spawns nothing and starts no listing. + assert!( + matches!(effects.as_slice(), [Effect::ProbePath { .. }]), + "the cd only probes its target — it spawns and starts nothing, got {effects:?}" + ); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\NORTON"), "the pending cd leaves the panel untouched"); +} + +#[test] +fn file_action_menu_does_not_open_for_a_directory_which_still_navigates() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, effects) = update(state, Command::Enter); + assert!(state.file_action_menu().is_none(), "Enter on a directory must not open the menu"); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left/sub")]); +} + +#[test] +fn file_action_menu_up_down_moves_the_highlight_clamped_at_both_ends() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + // Non-executable: Open, View, Edit, Send to clipboard, Copy, Rename, + // Move, Delete (8 entries). + let (state, _) = update(state, Command::FileActionMenuMove(-5)); + assert_eq!(state.file_action_menu().unwrap().cursor, 0, "Up from the first entry holds, it does not wrap"); + let (state, _) = update(state, Command::FileActionMenuMove(100)); + let menu = state.file_action_menu().unwrap(); + assert_eq!(menu.cursor, menu.entries.len() - 1, "Down past the last entry clamps, it does not wrap"); + assert_eq!(menu.selected(), FileActionMenuEntry::Delete); +} + +#[test] +fn file_action_menu_esc_closes_with_no_action_and_leaves_the_cursor_and_panel_untouched() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, effects) = update(state, Command::FileActionMenuCancel); + assert!(state.file_action_menu().is_none()); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.left.cursor, 0); +} + +#[test] +fn file_action_menu_first_letter_hotkey_activates_directly() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + assert!(state.file_action_menu().is_none(), "the hotkey closes the menu"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("notes.txt")); + } + other => panic!("expected the Delete hotkey to open DeleteConfirm, got {other:?}"), + } +} + +#[test] +fn file_action_menu_unknown_hotkey_is_a_no_op() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, effects) = update(state, Command::FileActionMenuHotkey('Z')); + assert!(state.file_action_menu().is_some(), "an unmatched hotkey leaves the menu open"); + assert!(effects.is_empty()); +} + +#[test] +fn file_action_menu_targets_the_entry_it_opened_on_even_if_the_cursor_drifts_from_a_background_listing_refresh() { + // Regression: ListingChunk is applied unconditionally regardless of any + // open modal (including this menu), and insert_streamed re-pins the + // cursor to row 0 whenever cursor_user_moved is still false — the + // normal state right after Enter opened the menu. The menu must keep + // acting on the entry it was opened on (`b.txt`), never on whatever a + // background listing update lands under the cursor in the meantime. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("b.txt", 1)]; + state.left.cursor = 0; + let (state, _) = update(state, Command::Enter); // opens the menu on b.txt + let menu = state.file_action_menu().unwrap(); + assert_eq!(menu.target_name, OsString::from("b.txt")); + + // A background listing chunk inserts a.txt, which sorts ahead of + // b.txt, and re-pins the (not-yet-user-moved) cursor to row 0. + let (state, _) = listing_chunk(state, PanelSide::Left, vec![file_entry("a.txt", 1)]); + assert_eq!(state.left.cursor, 0, "setup precondition: the cursor drifted onto a.txt"); + assert_eq!(state.left.entries[state.left.cursor].name, OsString::from("a.txt")); + + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("b.txt"), "must target the entry the menu opened on, not the drifted cursor"); + } + other => panic!("expected DeleteConfirm targeting b.txt (the menu's original target), got {other:?}"), + } +} + +#[test] +fn file_action_menu_view_routes_to_the_f3_viewer_path() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuMove(1)); // Open is highlighted first; one Down lands on View + let (state, effects) = update(state, Command::FileActionMenuConfirm); + assert!(state.file_action_menu().is_none()); + assert_eq!(effects, vec![Effect::OpenViewer { request: 3, path: PathBuf::from("/left/notes.txt") }]); + assert_eq!(state.phase, UiPhase::Panels, "opening is I/O; the phase flips only once ViewerOpened comes back"); +} + +#[test] +fn file_action_menu_edit_routes_to_the_f4_edit_path() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuMove(2)); // Open, View, Edit + let (state, effects) = update(state, Command::FileActionMenuConfirm); + assert!(state.file_action_menu().is_none()); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/left/notes.txt") }]); +} + +#[test] +fn file_action_menu_copy_opens_the_f5_destination_dialog_scoped_to_the_target_only() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("report.txt", 2)]; + state.left.selected.insert(OsString::from("a.txt")); // an unrelated selection must not leak in + state.left.cursor = 1; // report.txt + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::FileActionMenuMove(4)); // Open, View, Edit, SendToClipboard, Copy + let (state, effects) = update(state, Command::FileActionMenuConfirm); + assert!(effects.is_empty(), "opening the destination dialog is not itself a mutation"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, input, .. }) => { + assert_eq!(kind, JobKind::Copy); + assert_eq!(sources.len(), 1, "scoped to the menu's target only, not the selection"); + assert_eq!(sources[0].original_name, OsString::from("report.txt")); + assert_eq!(input.text(), PathBuf::from("/right").display().to_string(), "pre-filled with the opposite panel's path"); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn file_action_menu_move_opens_the_f6_destination_dialog_scoped_to_the_target_only() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuMove(6)); // Open, View, Edit, SendToClipboard, Copy, Rename, Move + let (state, _) = update(state, Command::FileActionMenuConfirm); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, .. }) => { + assert_eq!(kind, JobKind::Move); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("notes.txt")); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn file_action_menu_delete_requires_the_existing_confirmation_and_declining_deletes_nothing() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + let (state, effects) = update(state, Command::FileOpCancel); + assert_eq!(state.phase, UiPhase::Panels, "declining returns to the panels"); + assert!(effects.is_empty(), "nothing runs, nothing is deleted"); +} + +#[test] +fn activating_a_mutating_menu_action_changes_nothing_before_its_dialog_is_accepted() { + // Copy, Move, Rename, and Delete each only *open* a setup dialog on + // activation — no `Effect::RunJob` (the only mutation-triggering effect) + // is emitted until that dialog's own Confirm (file-action-menu "No + // mutation without an intervening dialog"). + for hotkey in ['C', 'M', 'R', 'D'] { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, effects) = update(state, Command::FileActionMenuHotkey(hotkey)); + assert!(!effects.iter().any(|e| matches!(e, Effect::RunJob(_))), "hotkey {hotkey:?} must not run a job on activation alone"); + assert!(matches!(state.phase, UiPhase::FileOpSetup(_)), "hotkey {hotkey:?} should have opened a setup dialog, got {:?}", state.phase); + } +} + +#[test] +fn cancelling_the_interposed_destination_dialog_after_move_starts_no_job() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('M')); + let (state, effects) = update(state, Command::FileOpCancel); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// file-action-menu: Open with the associated application +// (open-with-associated-app; design D1–D3) +// --------------------------------------------------------------------- + +/// "Open launches the associated application": Enter, Enter on a document — +/// Open is highlighted first, so the immediate Confirm activates it. The +/// menu closes, the target goes out as one fire-and-forget launch effect, +/// and the phase stays Panels (no dialog, no TUI suspension of any kind). +#[test] +fn enter_enter_on_a_document_emits_open_with_association_for_the_target() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.pdf", 1)]; + let (state, _) = update(state, Command::Enter); + assert_eq!(state.file_action_menu().unwrap().selected(), FileActionMenuEntry::Open, "setup precondition: Open highlighted"); + let (state, effects) = update(state, Command::FileActionMenuConfirm); + assert!(state.file_action_menu().is_none(), "activating Open closes the menu"); + assert_eq!( + effects, + vec![Effect::OpenWithAssociation { side: PanelSide::Left, path: PathBuf::from("/left/report.pdf"), cwd: PathBuf::from("/left") }] + ); + assert_eq!(state.phase, UiPhase::Panels, "the panels stay interactive; no dialog opens"); +} + +/// "O activates Open": the hotkey activates Open exactly as if it had been +/// highlighted and Enter pressed. +#[test] +fn file_action_menu_o_hotkey_activates_open() { + let state = opened_menu_state(&["report.pdf"], "report.pdf"); + let (state, effects) = update(state, Command::FileActionMenuHotkey('O')); + assert!(state.file_action_menu().is_none(), "the hotkey closes the menu"); + assert_eq!( + effects, + vec![Effect::OpenWithAssociation { side: PanelSide::Left, path: PathBuf::from("/left/report.pdf"), cwd: PathBuf::from("/left") }] + ); +} + +/// "Open on a directory shows it in the file browser": the directory menu +/// lists Open first and activating it hands the directory itself to the +/// platform association (Explorer on Windows) — the panel does not +/// navigate (no `StartListing`). +#[test] +fn open_on_a_directory_hands_the_directory_to_the_file_association_without_navigating() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("src")]; + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("src") }); + let (state, effects) = update(state, Command::FileActionMenuConfirm); // Open is highlighted first + assert_eq!(effects, vec![Effect::OpenWithAssociation { side: PanelSide::Left, path: PathBuf::from("/left/src"), cwd: PathBuf::from("/left") }]); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "the panel does not navigate"); +} + +/// "Open ignores the selection": even opened selection-scoped (right-click +/// on a member of the selection), Open emits exactly one launch effect for +/// the menu's target and leaves the selection untouched (design D3). +#[test] +fn open_on_a_selected_target_emits_one_effect_for_that_target_only() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; + for n in ["a.txt", "b.txt", "c.txt"] { + state.left.selected.insert(OsString::from(n)); + } + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("b.txt") }); + assert!(state.file_action_menu().unwrap().selection_scoped, "setup precondition: opened on a selection member"); + + let (state, effects) = update(state, Command::FileActionMenuHotkey('O')); + assert_eq!( + effects, + vec![Effect::OpenWithAssociation { side: PanelSide::Left, path: PathBuf::from("/left/b.txt"), cwd: PathBuf::from("/left") }], + "one launch effect, for the menu's target only" + ); + assert_eq!(state.left.selected.len(), 3, "the selection is untouched"); +} + +/// "No association is reported inline": the effect's failure reply lands on +/// the status line of the panel the Open action was invoked on, verbatim, +/// and opens no dialog. +#[test] +fn open_with_association_failed_lands_on_the_invoking_panels_last_error() { + let state = test_state(UiPhase::Panels); + let (state, effects) = update( + state, + Command::OpenWithAssociationFailed { side: PanelSide::Left, message: "No application is associated with data.zzz".to_string() }, + ); + assert!(effects.is_empty()); + assert_eq!(state.left.last_error.as_deref(), Some("No application is associated with data.zzz")); + assert_eq!(state.right.last_error, None); + assert_eq!(state.phase, UiPhase::Panels, "no dialog opens"); +} + +/// application-shell "Launching a slow handler leaves the panels +/// interactive": a late failure lands on the panel the menu acted on even +/// though the user has switched panels since — the reply carries the side, +/// so `state.active` at arrival time is irrelevant (offload-blocking-io +/// design D7). +#[test] +fn open_with_association_failed_lands_on_the_recorded_side_not_the_active_panel() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.pdf", 1)]; + let (state, _) = update(state, Command::Enter); + let (state, effects) = update(state, Command::FileActionMenuConfirm); // Open + assert!( + matches!(effects.as_slice(), [Effect::OpenWithAssociation { side: PanelSide::Left, .. }]), + "the effect records the invoking panel: {effects:?}" + ); + let (state, _) = update(state, Command::ToggleActivePanel); + assert_eq!(state.active, PanelSide::Right, "precondition: the user has switched panels"); + let (state, _) = update(state, Command::OpenWithAssociationFailed { side: PanelSide::Left, message: "boom".to_string() }); + assert_eq!(state.left.last_error.as_deref(), Some("boom"), "the failure lands on the panel Open was invoked on"); + assert_eq!(state.right.last_error, None); +} + +// --------------------------------------------------------------------- +// M3: F9 menus +// --------------------------------------------------------------------- + +#[test] +fn f9_opens_the_bar_with_the_left_pulldown_showing() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let menu = state.menu().expect("F9 opens the bar"); + assert_eq!(menu.active, MenuId::Left); + assert!(menu.pulldown_open); +} + +#[test] +fn esc_closes_the_pulldown_first_then_the_bar() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuCollapse); + let menu = state.menu().expect("the bar stays open after the first Esc"); + assert!(!menu.pulldown_open); + assert_eq!(menu.active, MenuId::Left, "the active title stays highlighted"); + + let (state, _) = update(state, Command::MenuCollapse); + assert!(state.menu().is_none(), "the second Esc closes the bar"); +} + +#[test] +fn hotkey_letter_jumps_to_another_menu_with_its_pulldown_open() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('c')); + let menu = state.menu().expect("bar still open"); + assert_eq!(menu.active, MenuId::Commands); + assert!(menu.pulldown_open); +} + +#[test] +fn horizontal_movement_keeps_a_pulldown_open_and_wraps() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuNextMenu); + assert_eq!(state.menu().unwrap().active, MenuId::Files); + assert!(state.menu().unwrap().pulldown_open); + + // Left -> Files -> Commands -> Options -> Right -> wraps to Left. + let state = (0..4).fold(state, |s, _| update(s, Command::MenuNextMenu).0); + assert_eq!(state.menu().unwrap().active, MenuId::Left); + + let (state, _) = update(state, Command::MenuPrevMenu); + assert_eq!(state.menu().unwrap().active, MenuId::Right); + assert!(state.menu().unwrap().pulldown_open); +} + +#[test] +fn vertical_selection_skips_separators() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('f')); + // Files opens on View, its first item; every shipped item is enabled + // (pulldown-menu-completion), so only separators are ever skipped. + assert_eq!(state.menu().unwrap().selected_item().map(|i| i.label), Some("View")); + let (state, _) = update(state, Command::MenuSelectNext); + assert_eq!(state.menu().unwrap().selected_item().map(|i| i.label), Some("Edit")); + let (state, _) = update(state, Command::MenuSelectPrev); + assert_eq!(state.menu().unwrap().selected_item().map(|i| i.label), Some("View")); +} + +#[test] +fn enter_dispatches_the_selected_item_and_closes_the_overlay() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('f')); + // Files opens on View; Copy is two rows down (View, Edit, Copy). + let (state, _) = update(state, Command::MenuSelectNext); + let (state, _) = update(state, Command::MenuSelectNext); + let (state, _) = update(state, Command::MenuActivate); // Copy + + assert!(state.menu().is_none(), "activating an item closes the whole overlay"); + assert!( + matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Copy, .. })), + "the item's action ran, got {:?}", + state.phase + ); +} + +#[test] +fn left_menu_sorts_the_left_panel_even_when_the_right_one_is_focused() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.left.entries = vec![file_entry("b", 1), file_entry("a", 2)]; + + let (state, _) = update(state, Command::MenuOpen); // Left menu + // Opens on Brief; Down walks Full, Tree, Quick view, Info, then (the + // separator skipped, not counted) Name, Extension, Modif. time, Size. + let state = (0..8).fold(state, |s, _| update(s, Command::MenuSelectNext).0); + assert_eq!(state.menu().unwrap().selected_item().map(|i| i.label), Some("Size")); + let (state, _) = update(state, Command::MenuActivate); + + assert_eq!(state.left.sort_mode, SortMode::Size, "the Left menu targets the left panel regardless of focus"); + assert_eq!(state.right.sort_mode, SortMode::Name); +} + +#[test] +fn right_menu_targets_the_right_panel() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('r')); + // Opens on Brief; Down four times walks Full, Tree, Quick view, Info. + let state = (0..4).fold(state, |s, _| update(s, Command::MenuSelectNext).0); + let (state, effects) = update(state, Command::MenuActivate); // Info + assert_eq!(state.right.display_mode, DisplayMode::Info); + assert_eq!(state.left.display_mode, DisplayMode::Full); + assert!(effects.iter().any(|e| matches!(e, Effect::QueryInfo { panel: PanelSide::Right, .. }))); +} + +#[test] +fn activating_options_themes_opens_the_theme_picker_and_closes_the_menu() { + // Options used to be entirely disabled (the old target of this test); + // Themes (visual-themes) is now its first enabled item, so activating + // Options and pressing Enter opens the picker instead of doing nothing + // (theme-selection "Options menu opens the theme picker"). + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('o')); // Options: Themes is first-selectable + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none(), "activating an item closes the whole menu overlay"); + assert!(state.theme_picker().is_some(), "Options -> Themes opens the picker"); + assert!(effects.is_empty()); +} + +#[test] +fn panel_keys_do_not_leak_through_an_open_menu() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + let (state, _) = update(state, Command::MenuOpen); + // MoveCursor is not a menu command; the mapper would never emit it while + // the bar is open, but the overlay must not be disturbed if it does. + let (state, _) = update(state, Command::MenuSelectNext); + assert_eq!(state.left.cursor, 0); + assert!(state.menu().is_some()); +} + +#[test] +fn every_menu_item_that_is_enabled_maps_to_a_command() { + for id in crate::menu::ALL_MENUS { + for entry in crate::menu::entries(id) { + if let MenuEntry::Item(item) = entry { + if item.is_enabled() { + assert!(menu_action_command(item.action, PanelSide::Left).is_some(), "`{}` is enabled but dispatches nothing", item.label); + } + } + } + } +} + +// --------------------------------------------------------------------- +// pulldown-menu-completion: every formerly-greyed item wired (task 2.5) +// --------------------------------------------------------------------- + +/// pulldown-menus "Every listed item is wired": the exact `Command` each new +/// `MenuAction` stands for. Left/Right-scoped actions carry the menu's own +/// side; Files/Commands actions follow the active panel. +#[test] +fn menu_action_command_maps_the_newly_wired_actions() { + use crate::menu::MenuAction; + assert_eq!(menu_action_command(MenuAction::View, PanelSide::Left), Some(Command::RequestViewer)); + assert_eq!(menu_action_command(MenuAction::Edit, PanelSide::Left), Some(Command::RequestEditor)); + assert_eq!(menu_action_command(MenuAction::QuickFilter, PanelSide::Right), Some(Command::QuickFilterStart(PanelSide::Right))); + assert_eq!(menu_action_command(MenuAction::NewTab, PanelSide::Right), Some(Command::OpenTab(PanelSide::Right))); + assert_eq!(menu_action_command(MenuAction::CloseTab, PanelSide::Right), Some(Command::CloseTab(PanelSide::Right))); + assert_eq!(menu_action_command(MenuAction::History, PanelSide::Left), Some(Command::HistoryDialogOpen)); + assert_eq!(menu_action_command(MenuAction::SwapPanels, PanelSide::Left), Some(Command::SwapPanels)); + assert_eq!(menu_action_command(MenuAction::EditMenuFile, PanelSide::Left), Some(Command::EditUserMenuFile)); +} + +/// pulldown-menus "Files → View opens the viewer": activating Files → View +/// closes the menu and opens the F3 viewer on the cursor entry, exactly as +/// F3 would. +#[test] +fn files_menu_view_follows_the_f3_path() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 1)]; + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('f')); + assert_eq!(state.menu().unwrap().selected_item().map(|i| i.label), Some("View")); + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none(), "activating an item closes the whole overlay"); + assert_eq!(effects, vec![Effect::OpenViewer { request: 3, path: PathBuf::from("/left/notes.txt") }]); +} + +/// Files → Edit follows the F4 path: with no external editor configured, the +/// built-in editor opens on the cursor entry. +#[test] +fn files_menu_edit_follows_the_f4_path() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 1)]; + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('f')); + let (state, _) = update(state, Command::MenuSelectNext); // Edit + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/left/notes.txt") }]); +} + +/// pulldown-menus "Right → Filter on the inactive panel": the filter starts +/// on the right panel, which becomes active so typed characters narrow it +/// (design D1). +#[test] +fn right_menu_filter_activates_the_right_panel_with_its_quick_filter_open() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('r')); + let filter_index = crate::menu::entries(MenuId::Right).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Filter")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = filter_index; + let (state, _) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert_eq!(state.right.quick_filter.as_deref(), Some(""), "the right panel's quick filter opened"); + assert_eq!(state.left.quick_filter, None, "the left panel is untouched"); + assert_eq!(state.active, PanelSide::Right, "the filtered panel became active so typing reaches it"); + // Subsequent typed characters narrow the right panel. + let (state, _) = update(state, Command::QuickFilterChar('x')); + assert_eq!(state.right.quick_filter.as_deref(), Some("x")); +} + +/// pulldown-menus "Left → New tab on the inactive panel": the left panel +/// gains the tab exactly as Ctrl+T with the left panel active would have +/// created it, and focus stays put (design D1). +#[test] +fn left_menu_new_tab_opens_a_tab_on_the_left_without_moving_focus() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + let (state, _) = update(state, Command::MenuOpen); // the Left menu is first + let tab_index = crate::menu::entries(MenuId::Left).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "New tab")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = tab_index; + let (state, _) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert_eq!(state.left.tab_count(), 2, "the left panel gained a tab"); + assert_eq!(state.right.tab_count(), 1, "the right panel is untouched"); + assert_eq!(state.active, PanelSide::Right, "focus stays on the right panel"); +} + +/// Left → Close tab closes a tab on the left panel regardless of focus. +#[test] +fn left_menu_close_tab_closes_a_left_tab_without_moving_focus() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.left.open_tab(); + let (state, _) = update(state, Command::MenuOpen); + let close_index = crate::menu::entries(MenuId::Left).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Close tab")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = close_index; + let (state, _) = update(state, Command::MenuActivate); + assert_eq!(state.left.tab_count(), 1, "the left panel's tab closed"); + assert_eq!(state.active, PanelSide::Right, "focus stays on the right panel"); +} + +/// Commands → History opens the Alt+F8 command-history dialog. +#[test] +fn commands_menu_history_opens_the_history_dialog() { + let (state, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('c')); + let history_index = crate::menu::entries(MenuId::Commands).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "History")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = history_index; + let (state, _) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert!(state.history_dialog().is_some(), "Commands -> History opens the dialog"); +} + +/// Commands → Swap panels issues the swap (see the swap tests below). +#[test] +fn commands_menu_swap_panels_swaps_the_panels() { + let mut state = test_state(UiPhase::Panels); + complete_panel_listings(&mut state); + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('c')); + let swap_index = crate::menu::entries(MenuId::Commands).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Swap panels")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = swap_index; + let (state, _) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert_eq!(state.left.cwd, PathBuf::from("/right")); + assert_eq!(state.right.cwd, PathBuf::from("/left")); +} + +/// Commands → Menu file edit opens the user-menu file in the editor (see the +/// menu-file edit tests below). +#[test] +fn commands_menu_menu_file_edit_opens_the_user_menu_file_in_the_editor() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_path = PathBuf::from("/profile/usermenu.toml"); + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('c')); + let edit_index = + crate::menu::entries(MenuId::Commands).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Menu file edit")).unwrap(); + let mut state = state; + state.menu_mut().unwrap().selected = edit_index; + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none()); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/profile/usermenu.toml") }]); +} + +// --------------------------------------------------------------------- +// pulldown-menu-completion: the Alt+F8 command-history dialog (task 2.5; +// command-line "Command history dialog (Alt+F8)"; design D2) +// --------------------------------------------------------------------- + +fn history_state(entries: &[&str]) -> State { + let mut state = test_state(UiPhase::Panels); + state.history = entries.iter().map(|s| s.to_string()).collect(); + state +} + +#[test] +fn history_dialog_open_lists_history_newest_first() { + let state = history_state(&["cd C:\\work", "cd D:\\"]); + let (state, effects) = update(state, Command::HistoryDialogOpen); + assert!(effects.is_empty()); + let dialog = state.history_dialog().expect("the dialog opens"); + assert_eq!(dialog.cursor, 0, "the highlight starts on the newest entry"); +} + +#[test] +fn history_dialog_confirm_places_the_entry_with_the_caret_at_the_end() { + // Spec scenario "Pick an entry": history `cd C:\work` then `cd D:\`; + // Alt+F8, Down, Enter -> the buffer reads `cd C:\work`, caret at end. + let state = history_state(&["cd C:\\work", "cd D:\\"]); + let (state, _) = update(state, Command::HistoryDialogOpen); + let (state, _) = update(state, Command::HistoryDialogMove(1)); + let (state, effects) = update(state, Command::HistoryDialogConfirm); + assert!(state.history_dialog().is_none(), "Enter closes the dialog"); + assert!(effects.is_empty(), "nothing runs — the entry is only placed"); + assert_eq!(state.command_line.text(), "cd C:\\work"); + assert_eq!(state.command_line.caret(), "cd C:\\work".chars().count(), "the caret sits at the end"); + assert_eq!(state.history_cursor, None, "no history recall is in progress"); +} + +#[test] +fn history_dialog_confirm_ends_an_up_down_recall_in_progress() { + let mut state = history_state(&["cd C:\\work", "cd D:\\"]); + state.command_line = LineEdit::from("cd D:\\"); + state.history_cursor = Some(1); + let (state, _) = update(state, Command::HistoryDialogOpen); + let (state, _) = update(state, Command::HistoryDialogConfirm); // cursor 0 = newest = "cd D:\" + assert_eq!(state.command_line.text(), "cd D:\\"); + assert_eq!(state.history_cursor, None); +} + +#[test] +fn history_dialog_cancel_leaves_the_buffer_alone() { + // Spec scenario "Esc leaves the buffer alone". + let mut state = history_state(&["cd C:\\work"]); + state.command_line = LineEdit::from("cd x"); + let (state, _) = update(state, Command::HistoryDialogOpen); + let (state, effects) = update(state, Command::HistoryDialogCancel); + assert!(state.history_dialog().is_none(), "Esc closes the dialog"); + assert!(effects.is_empty()); + assert_eq!(state.command_line.text(), "cd x", "the buffer is unchanged"); +} + +#[test] +fn history_dialog_empty_history_shows_no_history_and_enter_does_nothing() { + // Spec scenario "Empty history": Enter leaves the buffer unchanged with + // the dialog open until Esc. + let state = history_state(&[]); + let (state, _) = update(state, Command::HistoryDialogOpen); + let before = state.command_line.clone(); + let (state, effects) = update(state, Command::HistoryDialogConfirm); + assert!(state.history_dialog().is_some(), "Enter on the empty history keeps the dialog open"); + assert!(effects.is_empty()); + assert_eq!(state.command_line, before, "the buffer is unchanged"); + let (state, _) = update(state, Command::HistoryDialogCancel); + assert!(state.history_dialog().is_none(), "Esc still closes it"); +} + +#[test] +fn history_dialog_move_clamps_at_both_ends() { + let state = history_state(&["one", "two", "three"]); + let (state, _) = update(state, Command::HistoryDialogOpen); + let (state, _) = update(state, Command::HistoryDialogMove(-1)); + assert_eq!(state.history_dialog().unwrap().cursor, 0, "Up at the top stays put"); + let (state, _) = update(state, Command::HistoryDialogMove(isize::MAX / 2)); // End/PgDn + assert_eq!(state.history_dialog().unwrap().cursor, 2, "End lands on the oldest entry"); + let (state, _) = update(state, Command::HistoryDialogMove(1)); + assert_eq!(state.history_dialog().unwrap().cursor, 2, "Down at the bottom stays put"); +} + +#[test] +fn history_dialog_commands_without_an_open_dialog_are_no_ops() { + // The input mapper only emits these while the dialog is open; the + // reducer still never acts on them otherwise (same convention as the + // other overlay command families). + let state = history_state(&["cd C:\\work"]); + for cmd in [Command::HistoryDialogMove(1), Command::HistoryDialogConfirm, Command::HistoryDialogCancel] { + let before = state.clone(); + let (after, effects) = update(before.clone(), cmd); + assert!(effects.is_empty()); + assert_eq!(after, before); + } +} + +// --------------------------------------------------------------------- +// M3: drive select +// --------------------------------------------------------------------- + +#[test] +fn alt_f1_and_alt_f2_target_their_own_panels() { + for side in [PanelSide::Left, PanelSide::Right] { + let (_, effects) = update(test_state(UiPhase::Panels), Command::OpenDriveSelect(side)); + assert_eq!(effects, vec![Effect::EnumerateDrives(side)]); + } +} + +#[test] +fn drive_list_opens_the_dialog_and_requests_every_label_lazily() { + let (state, effects) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C', 'D'] }); + let dialog = state.drive_select().expect("the dialog opened"); + assert_eq!(dialog.target, PanelSide::Left); + assert_eq!(dialog.drives.len(), 3, "every letter is present on the first frame"); + assert!(dialog.drives.iter().all(|d| d.label.is_none()), "labels are still pending"); + let generation = dialog.generation; + assert_eq!( + effects, + vec![ + Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'A', generation }, + Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'C', generation }, + Effect::FetchDriveLabel { target: PanelSide::Left, letter: 'D', generation }, + ] + ); +} + +#[test] +fn the_dialog_opens_on_the_target_panel_s_current_drive() { + let mut state = test_state(UiPhase::Panels); + state.right.cwd = PathBuf::from(r"D:\work"); + let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Right, drives: vec!['C', 'D'] }); + assert_eq!(state.drive_select().unwrap().selected_letter(), Some('D')); +} + +#[test] +fn a_resolved_label_fills_in_place_without_moving_other_rows() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C'] }); + let generation = state.drive_select().unwrap().generation; + let (state, effects) = + update(state, Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation }); + assert!(effects.is_empty()); + let dialog = state.drive_select().unwrap(); + assert_eq!(dialog.drives, vec![DriveEntry { letter: 'A', label: None }, DriveEntry { letter: 'C', label: Some("OS".to_string()) }]); +} + +#[test] +fn a_label_arriving_after_the_dialog_closed_is_discarded() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); + let generation = state.drive_select().unwrap().generation; + let (state, _) = update(state, Command::DriveSelectCancel); + assert!(state.drive_select().is_none()); + + let (state, effects) = + update(state, Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation }); + assert!(state.drive_select().is_none(), "a stale result must not resurrect the dialog"); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "and must not touch the panel"); +} + +#[test] +fn a_label_for_a_superseded_target_panel_is_discarded() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); + let first_generation = state.drive_select().unwrap().generation; + // The dialog was reopened for the other panel before the label landed. + let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Right, drives: vec!['C'] }); + let (state, _) = update( + state, + Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("OS".to_string()), generation: first_generation }, + ); + assert_eq!(state.drive_select().unwrap().drives[0].label, None); +} + +#[test] +fn a_stale_drive_label_from_a_quick_reopen_of_the_same_target_is_discarded_but_the_fresher_one_applies() { + // Regression coverage for the out-of-order-completion race: the user + // cancels and reopens the drive dialog for the *same* panel before the + // first fetch lands (Alt+F1, Esc, Alt+F1 again). `target` alone can't + // tell the two sessions apart — only `generation` can. + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); + let first_generation = state.drive_select().unwrap().generation; + + let (state, _) = update(state, Command::DriveSelectCancel); + let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); + let second_generation = state.drive_select().unwrap().generation; + assert_ne!(first_generation, second_generation, "reopening mints a fresh generation"); + + let (state, _) = update( + state, + Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("STALE".to_string()), generation: first_generation }, + ); + assert_eq!(state.drive_select().unwrap().drives[0].label, None, "the stale (first-generation) answer is dropped"); + + let (state, _) = update( + state, + Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("FRESH".to_string()), generation: second_generation }, + ); + assert_eq!(state.drive_select().unwrap().drives[0].label, Some("FRESH".to_string()), "the current generation's answer applies"); +} + +#[test] +fn esc_dismisses_the_dialog_leaving_the_panel_where_it_was() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\Users"); + let (state, _) = update(state, Command::DriveListReady { target: PanelSide::Left, drives: vec!['C', 'D'] }); + let (state, effects) = update(state, Command::DriveSelectCancel); + assert!(state.drive_select().is_none()); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\Users")); +} + +#[test] +fn selecting_a_drive_switches_the_target_panel_to_its_root() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Right, drives: vec!['C', 'D'] }); + let (state, _) = update(state, Command::DriveSelectMove(1)); + let (state, effects) = update(state, Command::DriveSelectConfirm); + assert!(state.drive_select().is_none(), "confirming closes the dialog"); + assert_eq!(state.right.cwd, PathBuf::from(r"D:\")); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Right, r"D:\")]); +} + +#[test] +fn selecting_an_unavailable_drive_surfaces_the_panel_error_state() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['A'] }); + let (state, effects) = update(state, Command::DriveSelectConfirm); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, r"A:\")]); + // The worker's read fails and comes back as a normal listing failure — + // no dedicated hang-prone path. + let (state, _) = listing_failed(state, PanelSide::Left, "device not ready".into()); + assert_eq!(state.left.last_error.as_deref(), Some("device not ready")); +} + +#[test] +fn drive_select_claims_navigation_keys_while_open() { + let (state, _) = update(test_state(UiPhase::Panels), Command::DriveListReady { target: PanelSide::Left, drives: vec!['A', 'C', 'D'] }); + let (state, _) = update(state, Command::DriveSelectMove(2)); + assert_eq!(state.drive_select().unwrap().selected_letter(), Some('D')); + let (state, _) = update(state, Command::DriveSelectMove(-1)); + assert_eq!(state.drive_select().unwrap().selected_letter(), Some('C')); +} + +#[test] +fn fuzzy_jump_open_and_esc_close_without_navigating() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FuzzyJumpOpen); + assert!(state.fuzzy_jump().is_some()); + let (state, effects) = update(state, Command::FuzzyJumpCancel); + assert!(state.fuzzy_jump().is_none()); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "the panel is unchanged"); +} + +#[test] +fn fuzzy_jump_typing_and_backspace_edit_the_pattern() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FuzzyJumpOpen); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('d'))); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('o'))); + assert_eq!(state.fuzzy_jump().unwrap().pattern.text(), "do"); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Backspace)); + assert_eq!(state.fuzzy_jump().unwrap().pattern.text(), "d"); +} + +#[test] +fn fuzzy_jump_confirm_navigates_the_active_panel_to_the_highlighted_directory() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.dir_history = vec![history_entry(r"C:\Low", 1, 0), history_entry(r"C:\High", 10, 0)]; + let (state, _) = update(state, Command::FuzzyJumpOpen); + // No pattern typed: the full frecency-ranked list shows, most frecent + // first, so the default (cursor 0) highlight is `C:\High`. + let (state, effects) = update(state, Command::FuzzyJumpConfirm); + assert!(state.fuzzy_jump().is_none(), "the dialog closes"); + assert_eq!(state.right.cwd, PathBuf::from(r"C:\High")); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "the opposite panel is unaffected"); + assert!(without_git_info_effects(effects).iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Right, .. }))); +} + +#[test] +fn fuzzy_jump_move_clamps_within_the_currently_filtered_list() { + let mut state = test_state(UiPhase::Panels); + state.dir_history = vec![history_entry(r"C:\A", 1, 0), history_entry(r"C:\B", 1, 0)]; + let (state, _) = update(state, Command::FuzzyJumpOpen); + let (state, _) = update(state, Command::FuzzyJumpMove(-5)); + assert_eq!(state.fuzzy_jump().unwrap().cursor, 0); + let (state, _) = update(state, Command::FuzzyJumpMove(5)); + assert_eq!(state.fuzzy_jump().unwrap().cursor, 1); +} + +/// R-13 regression (fuzzy-jump "Recency survives a restart"; history- +/// persistence-hardening design D1): an earlier session stamped `C:\old` +/// at epoch E and `C:\new` three days later. A fresh `State` — monotonic +/// clock back near zero, wall clock a minute after the last visit — ranks +/// `C:\new` first, because ranking reads `epoch_ms`, not `clock_ms`. +#[test] +fn fuzzy_jump_ranks_with_the_epoch_clock_so_recency_survives_a_restart() { + let e = 1_787_000_000_000u64; + let day = 24 * 60 * 60 * 1000; + let mut state = test_state(UiPhase::Panels); + state.clock_ms = 500; // a fresh process: the monotonic clock restarted + state.epoch_ms = e + 3 * day + 60_000; + state.dir_history = vec![history_entry(r"C:\old", 3, e), history_entry(r"C:\new", 3, e + 3 * day)]; + let (state, _) = update(state, Command::FuzzyJumpOpen); + let (state, effects) = update(state, Command::FuzzyJumpConfirm); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\new"), "Enter on the top row lands on the more recently visited directory"); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { path, .. } if path == Path::new(r"C:\new")))); +} + +/// A tick carries both clocks, and a navigation stamps its visit with the +/// epoch reading (design D1). +#[test] +fn tick_updates_both_clocks_and_visits_are_stamped_with_epoch_time() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, _) = update(state, Command::Tick { now_ms: 42, epoch_ms: 1_787_000_000_000 }); + assert_eq!(state.clock_ms, 42); + assert_eq!(state.epoch_ms, 1_787_000_000_000); + let (state, _) = update(state, Command::Enter); + assert_eq!( + state.dir_history, + vec![history_entry("/left/sub", 1, 1_787_000_000_000)], + "the visit carries wall-clock time, not the monotonic reading" + ); +} + +// --------------------------------------------------------------------- +// M5: Alt+F7 find file +// --------------------------------------------------------------------- + +fn find_match(rel: &str, name: &str) -> FindMatch { + FindMatch { relative_path: PathBuf::from(rel), entry: dir_entry_named(name) } +} + +fn dir_entry_named(name: &str) -> Entry { + Entry::new(OsString::from(name), EntryKind::File, 0, None, false, false) +} + +#[test] +fn find_file_open_roots_at_the_active_panels_directory() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.right.cwd = PathBuf::from(r"C:\proj"); + let (state, _) = update(state, Command::FindFileOpen); + assert_eq!(state.find_file().unwrap().root, PathBuf::from(r"C:\proj")); +} + +#[test] +fn find_file_submit_mints_a_request_and_dispatches_the_walk_effect() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\proj"); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('r'))); + let (state, effects) = update(state, Command::FindFileSubmit); + match effects.as_slice() { + [Effect::FindInSubtree { root, pattern, request }] => { + assert_eq!(root, &PathBuf::from(r"C:\proj")); + assert_eq!(pattern, "r"); + assert_eq!(state.find_file().unwrap().request, Some(*request)); + } + other => panic!("expected exactly one FindInSubtree effect, got {other:?}"), + } +} + +#[test] +fn find_file_matches_from_a_superseded_request_are_dropped() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::FindFileSubmit); // request 1 + let (state, _) = update(state, Command::FindFileMatch { request: 999, m: find_match("a.txt", "a.txt") }); + assert!(state.find_file().unwrap().results.is_empty(), "a stale-request match must be dropped"); +} + +#[test] +fn find_file_confirm_navigates_in_place_and_seeds_the_cursor_target() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\proj"); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::FindFileSubmit); + let request = state.find_file().unwrap().request.unwrap(); + let (state, _) = update(state, Command::FindFileMatch { request, m: find_match(r"sub\report.txt", "report.txt") }); + let (state, effects) = update(state, Command::FindFileConfirm); + assert!(state.find_file().is_none(), "the dialog closes"); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\proj\sub"), "navigates to the match's containing directory"); + assert_eq!(state.left.pending_cursor_target, Some(OsString::from("report.txt"))); + assert!(without_git_info_effects(effects).iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. }))); +} + +#[test] +fn find_file_confirm_with_a_root_level_match_navigates_to_the_root() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\proj"); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::FindFileSubmit); + let request = state.find_file().unwrap().request.unwrap(); + let (state, _) = update(state, Command::FindFileMatch { request, m: find_match("readme.txt", "readme.txt") }); + let (state, _) = update(state, Command::FindFileConfirm); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\proj")); +} + +#[test] +fn find_file_cancel_abandons_the_search_without_navigating() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::FindFileSubmit); + let request = state.find_file().unwrap().request.unwrap(); + let (state, effects) = update(state, Command::FindFileCancel); + assert!(state.find_file().is_none()); + // The running walk is told to stop (find-file "Esc stops the walk"); + // nothing else — in particular no navigation — happens. + assert_eq!(effects, vec![Effect::CancelRequest(request)]); + assert_eq!(state.left.cwd, PathBuf::from("/left")); +} + +// --------------------------------------------------------------------- +// listing-request-guard: find-file and viewer searches stop when dismissed +// or superseded (find-file "Dismissed or superseded searches stop"; viewer +// "Search stops when superseded or when the viewer closes"; tasks 5.8/5.9) +// --------------------------------------------------------------------- + +#[test] +fn find_file_cancel_and_confirm_stop_a_running_walk_but_not_a_finished_one() { + // Esc while the walk runs: `CancelRequest(request)`. + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FindFileOpen); + let (state, _) = update(state, Command::FindFileSubmit); + let request = state.find_file().unwrap().request.unwrap(); + let (_, effects) = update(state.clone(), Command::FindFileCancel); + assert_eq!(effects, vec![Effect::CancelRequest(request)], "Esc stops the walk"); + + // Enter on a result while the walk runs: the cancel precedes the + // navigation's own effects (find-file "Confirming a result while the + // walk is running stops it"). + let (state, _) = update(state, Command::FindFileMatch { request, m: find_match(r"sub\report.txt", "report.txt") }); + let (confirmed, effects) = update(state.clone(), Command::FindFileConfirm); + assert_eq!(effects.first(), Some(&Effect::CancelRequest(request)), "the running walk is stopped first, got {effects:?}"); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { panel: PanelSide::Left, .. })), "the navigation still happens"); + assert_eq!(confirmed.left.cwd, PathBuf::from("/left/sub")); + + // Once the walk has reported done there is nothing to stop: neither + // Esc nor Enter cancels it. (Enter's navigation still cancels the + // panel's own still-streaming *listing* — a different request.) + let (state, _) = update(state, Command::FindFileSearchDone { request }); + let (_, effects) = update(state.clone(), Command::FindFileCancel); + assert!(effects.is_empty(), "a finished walk is not cancelled: {effects:?}"); + let (_, effects) = update(state, Command::FindFileConfirm); + assert!(!effects.contains(&Effect::CancelRequest(request)), "a finished walk is not cancelled: {effects:?}"); +} + +#[test] +fn find_file_cancel_before_any_search_was_submitted_emits_nothing() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FindFileOpen); + let (state, effects) = update(state, Command::FindFileCancel); + assert!(state.find_file().is_none()); + assert!(effects.is_empty(), "no walk was ever started, so there is nothing to stop"); +} + +#[test] +fn closing_the_viewer_or_starting_a_newer_search_stops_the_earlier_one() { + // viewer "A newer search supersedes a running one": the second F7 + // confirm cancels the first id before minting its own. + let state = opened_viewer_state(1000); + let (state, first) = viewer_with_pattern_confirmed(state, "ab"); + let (state, _) = update(state, Command::ViewerSearchStart); + let (state, _) = update(state, Command::ViewerSearchChar('c')); + let (state, effects) = update(state, Command::ViewerSearchConfirm); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + let second = v.search_request.expect("the second search mints its own id"); + assert_ne!(first, second); + assert_eq!( + effects, + vec![ + Effect::CancelRequest(first), + Effect::RunViewerSearch { path: PathBuf::from("/left/f.txt"), start_offset: 0, pattern: b"c".to_vec(), request: second }, + ] + ); + + // viewer "Closing the viewer stops a running search": F10 cancels the + // outstanding id. The id stays recorded after a result arrives, so the + // cancel is emitted then too — the TUI's registry treats an id with no + // running worker as a no-op (design D3). + let (state, effects) = update(state, Command::ViewerClose); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(effects, vec![Effect::CancelRequest(second)]); + + // A viewer that never searched has nothing to cancel. + let (_, effects) = update(opened_viewer_state(1000), Command::ViewerClose); + assert!(effects.is_empty()); +} + +#[test] +fn listing_complete_settles_the_cursor_on_a_pending_find_file_target() { + let mut state = test_state(UiPhase::Panels); + state.left.pending_cursor_target = Some(OsString::from("b.txt")); + let (state, _) = listing_chunk(state, PanelSide::Left, vec![dir_entry_named("a.txt"), dir_entry_named("b.txt"), dir_entry_named("c.txt")]); + let (state, _) = listing_complete(state, PanelSide::Left, 3); + assert_eq!(state.left.cursor, 1); + assert!(state.left.pending_cursor_target.is_none(), "consumed once applied"); +} + +// --------------------------------------------------------------------- +// M5: F2 user menu +// --------------------------------------------------------------------- + +#[test] +fn user_menu_confirm_dispatches_the_highlighted_commands_shell_passthrough() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.right.cwd = PathBuf::from(r"C:\Projects\app"); + state.user_menu_entries = vec![ + crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }, + crate::config::UserMenuEntry { label: "Build".to_string(), command: "cargo build".to_string() }, + ]; + let (state, _) = update(state, Command::UserMenuOpen); + let (state, _) = update(state, Command::UserMenuMove(1)); + let (state, effects) = update(state, Command::UserMenuConfirm); + assert!(state.user_menu().is_none(), "the menu closes"); + match effects.as_slice() { + [Effect::RunShellCommand(inv, side)] => { + assert_eq!(inv.cwd, PathBuf::from(r"C:\Projects\app")); + assert!(inv.args.iter().any(|a| a.contains("cargo build")), "{:?}", inv.args); + assert_eq!(*side, PanelSide::Right); + } + other => panic!("expected exactly one RunShellCommand effect, got {other:?}"), + } +} + +#[test] +fn user_menu_esc_closes_without_running_anything() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let (state, effects) = update(state, Command::UserMenuCancel); + assert!(state.user_menu().is_none()); + assert!(effects.is_empty()); +} + +#[test] +fn user_menu_move_clamps_and_confirm_on_an_empty_menu_is_a_harmless_close() { + let state = test_state(UiPhase::Panels); // user_menu_entries is empty + let (state, _) = update(state, Command::UserMenuOpen); + let (state, _) = update(state, Command::UserMenuMove(5)); + assert_eq!(state.user_menu().unwrap().cursor, 0); + let (state, effects) = update(state, Command::UserMenuConfirm); + assert!(state.user_menu().is_none()); + assert!(effects.is_empty(), "nothing to run on an empty menu"); +} + +// --------------------------------------------------------------------- +// user-menu-themes-entry: built-in Themes slot +// --------------------------------------------------------------------- + +#[test] +fn user_menu_down_past_the_last_user_entry_lands_on_themes_and_clamps_there() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![ + crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }, + crate::config::UserMenuEntry { label: "B".to_string(), command: "echo b".to_string() }, + ]; + let (state, _) = update(state, Command::UserMenuOpen); + // Two user entries (indices 0, 1) plus the built-in slot at index 2. + let (state, _) = update(state, Command::UserMenuMove(2)); + assert_eq!(state.user_menu().unwrap().cursor, 2, "Down from the last user entry lands on the built-in Themes slot"); + let (state, _) = update(state, Command::UserMenuMove(1)); + assert_eq!(state.user_menu().unwrap().cursor, 2, "the built-in slot is the end of the domain: Down holds, it does not wrap"); +} + +#[test] +fn user_menu_confirm_on_themes_opens_the_picker_pre_highlighted_with_no_shell_effect() { + let mut state = test_state(UiPhase::Panels); + state.theme = Theme::terminal_green(); + state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let (state, _) = update(state, Command::UserMenuMove(1)); // off the one user entry, onto the built-in slot + assert_eq!(state.user_menu().unwrap().cursor, 1); + let (state, effects) = update(state, Command::UserMenuConfirm); + assert!(state.user_menu().is_none(), "the F2 menu closes"); + let picker = state.theme_picker().expect("Confirm on the built-in slot opens the theme picker"); + let expected = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "terminal-green").unwrap(); + assert_eq!(picker.highlight, expected, "the active theme's row is pre-highlighted, same as the Options -> Themes route"); + assert!(effects.is_empty(), "no shell effect is emitted for the built-in slot"); +} + +#[test] +fn user_menu_confirm_on_a_user_entry_still_runs_it_via_the_shell_unaffected_by_the_built_in_slot() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + state.right.cwd = PathBuf::from(r"C:\Projects\app"); + state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "Build".to_string(), command: "cargo build".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let (state, effects) = update(state, Command::UserMenuConfirm); + assert!(state.user_menu().is_none()); + assert!(state.theme_picker().is_none(), "a user entry never opens the theme picker"); + match effects.as_slice() { + [Effect::RunShellCommand(inv, side)] => { + assert_eq!(inv.cwd, PathBuf::from(r"C:\Projects\app")); + assert!(inv.args.iter().any(|a| a.contains("cargo build")), "{:?}", inv.args); + assert_eq!(*side, PanelSide::Right); + } + other => panic!("expected exactly one RunShellCommand effect, got {other:?}"), + } +} + +#[test] +fn theme_picker_esc_after_f2_origin_returns_to_the_panels_without_reopening_the_user_menu() { + let mut state = test_state(UiPhase::Panels); + state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "A".to_string(), command: "echo a".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let (state, _) = update(state, Command::UserMenuMove(1)); // onto the built-in Themes slot + let (state, _) = update(state, Command::UserMenuConfirm); + assert!(state.user_menu().is_none() && state.theme_picker().is_some()); + let (state, effects) = update(state, Command::ThemePickerCancel); + assert!(state.theme_picker().is_none(), "the picker closes"); + assert!(state.user_menu().is_none(), "Esc lands on the panels, not back on the F2 menu (design D5)"); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// visual-themes: Options -> Themes picker +// --------------------------------------------------------------------- + +#[test] +fn theme_picker_opens_from_the_options_menu_with_the_active_theme_highlighted() { + let mut state = test_state(UiPhase::Panels); + state.theme = Theme::terminal_green(); + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(state, Command::MenuHotkey('o')); + let (state, effects) = update(state, Command::MenuActivate); + assert!(state.menu().is_none(), "the menu overlay closes"); + let picker = state.theme_picker().expect("Options -> Themes opens the picker"); + let expected = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "terminal-green").unwrap(); + assert_eq!(picker.highlight, expected, "the active theme's row is pre-highlighted"); + assert!(effects.is_empty()); +} + +#[test] +fn theme_picker_up_down_moves_the_highlight_over_the_theme_list() { + let state = test_state(UiPhase::Panels); // active theme is nc-classic (index 0) + let (state, _) = update(state, Command::ThemePickerOpen); + assert_eq!(state.theme_picker().unwrap().highlight, 0); + let (state, _) = update(state, Command::ThemePickerMove(1)); + assert_eq!(state.theme_picker().unwrap().highlight, 1); + let (state, _) = update(state, Command::ThemePickerMove(-1)); + assert_eq!(state.theme_picker().unwrap().highlight, 0, "Up from the first row holds, it does not wrap"); +} + +#[test] +fn theme_picker_enter_applies_the_highlighted_theme_immediately_and_persists_it() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::ThemePickerOpen); + // Move down to `yellow-storm` (index 4 of BUILTIN_THEME_NAMES). + let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "yellow-storm").unwrap(); + let (state, _) = update(state, Command::ThemePickerMove(target as isize)); + let (state, effects) = update(state, Command::ThemePickerConfirm); + assert!(state.theme_picker().is_none(), "the dialog closes"); + assert_eq!(state.theme.name, "yellow-storm", "the active theme switches in this same reducer step"); + assert_eq!(effects, vec![Effect::PersistTheme("yellow-storm".to_string())]); +} + +#[test] +fn theme_picker_esc_changes_nothing() { + let state = test_state(UiPhase::Panels); // active theme is nc-classic + let (state, _) = update(state, Command::ThemePickerOpen); + let (state, _) = update(state, Command::ThemePickerMove(3)); // highlight some other theme + let (state, effects) = update(state, Command::ThemePickerCancel); + assert!(state.theme_picker().is_none(), "the dialog closes"); + assert_eq!(state.theme.name, "nc-classic", "the active theme is untouched"); + assert!(effects.is_empty(), "nothing is persisted on cancel"); +} + +// --------------------------------------------------------------------- +// theme-picker-live-preview: State::render_theme() +// --------------------------------------------------------------------- + +#[test] +fn render_theme_before_opening_the_picker_equals_the_active_theme() { + let mut state = test_state(UiPhase::Panels); + state.theme = Theme::terminal_green(); + assert_eq!(state.render_theme(), state.theme, "no picker open: render theme is the active theme"); +} + +#[test] +fn render_theme_while_open_tracks_the_highlighted_theme_as_it_moves() { + let mut state = test_state(UiPhase::Panels); // active theme is nc-classic (index 0) + state.theme = Theme::classic(); + let (state, _) = update(state, Command::ThemePickerOpen); + // Opening is visually a no-op: highlight starts on the active theme. + assert_eq!(state.render_theme(), state.theme, "opening previews the active theme first"); + + let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "purple-lights").unwrap(); + let (state, _) = update(state, Command::ThemePickerMove(target as isize)); + assert_eq!(state.render_theme().name, "purple-lights", "moving the highlight previews the newly highlighted theme"); + assert_eq!(state.theme.name, "nc-classic", "the applied theme is untouched by moving the highlight"); +} + +#[test] +fn render_theme_after_cancel_reverts_to_the_active_theme() { + let state = test_state(UiPhase::Panels); // active theme is nc-classic + let (state, _) = update(state, Command::ThemePickerOpen); + let (state, _) = update(state, Command::ThemePickerMove(3)); // preview some other theme + assert_ne!(state.render_theme().name, "nc-classic", "sanity: a non-active theme is previewed"); + let (state, _) = update(state, Command::ThemePickerCancel); + assert_eq!(state.render_theme(), state.theme, "after cancel, render theme is the (unchanged) active theme"); + assert_eq!(state.theme.name, "nc-classic", "cancel never touched the active theme"); +} + +#[test] +fn render_theme_after_confirm_equals_the_newly_applied_theme() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::ThemePickerOpen); + let target = crate::theme::BUILTIN_THEME_NAMES.iter().position(|n| *n == "yellow-storm").unwrap(); + let (state, _) = update(state, Command::ThemePickerMove(target as isize)); + let (state, _) = update(state, Command::ThemePickerConfirm); + assert_eq!(state.render_theme(), state.theme, "after confirm, render theme is the applied theme"); + assert_eq!(state.render_theme().name, "yellow-storm"); +} + +// --------------------------------------------------------------------- +// M5: F1 Help window + About dialog +// --------------------------------------------------------------------- + +#[test] +fn help_opens_with_about_filecommand_highlighted_first() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let help = state.help().unwrap(); + assert_eq!(help.cursor, 0); + assert_eq!(crate::dialogs::HELP_TOPICS[help.cursor], "About FileCommand"); + assert!(help.page.is_none()); + assert!(!help.about_open); +} + +#[test] +fn help_activate_on_about_opens_the_about_dialog_not_a_page() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::HelpActivate); + assert!(state.help().unwrap().about_open); + assert!(state.help().unwrap().page.is_none()); +} + +#[test] +fn help_activate_on_a_topic_opens_its_page() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::HelpMove(1)); // "Keyboard reference" + let (state, _) = update(state, Command::HelpActivate); + assert_eq!(state.help().unwrap().page, Some(1)); +} + +#[test] +fn help_cancel_returns_a_page_to_the_list_then_closes_the_window() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::HelpMove(1)); + let (state, _) = update(state, Command::HelpActivate); + assert!(state.help().unwrap().page.is_some()); + + let (state, _) = update(state, Command::HelpCancel); + assert!(state.help().is_some(), "Esc from a page returns to the list, not closing the window"); + assert!(state.help().unwrap().page.is_none()); + assert_eq!(state.help().unwrap().cursor, 1, "the highlight is preserved"); + + let (state, _) = update(state, Command::HelpCancel); + assert!(state.help().is_none(), "Esc from the list closes the window"); +} + +#[test] +fn help_cancel_dismisses_about_back_to_the_list_with_about_still_highlighted() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::HelpActivate); // opens About + let (state, _) = update(state, Command::HelpCancel); + assert!(state.help().is_some()); + assert!(!state.help().unwrap().about_open); + assert_eq!(state.help().unwrap().cursor, 0); +} + +#[test] +fn help_move_does_not_walk_past_the_ends_of_the_topic_list() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::HelpMove(-5)); + assert_eq!(state.help().unwrap().cursor, 0); + let last = crate::dialogs::HELP_TOPICS.len() - 1; + let state = (0..20).fold(state, |s, _| update(s, Command::HelpMove(1)).0); + assert_eq!(state.help().unwrap().cursor, last); +} + +// --------------------------------------------------------------------- +// M5 review fix: startup-warning modal (malformed usermenu.toml) +// --------------------------------------------------------------------- + +#[test] +fn dismiss_startup_warning_clears_it() { + let mut state = test_state(UiPhase::Panels); + state.set_startup_warning("usermenu.toml is malformed; F2 uses the default user menu".to_string()); + let (state, effects) = update(state, Command::DismissStartupWarning); + assert_eq!(state.startup_warning(), None); + assert!(effects.is_empty()); +} + +#[test] +fn dismiss_startup_warning_is_a_no_op_when_nothing_is_warned() { + let state = test_state(UiPhase::Panels); + assert_eq!(state.startup_warning(), None); + let (state, effects) = update(state, Command::DismissStartupWarning); + assert_eq!(state.startup_warning(), None); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// File-action-menu row clicks (file-action-menu-mouse-click; mouse-input +// "File-action menu entries are clickable") +// --------------------------------------------------------------------- + +/// Clicking a row activates it exactly like `Command::FileActionMenuConfirm` +/// would once the highlight is there — +/// `menu_item_click_activates_the_item_exactly_like_menu_activate`, mirrored +/// for the file-action menu. +#[test] +fn file_action_menu_item_click_activates_the_item_exactly_like_confirm() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let edit_index = state.file_action_menu().unwrap().entries.iter().position(|e| *e == FileActionMenuEntry::Edit).unwrap(); + let (state, effects) = update(state, Command::FileActionMenuItemClick(edit_index)); + assert!(state.file_action_menu().is_none(), "activating an entry closes the menu"); + assert_eq!(effects, vec![Effect::OpenEditor { request: 3, path: PathBuf::from("/left/notes.txt") }]); +} + +#[test] +fn file_action_menu_item_click_with_no_menu_open_is_a_no_op() { + let state = test_state(UiPhase::Panels); + let before = state.clone(); + let (state, effects) = update(state, Command::FileActionMenuItemClick(0)); + assert!(effects.is_empty()); + assert_eq!(state, before); +} + +/// A stale/out-of-range index (shouldn't normally happen given hit-testing, +/// but guarded anyway) leaves the menu untouched. +#[test] +fn file_action_menu_item_click_out_of_range_is_a_no_op() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); + let before = state.clone(); + let (state, effects) = update(state, Command::FileActionMenuItemClick(999)); + assert!(effects.is_empty()); + assert_eq!(state, before, "an out-of-range index leaves the menu untouched"); +} + +/// Clicking a row other than the highlighted one activates the clicked row, +/// not the previously-highlighted one (file-action-menu-mouse-click +/// "Clicking a different row than the highlighted one activates the clicked +/// row"). +#[test] +fn file_action_menu_item_click_on_a_non_highlighted_row_activates_the_clicked_row() { + let state = opened_menu_state(&["notes.txt"], "notes.txt"); // Open highlighted first + assert_eq!(state.file_action_menu().unwrap().selected(), FileActionMenuEntry::Open, "setup precondition"); + let delete_index = state.file_action_menu().unwrap().entries.iter().position(|e| *e == FileActionMenuEntry::Delete).unwrap(); + let (state, _) = update(state, Command::FileActionMenuItemClick(delete_index)); + assert!(state.file_action_menu().is_none()); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. })), "Delete was activated, not Open"); +} + +// --------------------------------------------------------------------- +// ui-animations: the reducer only records *when* things opened +// --------------------------------------------------------------------- + +/// A state whose clock reads `ms`, as if a `Tick(ms)` had just landed. +fn clocked(phase: UiPhase, ms: u64) -> State { + State { clock_ms: ms, ..test_state(phase) } +} + +#[test] +fn opening_the_bar_stamps_every_clock_from_the_reducer_clock() { + let (state, _) = update(clocked(UiPhase::Panels, 1_000), Command::MenuOpen); + let menu = state.menu().expect("the bar opens"); + assert_eq!(menu.opened_at_ms, 1_000); + assert_eq!(menu.pulldown_opened_at_ms, 1_000); + assert_eq!(menu.selected_at_ms, None, "opening is the unfold's job, not a highlight flash"); +} + +#[test] +fn stamps_come_from_the_last_tick_reading_not_a_live_clock() { + // Same inputs, same outputs: the stamp depends on `clock_ms` alone. + let a = update(clocked(UiPhase::Panels, 42), Command::MenuOpen).0; + let b = update(clocked(UiPhase::Panels, 42), Command::MenuOpen).0; + assert_eq!(a, b); + let later = update(clocked(UiPhase::Panels, 43), Command::MenuOpen).0; + assert_eq!(later.menu().unwrap().opened_at_ms, 43); +} + +#[test] +fn horizontal_traversal_restamps_the_pulldown_but_not_the_bar() { + let (state, _) = update(clocked(UiPhase::Panels, 1_000), Command::MenuOpen); + let (state, _) = update(state, Command::Tick { now_ms: 1_040, epoch_ms: 1_040 }); + let (state, _) = update(state, Command::MenuNextMenu); + let menu = state.menu().expect("still open"); + assert_eq!(menu.active, MenuId::Files); + assert!(menu.pulldown_open, "traversal never shows a closed intermediate state"); + assert_eq!(menu.opened_at_ms, 1_000, "the bar itself did not reopen"); + assert_eq!(menu.pulldown_opened_at_ms, 1_040, "the adjacent pull-down unfolds afresh"); + assert_eq!(menu.selected_at_ms, None); +} + +#[test] +fn hotkey_jump_and_title_click_restamp_the_pulldown_and_keep_the_bar_stamp() { + let (state, _) = update(clocked(UiPhase::Panels, 1_000), Command::MenuOpen); + let (state, _) = update(state, Command::Tick { now_ms: 1_030, epoch_ms: 1_030 }); + let (jumped, _) = update(state.clone(), Command::MenuHotkey('c')); + let menu = jumped.menu().expect("open"); + assert_eq!(menu.active, MenuId::Commands); + assert_eq!((menu.opened_at_ms, menu.pulldown_opened_at_ms), (1_000, 1_030)); + + // A title click rebuilds the whole `MenuState`; the bar stamp must survive. + let (clicked, _) = update(state, Command::MenuTitleClick(MenuId::Options)); + let menu = clicked.menu().expect("open"); + assert_eq!(menu.active, MenuId::Options); + assert_eq!((menu.opened_at_ms, menu.pulldown_opened_at_ms), (1_000, 1_030)); +} + +#[test] +fn moving_the_highlight_stamps_only_the_highlight() { + let (state, _) = update(clocked(UiPhase::Panels, 1_000), Command::MenuOpen); + let (state, _) = update(state, Command::Tick { now_ms: 1_200, epoch_ms: 1_200 }); + let (state, _) = update(state, Command::MenuSelectNext); + let menu = state.menu().cloned().expect("open"); + assert_eq!(menu.selected_at_ms, Some(1_200)); + assert_eq!((menu.opened_at_ms, menu.pulldown_opened_at_ms), (1_000, 1_000)); + + // A later tick with no selection change leaves the stamp alone. + let (state, _) = update(state, Command::Tick { now_ms: 1_500, epoch_ms: 1_500 }); + assert_eq!(state.menu().unwrap().selected_at_ms, Some(1_200)); +} + +#[test] +fn reopening_a_collapsed_pulldown_unfolds_it_again() { + let (state, _) = update(clocked(UiPhase::Panels, 1_000), Command::MenuOpen); + let (state, _) = update(state, Command::MenuCollapse); + assert!(!state.menu().unwrap().pulldown_open); + let (state, _) = update(state, Command::Tick { now_ms: 1_300, epoch_ms: 1_300 }); + // The hotkey of the *same* menu (`Left`) reopens its pull-down. + let (state, _) = update(state, Command::MenuHotkey('l')); + let menu = state.menu().expect("open"); + assert_eq!(menu.active, MenuId::Left); + assert!(menu.pulldown_open, "the hotkey reopens the collapsed pull-down"); + assert_eq!(menu.pulldown_opened_at_ms, 1_300); + assert_eq!(menu.opened_at_ms, 1_000, "the bar stayed open throughout"); +} + +#[test] +fn user_menu_stamps_open_and_highlight_moves() { + let mut state = clocked(UiPhase::Panels, 2_000); + state.user_menu_entries = + vec![UserMenuEntry { label: "A".to_string(), command: "a".to_string() }, UserMenuEntry { label: "B".to_string(), command: "b".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let menu = state.user_menu().expect("opens"); + assert_eq!((menu.opened_at_ms, menu.selected_at_ms), (2_000, None)); + + let (state, _) = update(State { clock_ms: 2_080, ..state }, Command::UserMenuMove(1)); + let menu = state.user_menu().expect("open"); + assert_eq!(menu.cursor, 1); + assert_eq!((menu.opened_at_ms, menu.selected_at_ms), (2_000, Some(2_080))); +} + +#[test] +fn file_action_menu_stamps_open_and_highlight_moves() { + let mut state = clocked(UiPhase::Panels, 3_000); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::Enter); + let menu = state.file_action_menu().cloned().expect("Enter on a file opens the action menu"); + assert_eq!((menu.opened_at_ms, menu.selected_at_ms), (3_000, None)); + + let (state, _) = update(State { clock_ms: 3_060, ..state }, Command::FileActionMenuMove(1)); + let menu = state.file_action_menu().expect("open"); + assert_eq!((menu.opened_at_ms, menu.selected_at_ms), (3_000, Some(3_060))); +} + +#[test] +fn dialogs_stamp_when_they_open() { + let (state, _) = update(clocked(UiPhase::Panels, 500), Command::RequestMkdir); + assert!(matches!(state.phase, UiPhase::FileOpSetup(_))); + assert_eq!(state.dialog_opened_at_ms, 500); + + let (state, _) = update(clocked(UiPhase::Panels, 700), Command::HelpOpen); + assert!(state.help().is_some()); + assert_eq!(state.dialog_opened_at_ms, 700); + + let (state, _) = update(clocked(UiPhase::Panels, 900), Command::FuzzyJumpOpen); + assert!(state.fuzzy_jump().is_some()); + assert_eq!(state.dialog_opened_at_ms, 900); + + let (state, _) = update(clocked(UiPhase::Panels, 1_100), Command::ThemePickerOpen); + assert!(state.theme_picker().is_some()); + assert_eq!(state.dialog_opened_at_ms, 1_100); +} + +#[test] +fn typing_into_a_dialog_does_not_restamp_it() { + let (state, _) = update(clocked(UiPhase::Panels, 500), Command::RequestMkdir); + let (state, _) = update(State { clock_ms: 640, ..state }, Command::TextEdit(TextEditOp::Insert('x'))); + assert_eq!(state.dialog_opened_at_ms, 500, "same dialog, no new iris"); +} + +#[test] +fn a_replacement_dialog_restamps_but_progress_updates_do_not() { + let state = State { clock_ms: 100, ..running_progress_state(JobKind::Copy, "/left", "/right") }; + let info = ProgressInfo { files_done: 1, files_total: 3, bytes_done: 10, bytes_total: 30, current_file: OsString::from("a.txt") }; + let (state, _) = update(State { clock_ms: 200, ..state }, Command::JobProgress(info)); + assert_eq!(state.dialog_opened_at_ms, 0, "progress counters ticking is not a new dialog"); + + // Progress -> conflict: a different dialog in the same slot irises again. + let (state, _) = update(State { clock_ms: 300, ..state }, Command::JobConflict(sample_conflict())); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. })); + assert_eq!(state.dialog_opened_at_ms, 300); +} + +#[test] +fn closing_a_dialog_stamps_nothing() { + let (state, _) = update(clocked(UiPhase::Panels, 700), Command::HelpOpen); + let (state, _) = update(State { clock_ms: 900, ..state }, Command::HelpCancel); + assert!(state.help().is_none()); + assert_eq!(state.dialog_opened_at_ms, 700); +} + +#[test] +fn the_quit_dialog_has_its_own_stamp_and_never_restamps_the_dialog_beneath() { + let (state, _) = update(clocked(UiPhase::Panels, 700), Command::HelpOpen); + let (state, _) = update(State { clock_ms: 950, ..state }, Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.quit_opened_at_ms, 950); + assert_eq!(state.dialog_opened_at_ms, 700, "the dialog under the quit prompt does not re-animate"); + + // Cancelling restores the context exactly and leaves both stamps alone. + let (state, _) = update(State { clock_ms: 1_200, ..state }, Command::CancelQuit); + assert!(!state.quit_confirm && state.help().is_some()); + assert_eq!((state.quit_opened_at_ms, state.dialog_opened_at_ms), (950, 700)); +} + +#[test] +fn leaving_the_splash_for_the_panels_stamps_the_reveal() { + // By the hold elapsing on a Tick... + let state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); + let (state, _) = update(state, Command::Tick { now_ms: 1_799, epoch_ms: 1_799 }); + assert_eq!(state.panels_revealed_at_ms, None, "still on the splash"); + let (state, _) = update(state, Command::Tick { now_ms: 1_800, epoch_ms: 1_800 }); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.panels_revealed_at_ms, Some(1_800)); + + // ...or by a dismissing key. + let (state, _) = update(State { clock_ms: 1_250, ..test_state(UiPhase::Splash { started_at_ms: 1_000 }) }, Command::ToggleActivePanel); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.panels_revealed_at_ms, Some(1_250)); +} + +#[test] +fn a_key_during_the_splash_still_dismisses_it_entirely_and_is_consumed() { + // startup-splash "Key mid-animation dismisses entirely": with animations + // on, a key 150 ms in goes straight to the panels and is not forwarded. + let mut state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); + state.animations = true; + state.clock_ms = 1_150; + let (state, effects) = update(state, Command::ToggleActivePanel); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.active, PanelSide::Left, "the dismissing key was consumed, not applied"); + assert!(effects.is_empty()); +} + +#[test] +fn no_reveal_is_recorded_when_the_splash_never_showed() { + // --nosplash / splash = false: frame 1 is the complete panel screen. + let (state, _) = State::initial(Theme::classic(), (80, 24), 0, 0, PathBuf::from("/"), PathBuf::from("/"), false); + assert_eq!(state.panels_revealed_at_ms, None); + let (state, _) = update(state, Command::Tick { now_ms: 5_000, epoch_ms: 5_000 }); + assert_eq!(state.panels_revealed_at_ms, None, "later ticks never invent a reveal"); + + // Below the floor the splash is skipped for the placeholder; growing + // back to Panels is not a Splash -> Panels transition either. + let (state, _) = State::initial(Theme::classic(), (40, 10), 0, 0, PathBuf::from("/"), PathBuf::from("/"), true); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); + let (state, _) = update(state, Command::Resize(80, 24)); + assert!(!state.undersized); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.panels_revealed_at_ms, None); +} + +#[test] +fn leaving_the_splash_via_the_placeholder_records_no_reveal() { + let state = test_state(UiPhase::Splash { started_at_ms: 0 }); + let (state, _) = update(state, Command::Resize(40, 10)); + let (state, _) = update(state, Command::Resize(80, 24)); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.panels_revealed_at_ms, None); +} + +#[test] +fn input_during_an_animation_applies_to_the_final_state() { + // ui-animations "Input during an animation applies to the final state": + // 30 ms into the unfold, Down moves the selection exactly as it does + // with animations disabled; the animation flag never changes a transition. + let open = |animations: bool| { + let mut state = clocked(UiPhase::Panels, 1_000); + state.animations = animations; + let (state, _) = update(state, Command::MenuOpen); + let (state, _) = update(State { clock_ms: 1_030, ..state }, Command::MenuSelectNext); + state + }; + let (on, off) = (open(true), open(false)); + assert_eq!(on.menu().map(|m| (m.active, m.pulldown_open, m.selected)), off.menu().map(|m| (m.active, m.pulldown_open, m.selected))); + assert_ne!(on.menu().unwrap().selected, MenuState::opened().selected, "Down really moved the highlight"); +} + +#[test] +fn animations_flag_is_off_in_empty_states_and_exposes_the_clock_only_when_on() { + let mut state = State::empty(Theme::classic()); + state.clock_ms = 77; + assert!(!state.animations, "States built without config never animate"); + assert_eq!(state.anim_now(), None); + state.animations = true; + assert_eq!(state.anim_now(), Some(77)); +} diff --git a/crates/filecommand-core/src/update/tests/panels.rs b/crates/filecommand-core/src/update/tests/panels.rs new file mode 100644 index 0000000..ac53449 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/panels.rs @@ -0,0 +1,2654 @@ +use super::*; + +// --------------------------------------------------------------------- +// M1/M2 regression coverage +// --------------------------------------------------------------------- + +#[test] +fn update_is_deterministic() { + let s1 = test_state(UiPhase::Panels); + let s2 = test_state(UiPhase::Panels); + let (r1, e1) = update(s1, Command::ToggleActivePanel); + let (r2, e2) = update(s2, Command::ToggleActivePanel); + assert_eq!(r1, r2); + assert_eq!(e1, e2); +} + +#[test] +fn directory_read_returns_intent_effect_without_io() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, effects) = update(state, Command::Enter); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left/sub")]); + // The panel's own entries were reset locally — no filesystem was touched. + assert!(state.left.entries.is_empty()); + assert!(matches!(state.left.progress, crate::panel::ListingProgress::Streaming { count: 0 })); +} + +#[test] +fn worker_events_reenter_through_update() { + let state = test_state(UiPhase::Panels); + let entries = vec![file_entry("a", 1)]; + let (state, effects) = listing_chunk(state, PanelSide::Left, entries.clone()); + assert!(effects.is_empty()); + assert_eq!(state.left.entries, entries); + let (state, effects) = listing_complete(state, PanelSide::Left, 1); + assert!(effects.is_empty()); + assert_eq!(state.left.progress, crate::panel::ListingProgress::Complete { count: 1 }); +} + +#[test] +fn toggle_active_panel_is_exclusive() { + let state = test_state(UiPhase::Panels); + assert_eq!(state.active, PanelSide::Left); + let (state, _) = update(state, Command::ToggleActivePanel); + assert_eq!(state.active, PanelSide::Right); + let (state, _) = update(state, Command::ToggleActivePanel); + assert_eq!(state.active, PanelSide::Left); +} + +#[test] +fn enter_on_parent_dir_navigates_up_and_resets_cursor() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/a/b"); + state.left.entries = vec![Entry::parent_dir()]; + state.left.cursor = 0; + let (state, effects) = update(state, Command::Enter); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/a")]); + assert_eq!(state.left.cursor, 0); +} + +#[test] +fn parent_nav_at_root_is_no_op() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/"); + let (state, effects) = update(state, Command::ParentDir); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from("/")); +} + +#[test] +fn f10_raises_the_quit_confirm_overlay_and_confirm_quits() { + // Post-quit-keys: the dialog is an overlay beside the phase + // (`State::quit_confirm: bool`), not a `UiPhase::QuitConfirm` variant — + // `RequestQuit` must not disturb `state.phase` at all (application-shell + // "Quit request keys and confirmation"; design D5). + let state = test_state(UiPhase::Panels); + let (state, effects) = update(state, Command::RequestQuit); + assert!(state.quit_confirm, "RequestQuit must raise the overlay"); + assert_eq!(state.phase, UiPhase::Panels, "the overlay lives beside the phase, not inside it"); + assert!(effects.is_empty()); + let (state, effects) = update(state, Command::ConfirmQuit); + assert_eq!(effects, vec![Effect::Quit]); + assert!(!state.quit_confirm); +} + +#[test] +fn quit_confirm_cancel_clears_only_the_overlay_flag() { + let mut state = test_state(UiPhase::Panels); + state.quit_confirm = true; + let (state, effects) = update(state, Command::CancelQuit); + assert!(!state.quit_confirm); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty()); +} + +// --------------------------------------------------------------------- +// quit-keys: the quit-confirmation overlay opens from every context and +// cancelling restores that context bit-for-bit (application-shell "Quit +// request keys and confirmation"; design D5; tasks 19.1/19.2/19.3) +// --------------------------------------------------------------------- + +#[test] +fn request_quit_opens_over_idle_panels_and_cancel_restores_them_exactly() { + let before = test_state(UiPhase::Panels); + let (state, effects) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert!(effects.is_empty()); + let (state, effects) = update(state, Command::CancelQuit); + assert!(!state.quit_confirm); + assert!(effects.is_empty()); + assert_eq!(state, before, "cancel must restore idle panels bit-for-bit"); +} + +#[test] +fn request_quit_opens_mid_command_line_and_cancel_restores_the_buffer() { + let before = type_line(test_state(UiPhase::Panels), "dir"); + let (state, _) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.command_line.text(), "dir", "the typed buffer must survive RequestQuit untouched"); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must restore the typed command line bit-for-bit"); +} + +#[test] +fn request_quit_opens_under_an_active_quick_filter_and_cancel_restores_it() { + let mut before = test_state(UiPhase::Panels); + before.left.set_quick_filter(Some("rep".to_string())); + let (state, _) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.left.quick_filter.as_deref(), Some("rep")); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must restore the active quick filter bit-for-bit"); +} + +#[test] +fn request_quit_opens_during_type_ahead_and_cancel_restores_it() { + let mut before = test_state(UiPhase::Panels); + before.quick_search = Some("re".to_string()); + let (state, _) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.quick_search.as_deref(), Some("re")); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must restore type-ahead bit-for-bit"); +} + +#[test] +fn request_quit_opens_above_the_viewer_and_cancel_restores_it() { + let (before, _) = update( + with_pending_open(test_state(UiPhase::Panels), 3), + Command::ViewerOpened { request: 3, path: PathBuf::from("/left/big.log"), file_len: 5_000_000_000 }, + ); + assert!(matches!(before.phase, UiPhase::Viewer(_)), "precondition: viewer must be open"); + let (state, effects) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm, "Ctrl+C/RequestQuit must open the overlay while the viewer is open"); + assert!(matches!(state.phase, UiPhase::Viewer(_)), "the overlay must not replace the viewer underneath it"); + assert!(effects.is_empty()); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must leave the viewer open, untouched, bit-for-bit"); +} + +#[test] +fn request_quit_opens_with_a_pull_down_menu_open_and_cancel_restores_it() { + let (before, _) = update(test_state(UiPhase::Panels), Command::MenuOpen); + assert!(before.menu().is_some(), "precondition: the menu must be open"); + let (state, effects) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.menu(), before.menu(), "the open menu must not be disturbed by RequestQuit"); + assert!(effects.is_empty()); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must leave the menu open, untouched, bit-for-bit"); +} + +#[test] +fn request_quit_opens_with_a_modal_dialog_open_and_cancel_restores_it() { + let (before, _) = update(test_state(UiPhase::Panels), Command::FuzzyJumpOpen); + assert!(before.fuzzy_jump().is_some(), "precondition: the fuzzy-jump dialog must be open"); + let (state, effects) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert_eq!(state.fuzzy_jump(), before.fuzzy_jump(), "the open dialog must not be disturbed by RequestQuit"); + assert!(effects.is_empty()); + let (state, _) = update(state, Command::CancelQuit); + assert_eq!(state, before, "cancel must leave the modal dialog open, untouched, bit-for-bit"); +} + +#[test] +fn confirming_quit_while_a_job_is_running_aborts_it_before_quitting() { + // Confirming quit while a file operation is running must abort the job + // through the existing cancel path (`Effect::CancelJob`, the same one + // the Progress dialog's own Cancel key uses) before `Effect::Quit` — + // order matters, since the caller must stop the worker before tearing + // the app down (application-shell "Quit request keys and + // confirmation"; design D3; task 19.3). + let mut state = running_progress_state(JobKind::Copy, "/left", "/right"); + state.quit_confirm = true; + let (state, effects) = update(state, Command::ConfirmQuit); + assert_eq!(effects, vec![Effect::CancelJob, Effect::Quit], "the job must be cancelled before the app quits"); + assert!(!state.quit_confirm); +} + +#[test] +fn request_quit_while_a_job_is_running_does_not_touch_the_job_until_confirmed() { + let before = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, effects) = update(before.clone(), Command::RequestQuit); + assert!(state.quit_confirm); + assert!(effects.is_empty(), "merely opening the dialog must not cancel the running job"); + assert!(matches!(state.phase, UiPhase::FileOpRunning { .. })); + let (state, effects) = update(state, Command::CancelQuit); + assert!(effects.is_empty(), "cancelling the quit dialog must not touch the job either"); + assert_eq!(state, before, "cancel must leave the running job untouched, bit-for-bit"); +} + +#[test] +fn splash_dismissed_immediately_by_key_before_min_hold() { + let state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); + let (state, effects) = update(state, Command::ToggleActivePanel); + assert_eq!(state.phase, UiPhase::Panels); + // The dismissing key was consumed, not forwarded: active panel must + // still be Left (unaffected by the ToggleActivePanel command). + assert_eq!(state.active, PanelSide::Left); + assert!(effects.is_empty()); +} + +#[test] +fn splash_auto_dismisses_after_min_hold_via_tick() { + let state = test_state(UiPhase::Splash { started_at_ms: 1_000 }); + let (state, _) = update(state, Command::Tick { now_ms: 1_799, epoch_ms: 1_799 }); + assert!(matches!(state.phase, UiPhase::Splash { .. })); + let (state, _) = update(state, Command::Tick { now_ms: 1_800, epoch_ms: 1_800 }); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn placeholder_below_min_and_grows_back_to_panels_never_splash() { + // resize-preserves-session-state design D1/D2: "too small" is the + // `undersized` flag beside the phase; the splash is dismissed for good. + let state = test_state(UiPhase::Splash { started_at_ms: 0 }); + let (state, _) = update(state, Command::Resize(40, 10)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); + let (state, _) = update(state, Command::Resize(80, 24)); + assert!(!state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn too_small_boundary_at_59x16_is_placeholder() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::Resize(59, 16)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn too_small_boundary_at_60x15_is_placeholder() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::Resize(60, 15)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn too_small_boundary_at_60x16_is_panels() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::Resize(60, 16)); + assert!(!state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn splash_skipped_when_starting_below_60x16_floor() { + let (state, _) = State::initial(Theme::classic(), (59, 16), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), true); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn splash_starts_normally_at_exactly_60x16() { + let (state, _) = State::initial(Theme::classic(), (60, 16), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), true); + assert_eq!(state.phase, UiPhase::Splash { started_at_ms: 0 }); +} + +#[test] +fn placeholder_replaces_splash_when_resized_below_new_floor_mid_splash() { + let state = test_state(UiPhase::Splash { started_at_ms: 0 }); + let (state, _) = update(state, Command::Resize(59, 16)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); + // Enlarging back never returns to Splash, only Panels. + let (state, _) = update(state, Command::Resize(60, 16)); + assert!(!state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +// --------------------------------------------------------------------- +// Adjustable panel split (panel-split) +// --------------------------------------------------------------------- + +#[test] +fn split_grow_moves_divider_two_columns_right() { + // panel-split "Divider moves in 2-column steps": 50/50 at 100 columns, + // Ctrl+Right widens the left panel to 52. + let mut state = test_state(UiPhase::Panels); + state.term_size = (100, 24); + let (state, effects) = update(state, Command::SplitGrow); + assert_eq!(state.split_percent, 52); + assert_eq!(effects, vec![Effect::PersistPanelSplit(52)]); +} + +#[test] +fn split_shrink_moves_divider_two_columns_left() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (100, 24); + let (state, effects) = update(state, Command::SplitShrink); + assert_eq!(state.split_percent, 48); + assert_eq!(effects, vec![Effect::PersistPanelSplit(48)]); +} + +#[test] +fn split_shrink_is_a_no_op_at_the_minimum() { + // panel-split "Adjustment at the limit is a no-op": the right panel is + // already at its 20-column minimum (left = 80 of 100). + let mut state = test_state(UiPhase::Panels); + state.term_size = (100, 24); + state.split_percent = 80; + let (state, effects) = update(state, Command::SplitGrow); + assert_eq!(state.split_percent, 80, "no change at the limit"); + assert!(effects.is_empty()); +} + +#[test] +fn split_reset_restores_fifty_fifty() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (100, 24); + state.split_percent = 66; + let (state, effects) = update(state, Command::SplitReset); + assert_eq!(state.split_percent, 50); + assert_eq!(effects, vec![Effect::PersistPanelSplit(50)]); +} + +#[test] +fn split_reset_is_a_no_op_when_already_at_default() { + let state = test_state(UiPhase::Panels); + assert_eq!(state.split_percent, 50); + let (state, effects) = update(state, Command::SplitReset); + assert_eq!(state.split_percent, 50); + assert!(effects.is_empty(), "no redundant persist when already at the default"); +} + +#[test] +fn split_change_persists_via_effect() { + let mut state = test_state(UiPhase::Panels); + state.term_size = (100, 24); + let (_, effects) = update(state, Command::SplitGrow); + assert!(matches!(effects.as_slice(), [Effect::PersistPanelSplit(_)])); +} + +#[test] +fn listing_chunk_during_splash_still_updates_panel_state() { + let state = test_state(UiPhase::Splash { started_at_ms: 0 }); + let entries = vec![file_entry("a", 0)]; + let (state, _) = listing_chunk(state, PanelSide::Left, entries.clone()); + assert_eq!(state.left.entries, entries); + assert!(matches!(state.phase, UiPhase::Splash { .. })); +} + +#[test] +fn initial_builds_start_listing_effects_for_both_panels() { + let (state, effects) = State::initial(Theme::classic(), (80, 24), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), true); + assert_eq!(state.phase, UiPhase::Splash { started_at_ms: 0 }); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/l"), start_listing(&state, PanelSide::Right, "/r"),]); +} + +#[test] +fn initial_below_min_size_skips_straight_to_placeholder() { + let (state, _) = State::initial(Theme::classic(), (40, 10), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), true); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn initial_without_splash_starts_at_panels() { + let (state, _) = State::initial(Theme::classic(), (80, 24), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), false); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn request_copy_with_no_selection_and_no_cursor_entry_is_noop() { + let state = test_state(UiPhase::Panels); + let (state, effects) = update(state, Command::RequestCopy); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty()); +} + +#[test] +fn request_copy_uses_cursor_entry_when_nothing_explicitly_selected() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 10)]; + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind, sources, .. }) => { + assert_eq!(kind, JobKind::Copy); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("a.txt")); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn request_copy_prefills_destination_with_opposite_panel_cwd() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 10)]; + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert_eq!(input.text(), PathBuf::from("/right").display().to_string()); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn request_copy_uses_explicit_selection_over_cursor() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.left.selected.insert(OsString::from("b.txt")); + let (state, _) = update(state, Command::RequestCopy); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { sources, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("b.txt")); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn request_mkdir_is_always_available_even_with_empty_panel() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::RequestMkdir); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Mkdir, .. }))); +} + +#[test] +fn destination_input_typing_and_confirm_starts_job() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestCopy); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Backspace)); // backspace on prefilled input + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('X'))); + let (state, effects) = update(state, Command::FileOpConfirm); + match &state.phase { + UiPhase::FileOpRunning { source_dir, dest_dir, dialog: RunningDialog::Progress { kind, .. } } => { + assert_eq!(*kind, JobKind::Copy); + assert_eq!(source_dir, &PathBuf::from("/left")); + assert!(dest_dir.to_string_lossy().ends_with('X')); + } + other => panic!("expected FileOpRunning Progress, got {other:?}"), + } + assert!(matches!(effects.as_slice(), [Effect::RunJob(_)])); +} + +#[test] +fn destination_input_confirm_with_empty_input_is_noop() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestMkdir); + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(effects.is_empty()); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { .. }))); +} + +#[test] +fn escape_cancels_destination_input_back_to_panels() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestCopy); + let (state, effects) = update(state, Command::FileOpCancel); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty()); +} + +#[test] +fn prompt_shows_the_active_panel_path_and_follows_focus() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.right.cwd = PathBuf::from(r"D:\WORK"); + assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"C:\NORTON").display())); + + let (state, _) = update(state, Command::ToggleActivePanel); + assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"D:\WORK").display())); +} + +#[test] +fn prompt_follows_the_active_panel_into_a_subdirectory() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/left"); + state.left.entries = vec![dir_entry("sub")]; + let (state, _) = update(state, Command::Enter); + assert_eq!(state.prompt(), format!("{}>", PathBuf::from("/left").join("sub").display())); +} + +#[test] +fn printable_keys_build_the_command_buffer_without_moving_the_cursor() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + state.left.cursor = 1; + let state = type_line(state, "dir"); + assert_eq!(state.command_line.text(), "dir"); + assert_eq!(state.left.cursor, 1, "typing must not move the panel cursor"); +} + +#[test] +fn backspace_edits_the_buffer_and_clears_the_history_cursor() { + let mut state = test_state(UiPhase::Panels); + state.history = vec!["old".to_string()]; + let state = type_line(state, "dir"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "old"); + assert_eq!(state.history_cursor, Some(0)); + + let (state, _) = update(state, Command::TextEdit(TextEditOp::Backspace)); + assert_eq!(state.command_line.text(), "ol"); + assert_eq!(state.history_cursor, None, "editing leaves history recall"); +} + +#[test] +fn command_line_clear_command_still_clears_the_buffer() { + // `Command::CommandLineClear` behaves as a plain "clear the buffer" + // reducer action. Esc no longer dispatches it (`input::map_panel_key` + // maps panel-level Esc to `RequestQuit` unconditionally); the shipped + // trigger is the clear-line chord, Ctrl+Y by default (command-line + // "Clear the command line with a dedicated key"). + let state = type_line(test_state(UiPhase::Panels), "dir"); + let (state, _) = update(state, Command::CommandLineClear); + assert!(state.command_line.is_empty()); + assert_eq!(state.history_cursor, None); +} + +#[test] +fn command_line_clear_ends_history_recall_and_hands_up_down_back_to_the_panel() { + // command-line "Clear the command line with a dedicated key" scenario + // "Clearing ends history recall": after the clear the buffer is empty, + // so the input layer routes Up/Down to the panel cursor again — the + // same release backspacing to empty performs (command-line "Command + // history navigation"; see input/tests.rs `ctrl_y_clears_a_composed_line`). + let mut state = test_state(UiPhase::Panels); + state.history = vec!["old".to_string()]; + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + let state = type_line(state, "dir"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "old"); + assert_eq!(state.history_cursor, Some(0)); + + let (state, _) = update(state, Command::CommandLineClear); + assert!(state.command_line.is_empty()); + assert_eq!(state.history_cursor, None, "clearing ends the recall"); + + // With the buffer empty, the mapper sends cursor moves for Up/Down + // again — the panel cursor must be free to move. + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert_eq!(state.left.cursor, 1, "the next Up/Down belongs to the panel, not to history"); + assert!(state.command_line.is_empty(), "no history entry was recalled"); +} + +#[test] +fn quick_search_mode_consumes_printables_instead_of_the_command_line() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("alpha", 1), file_entry("beta", 2)]; + let (state, _) = update(state, Command::QuickSearchStart('b')); + assert_eq!(state.quick_search.as_deref(), Some("b")); + assert_eq!(state.left.cursor, 1, "quick search jumped to the first match"); + + let (state, _) = update(state, Command::QuickSearchChar('e')); + assert_eq!(state.quick_search.as_deref(), Some("be")); + assert!(state.command_line.is_empty(), "the command line never saw those keys"); +} + +#[test] +fn leaving_quick_search_hands_printables_back_to_the_command_line() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("alpha", 1)]; + let (state, _) = update(state, Command::QuickSearchStart('a')); + let (state, _) = update(state, Command::QuickSearchEnd); + assert_eq!(state.quick_search, None); + let state = type_line(state, "dir"); + assert_eq!(state.command_line.text(), "dir"); +} + +#[test] +fn quick_search_backspace_shrinks_and_stays_active_when_emptied() { + // type-ahead-jump "Backspace on a single-character pattern": the + // pattern becomes empty but type-ahead mode itself stays active (only a + // movement key exits it now — Esc no longer does; it requests quit + // instead) and the cursor holds its position rather than re-jumping + // against an empty pattern. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("alpha", 1)]; + let (state, _) = update(state, Command::QuickSearchStart('a')); + let (state, _) = update(state, Command::QuickSearchChar('l')); + assert_eq!(state.quick_search.as_deref(), Some("al")); + let cursor_before = state.left.cursor; + let (state, _) = update(state, Command::QuickSearchBackspace); + assert_eq!(state.quick_search.as_deref(), Some("a")); + let (state, _) = update(state, Command::QuickSearchBackspace); + assert_eq!(state.quick_search.as_deref(), Some(""), "type-ahead must remain active with an empty pattern"); + assert_eq!(state.left.cursor, cursor_before, "the cursor must hold position rather than jump on an empty pattern"); + let (state, _) = update(state, Command::QuickSearchEnd); + assert_eq!(state.quick_search, None, "QuickSearchEnd (now dispatched by a movement key, not Esc) still exits type-ahead"); +} + +// --------------------------------------------------------------------- +// Quick filter (Ctrl+P), reducer-level (task 15.2) +// --------------------------------------------------------------------- + +#[test] +fn quick_filter_start_char_and_end_are_scoped_to_the_active_panel() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; + state.right.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; + + let (state, _) = update(state, Command::QuickFilterStart(PanelSide::Left)); + assert_eq!(state.left.quick_filter.as_deref(), Some("")); + assert_eq!(state.right.quick_filter, None, "the opposite panel must be unaffected"); + + let (state, _) = update(state, Command::QuickFilterChar('r')); + let (state, _) = update(state, Command::QuickFilterChar('e')); + let (state, _) = update(state, Command::QuickFilterChar('p')); + assert_eq!(state.left.quick_filter.as_deref(), Some("rep")); + let visible: Vec = state.left.visible_indices().into_iter().map(|i| state.left.entries[i].name.to_string_lossy().into_owned()).collect(); + assert_eq!(visible, vec!["report.txt"]); + + let (state, _) = update(state, Command::QuickFilterBackspace); + assert_eq!(state.left.quick_filter.as_deref(), Some("re")); + + let (state, _) = update(state, Command::QuickFilterEnd); + assert_eq!(state.left.quick_filter, None); +} + +#[test] +fn jump_to_prefix_only_lands_within_the_active_quick_filter() { + // Input routing normally keeps type-ahead and the quick filter mutually + // exclusive, but `jump_to_prefix` (backing `QuickSearchStart`/`Char`) + // must still be safe if both are ever active together: it must not + // land the cursor on an entry the filter hides. Both "beta" and + // "berry" start with "b", so an unfiltered type-ahead jump would land + // on "beta" (it comes first); the active filter "rr" hides "beta" but + // not "berry", so the filtered jump must land on "berry" instead. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("beta", 1), file_entry("berry", 2)]; + state.left.set_quick_filter(Some("rr".to_string())); + let (state, _) = update(state, Command::QuickSearchStart('b')); + assert_eq!( + state.left.entries[state.left.cursor].name.to_string_lossy(), + "berry", + "the jump must only search entries the active filter leaves visible" + ); +} + +// --------------------------------------------------------------------- +// filter-selection-safety: no current entry under an empty filter (R-17; +// quick-filter "Cursor and mini-status behavior under an active filter") +// --------------------------------------------------------------------- + +/// A drive-root-style listing (no `..`) whose quick filter matches nothing, +/// with nothing selected: the panel has no current entry. +fn filtered_out_root_state() -> State { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/"); + state.left.entries = vec![file_entry("pagefile.sys", 1), file_entry("notes.txt", 1)]; + state.left.set_quick_filter(Some("zzzz".to_string())); + state +} + +/// quick-filter "Cursor-targeted operations do nothing while no entry is +/// visible" (tasks.md 4.3). +#[test] +fn cursor_targeted_commands_do_nothing_while_no_entry_is_visible() { + for cmd in [ + Command::RequestDelete, + Command::RequestCopy, + Command::RequestMove, + Command::RequestViewer, + Command::RequestEditor, + Command::Enter, + Command::PasteCursorName, + ] { + let state = filtered_out_root_state(); + let command_line_before = state.command_line.clone(); + let (state, effects) = update(state, cmd.clone()); + assert!(effects.is_empty(), "{cmd:?} must emit no effects"); + assert_eq!(state.phase, UiPhase::Panels, "{cmd:?} must not change the phase"); + assert!(state.file_action_menu().is_none(), "{cmd:?} must not open the file-action menu"); + assert_eq!(state.command_line, command_line_before, "{cmd:?} must not touch the command line"); + } +} + +/// quick-filter "Ins does nothing while no entry is visible" (tasks.md 4.3). +#[test] +fn ins_does_nothing_while_no_entry_is_visible() { + let state = filtered_out_root_state(); + let (state, effects) = update(state, Command::ToggleSelectAtCursor); + assert!(effects.is_empty()); + assert!(state.left.selected.is_empty(), "Ins must not toggle the hidden cursor entry"); +} + +/// quick-filter "Widening the pattern restores a current entry" +/// (tasks.md 4.4). +#[test] +fn widening_the_pattern_restores_cursor_targeted_commands() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("/"); + state.left.entries = vec![file_entry("aaa.txt", 1), file_entry("notes.txt", 1)]; + let (state, _) = update(state, Command::QuickFilterStart(PanelSide::Left)); + let state = "notesz".chars().fold(state, |s, c| update(s, Command::QuickFilterChar(c)).0); + assert!(state.left.selected().is_none(), "setup precondition: nothing visible"); + + let (state, _) = update(state, Command::QuickFilterBackspace); // "notes" + let (state, effects) = update(state, Command::RequestViewer); + assert_eq!(effects, vec![Effect::OpenViewer { request: 3, path: PathBuf::from("/notes.txt") }]); + assert_eq!(state.phase, UiPhase::Panels, "the viewer opens only once ViewerOpened comes back"); +} + +// --------------------------------------------------------------------- +// Panel tabs (Ctrl+T / Ctrl+W / Alt+1..9), reducer-level (task 15.5) +// --------------------------------------------------------------------- + +#[test] +fn tab_commands_are_scoped_to_the_active_panel() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + + let (state, _) = update(state, Command::OpenTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 2); + assert_eq!(state.right.tab_count(), 1, "the opposite panel's tabs must be untouched"); + + let (mut state, _) = update(state, Command::CloseTab(PanelSide::Left)); + assert_eq!(state.left.tab_count(), 1); + + state.left.open_tab(); + state.left.begin_new_listing(PathBuf::from("/left/other")); + let (state, _) = update(state, Command::SwitchTab(1)); + assert_eq!(state.left.cwd, PathBuf::from("/left")); +} + +#[test] +fn switch_tab_out_of_range_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Right; + let before = state.right.cwd.clone(); + let (state, _) = update(state, Command::SwitchTab(9)); + assert_eq!(state.right.cwd, before); + assert_eq!(state.right.tab_count(), 1); +} + +// --------------------------------------------------------------------- +// line-editing-in-text-fields: the shared LineEdit backing (reducer-level, +// tasks.md 2.5) +// --------------------------------------------------------------------- + +#[test] +fn text_edit_inserts_at_the_caret_mid_command_line() { + // command-line "Caret editing mid-line": `cd srv`, Left x2, `c` -> + // `cd scrv`, with the panel cursor untouched. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + state.left.cursor = 1; + let state = type_line(state, "cd srv"); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('c'))); + assert_eq!(state.command_line.text(), "cd scrv"); + assert_eq!(state.command_line.caret(), 5, "the caret sits after the inserted c"); + assert_eq!(state.left.cursor, 1, "caret editing never moves the panel cursor"); +} + +#[test] +fn caret_moves_are_the_only_non_mutating_edits() { + // A pure caret move leaves history recall alone; the next text change + // ends it, exactly as typing always has. + let mut state = test_state(UiPhase::Panels); + state.history = vec!["old".to_string()]; + let state = type_line(state, "x"); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.history_cursor, Some(0)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + assert_eq!(state.history_cursor, Some(0), "a caret move does not end recall"); + let (state, _) = update(state, Command::TextEdit(TextEditOp::DeleteWordBack)); + assert_eq!(state.history_cursor, None, "a text-mutating edit ends recall"); + assert_eq!(state.command_line.text(), "d", "delete-word-back from mid-`old` removes `ol`"); +} + +#[test] +fn home_still_moves_the_panel_cursor_mid_composition() { + // command-line "Home still moves the panel cursor while composing": + // Home is a panel key even with text on the line, and the buffer and + // its caret are unchanged by it. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + state.left.cursor = 1; + let state = type_line(state, "cd docs"); + let (state, _) = update(state, Command::MoveCursor(CursorMove::Home)); + assert_eq!(state.left.cursor, 0, "Home jumps the panel cursor to the first entry"); + assert_eq!(state.command_line.text(), "cd docs"); + assert_eq!(state.command_line.caret(), 7, "the caret is untouched"); +} + +#[test] +fn history_recall_leaves_the_caret_at_the_end() { + let mut state = test_state(UiPhase::Panels); + state.history = vec!["cd somewhere".to_string()]; + let state = type_line(state, "x"); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Home)); + let (state, _) = update(state, Command::CommandLineHistoryPrev); + assert_eq!(state.command_line.text(), "cd somewhere"); + assert_eq!(state.command_line.caret(), state.command_line.len_chars(), "recall lands the caret at the end"); +} + +#[test] +fn paste_flattens_line_breaks_and_inserts_at_the_caret() { + // text-field-editing "Multi-line clipboard text is flattened": the two + // clipboard lines land joined by one space, and the panel cursor does + // not move. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a", 1), file_entry("b", 2)]; + state.left.cursor = 1; + let state = type_line(state, "cd "); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Paste("one\r\ntwo".to_string()))); + assert_eq!(state.command_line.text(), "cd one two"); + assert_eq!(state.command_line.caret(), state.command_line.len_chars()); + assert_eq!(state.left.cursor, 1, "pasting never moves the panel cursor"); +} + +#[test] +fn request_paste_asks_the_tui_for_clipboard_text() { + // design D4: the key map emits `RequestPaste`; the reducer turns it + // into the clipboard-read effect whose answer comes back as + // `TextEdit(Paste(..))`. + let state = type_line(test_state(UiPhase::Panels), "x"); + let (state, effects) = update(state, Command::RequestPaste); + assert_eq!(effects, vec![Effect::ReadClipboardText]); + assert_eq!(state.command_line.text(), "x", "nothing changes until the clipboard text arrives"); +} + +#[test] +fn paste_cursor_entry_after_caret_editing_lands_at_the_end() { + // command-line "Paste after caret editing lands at the end". + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("old.log", 1)]; + let state = type_line(state, "del "); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Home)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Right)); + let (state, _) = update(state, Command::PasteCursorName); + assert_eq!(state.command_line.text(), "del old.log"); + assert_eq!(state.command_line.caret(), state.command_line.len_chars()); +} + +#[test] +fn destination_field_edits_in_the_middle() { + // operation-dialogs "Editing the pre-filled destination in place": + // `D:\backup`, Home, Delete, `E` -> `E:\backup`. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.cwd = PathBuf::from(r"D:\backup"); + let (state, _) = update(state, Command::RequestCopy); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Home)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Delete)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('E'))); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert_eq!(input.text(), r"E:\backup"); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn destination_field_word_keys_and_paste_work() { + // text-field-editing "Word delete" over the pre-filled path, then a + // paste flattened into the field at the caret. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.cwd = PathBuf::from(r"D:\backup\2026"); + let (state, _) = update(state, Command::RequestCopy); + let (state, _) = update(state, Command::TextEdit(TextEditOp::DeleteWordBack)); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => assert_eq!(input.text(), r"D:\backup\"), + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } + let (state, effects) = update(state, Command::RequestPaste); + assert_eq!(effects, vec![Effect::ReadClipboardText]); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Paste("one\ntwo".to_string()))); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert_eq!(input.text(), r"D:\backup\one two"); + assert_eq!(input.caret(), input.len_chars(), "the paste leaves the caret at the end"); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } +} + +#[test] +fn pre_filled_destination_accepts_unchanged_with_enter() { + // text-field-editing "Pre-filled field accepts unchanged with Enter": + // the caret sits at the end of the pre-fill, so no editing key is + // needed before accepting exactly as before this capability existed. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestCopy); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert_eq!(input.text(), "/right"); + assert_eq!(input.caret(), input.len_chars(), "a pre-filled field opens with the caret at the end"); + } + other => panic!("expected FileOpSetup::DestinationInput, got {other:?}"), + } + let (_, effects) = update(state, Command::FileOpConfirm); + assert!(matches!(effects.as_slice(), [Effect::RunJob(Job { kind: JobKind::Copy, .. })])); +} + +#[test] +fn rename_edits_the_stem_without_retyping_the_extension() { + // text-field-editing "Insert in the middle of a pre-filled name": + // `draft.txt` -> `drift.txt` via Left, Backspace, `i`. (The spec + // scenario's "Left five times" is off by one — from the end of the + // 9-char name the `a` is six Lefts away; the stated result `drift.txt` + // is what the reducer must produce.) + let state = opened_menu_state(&["draft.txt"], "draft.txt"); + let (state, _) = update(state, Command::FileActionMenuHotkey('R')); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::RenameInput { input, .. }) => { + assert_eq!(input.text(), "draft.txt"); + assert_eq!(input.caret(), input.len_chars(), "the rename field opens with the caret at the end"); + } + other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), + } + let state = (0..6).fold(state, |s, _| update(s, Command::TextEdit(TextEditOp::Left)).0); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Backspace)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('i'))); + match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::RenameInput { input, .. }) => { + assert_eq!(input.text(), "drift.txt"); + assert_eq!(input.caret(), 3, "the caret sits after the inserted i"); + } + other => panic!("expected FileOpSetup::RenameInput, got {other:?}"), + } +} + +#[test] +fn conflict_rename_field_edits_in_the_middle() { + // The overwrite-conflict Rename field is the same shared text field: + // Home, then typing, prepends to the composed name. + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update(state, Command::JobConflict(sample_conflict())); + let (state, _) = update(state, Command::FileOpBeginRename); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('b'))); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Home)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('a'))); + let (_state, effects) = update(state, Command::FileOpConfirm); + assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Rename(OsString::from("ab")))]); +} + +#[test] +fn find_file_pattern_edits_mid_string_until_submitted() { + // find-file "Pattern is editable mid-string": `reprt`, Left x2, `o` -> + // `report`; once submitted, editing keys stop reaching the pattern. + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::FindFileOpen); + let state = "reprt".chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('o'))); + assert_eq!(state.find_file().unwrap().pattern.text(), "report"); + let (state, _) = update(state, Command::FindFileSubmit); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('x'))); + assert_eq!(state.find_file().unwrap().pattern.text(), "report", "the pattern is fixed once the walk is in flight"); +} + +#[test] +fn fuzzy_jump_pattern_edited_mid_string_renarrows() { + // fuzzy-jump "Pattern edited mid-string re-narrows the list": `prj`, + // Left, `o` -> `proj`; a pure caret move keeps the highlight, the text + // change resets it to the top of the newly narrowed list. + let mut state = test_state(UiPhase::Panels); + state.dir_history = vec![history_entry(r"C:\proj", 1, 0), history_entry(r"C:\prj-other", 1, 0)]; + let (state, _) = update(state, Command::FuzzyJumpOpen); + let state = "prj".chars().fold(state, |s, c| update(s, Command::TextEdit(TextEditOp::Insert(c))).0); + let (state, _) = update(state, Command::FuzzyJumpMove(1)); + assert_eq!(state.fuzzy_jump().unwrap().cursor, 1); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Left)); + assert_eq!(state.fuzzy_jump().unwrap().cursor, 1, "a caret move never re-narrows"); + let (state, _) = update(state, Command::TextEdit(TextEditOp::Insert('o'))); + let fj = state.fuzzy_jump().unwrap(); + assert_eq!(fj.pattern.text(), "proj"); + assert_eq!(fj.cursor, 0, "the list re-narrows against `proj` from the top"); +} + +// `running_a_command_records_history_clears_the_buffer_and_persists` and +// `running_a_command_uses_the_configured_shell` used to assert that +// arbitrary typed text (`dir`, `Get-ChildItem`) reached the shell — no +// longer true now that the command line only recognizes `cd`/`del`/`rmdir` +// (command-line "Command-line builtin whitelist"). The buffer-clearing +// assertion moves to the rejection path below; `shell::build_command` +// respecting the configured shell is still exercised, just through the F2 +// user menu, one of its two remaining (unaffected) call sites. +#[test] +fn an_unrecognized_command_clears_the_buffer_and_is_rejected_without_touching_history() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, "dir"); + let (state, effects) = update(state, Command::Enter); + + assert!(state.command_line.is_empty(), "the buffer is cleared even for a rejected line"); + assert!(state.history.is_empty(), "a rejected command is not recorded in history"); + assert!(effects.is_empty(), "nothing is spawned and nothing is persisted, got {effects:?}"); + assert_eq!(state.left.last_error.as_deref(), Some("'dir' is not a recognized command")); + assert_eq!(state.prompt(), format!("{}>", PathBuf::from(r"C:\NORTON").display()), "the panel itself is untouched"); +} + +#[test] +fn f2_user_menu_entry_uses_the_configured_shell() { + let mut state = test_state(UiPhase::Panels); + state.shell.shell = Some("powershell".to_string()); + state.user_menu_entries = vec![crate::config::UserMenuEntry { label: "List".to_string(), command: "Get-ChildItem".to_string() }]; + let (state, _) = update(state, Command::UserMenuOpen); + let (_, effects) = update(state, Command::UserMenuConfirm); + match effects.first() { + Some(Effect::RunShellCommand(inv, _)) => { + assert_eq!(inv.program, "powershell"); + assert_eq!(inv.args, vec!["-NoLogo".to_string(), "-Command".to_string(), "Get-ChildItem".to_string()]); + } + other => panic!("expected a shell invocation, got {other:?}"), + } +} + +#[test] +fn enter_with_an_empty_buffer_still_navigates() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, effects) = update(state, Command::Enter); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left/sub")]); +} + +// Superseded by the file-action menu (BREAKING, command-line "Enter on an +// executable opens the menu instead of spawning"): Enter on an executable no +// longer spawns it directly on the first keystroke — it opens the menu with +// Run first and highlighted, so `enter_on_an_executable_target_spawns_it_ +// through_the_shell` becomes the two tests below: the first Enter opens the +// menu (no spawn), and a second Enter (activating the highlighted Run entry) +// is what actually spawns it. +#[test] +fn enter_on_an_executable_target_opens_the_menu_with_run_first_and_does_not_spawn() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("setup.exe", 1)]; + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty(), "no direct spawn on the first Enter, got {effects:?}"); + let menu = state.file_action_menu().expect("Enter on a file opens the file-action menu"); + assert_eq!(menu.target_name, OsString::from("setup.exe")); + assert_eq!(menu.entries[0], FileActionMenuEntry::Run, "an executable target lists Run first"); + assert_eq!(menu.entries[1], FileActionMenuEntry::Open, "Open follows Run for an executable (open-with-associated-app design D2)"); + assert_eq!(menu.selected(), FileActionMenuEntry::Run, "Run is highlighted by default"); +} + +#[test] +fn enter_enter_on_an_executable_spawns_it_through_the_shell() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("setup.exe", 1)]; + let (state, _) = update(state, Command::Enter); // opens the menu, Run highlighted + let (_, effects) = update(state, Command::FileActionMenuConfirm); // activates Run + match effects.as_slice() { + [Effect::RunShellCommand(inv, side)] => { + assert_eq!(inv.args.last().unwrap(), "\"setup.exe\""); + assert_eq!(inv.cwd, PathBuf::from("/left")); + assert_eq!(*side, PanelSide::Left); + } + other => panic!("expected the executable to spawn through the shell, got {other:?}"), + } +} + +// Superseded by the file-action menu (file-action-menu "Enter on a file +// opens the action menu"): Enter on a non-executable file used to be a dead +// key; it now opens the menu instead of doing nothing. +#[test] +fn enter_on_a_plain_file_opens_the_action_menu_without_spawning_anything() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("readme.txt", 1)]; + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty()); + let menu = state.file_action_menu().expect("Enter on a non-executable file opens the file-action menu"); + assert_eq!(menu.target_name, OsString::from("readme.txt")); + assert!(!menu.entries.contains(&FileActionMenuEntry::Run), "non-executable: no Run entry"); + assert_eq!(menu.entries[0], FileActionMenuEntry::Open, "non-executable: Open is listed first (open-with-associated-app design D2)"); + assert_eq!(menu.selected(), FileActionMenuEntry::Open, "Open is highlighted by default"); +} + +// --------------------------------------------------------------------- +// relative-destination-resolution: the shared resolver's new contract +// (command-line "cd, del and rmdir targets resolve to a normalized +// absolute path") — the same rules the destination input dialog resolves +// with (operation-dialogs "Relative destinations resolve against the +// source panel directory") +// --------------------------------------------------------------------- + +/// The resolver's form table (design D2): every result is absolute and +/// free of `.`/`..` components whenever `cwd` is, and a drive-relative +/// form never depends on the process's per-drive current directory. +#[cfg(windows)] +#[test] +fn resolve_cd_target_normalizes_and_resolves_drive_relative_forms() { + assert_eq!(resolve_cd_target(Path::new(r"C:\work\proj"), r"..\archive"), Some(PathBuf::from(r"C:\work\archive"))); + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), "backup"), Some(PathBuf::from(r"C:\work\backup"))); + // A drive-rooted path keeps the panel's drive. + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), r"\archive"), Some(PathBuf::from(r"C:\archive"))); + // A drive-relative form resolves against the panel's directory when it + // is on that drive (compared case-insensitively), and against the + // drive's root otherwise. + assert_eq!(resolve_cd_target(Path::new(r"D:\work"), "D:old"), Some(PathBuf::from(r"D:\work\old"))); + assert_eq!(resolve_cd_target(Path::new(r"d:\work"), "D:old"), Some(PathBuf::from(r"d:\work\old"))); + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), "D:old"), Some(PathBuf::from(r"D:\old"))); + // UNC and absolute paths pass through unchanged; an absolute path + // carrying `..` is normalized rather than kept as typed. + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), r"\\server\share\dest"), Some(PathBuf::from(r"\\server\share\dest"))); + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), r"E:\backup"), Some(PathBuf::from(r"E:\backup"))); + assert_eq!(resolve_cd_target(Path::new(r"C:\work"), r"C:\a\..\b"), Some(PathBuf::from(r"C:\b"))); + // `..` at a drive root still resolves to nothing. + assert_eq!(resolve_cd_target(Path::new(r"C:\"), ".."), None); +} + +/// The normalization half of the new contract is platform-independent (the +/// Windows-only drive forms are covered by the test above). +#[test] +fn resolve_cd_target_removes_dot_and_dotdot_components() { + assert_eq!(resolve_cd_target(Path::new("/a/b"), "../c"), Some(PathBuf::from("/a/c"))); + assert_eq!(resolve_cd_target(Path::new("/a/b"), "./c"), Some(PathBuf::from("/a/b/c"))); + assert_eq!(resolve_cd_target(Path::new("/a/b"), "c/../../d"), Some(PathBuf::from("/a/d"))); +} + +/// command-line "cd with a parent-relative path lands on the normalized +/// directory": `cd ..\x` navigates to the *normalized* path, so both the +/// probe and the `StartListing` it begins carry no `..` component (a +/// verbatim-prefixed path with one fails with OS error 123). The resolved +/// parent is never the panel's own directory, so the check always goes +/// through the asynchronous probe (offload-blocking-io design D1). +#[cfg(windows)] +#[test] +fn cd_with_a_parent_relative_path_lists_the_normalized_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work\proj"); + let state = type_line(state, r"cd ..\x"); + let (state, effects) = update(state, Command::Enter); + let expected = PathBuf::from(r"C:\work\x"); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: expected.clone() }], "the probe takes the normalized path"); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + assert_eq!(state.left.cwd, expected, "the panel lands on the normalized directory"); + assert!(without_git_info_effects(effects).contains(&start_listing(&state, PanelSide::Left, expected)), "the listing targets the resolved path"); +} + +/// command-line "cd with a drive-relative path resolves against the panel +/// or the drive root". +#[cfg(windows)] +#[test] +fn cd_with_a_drive_relative_path_resolves_against_the_panel_or_the_drive_root() { + // On the target's own drive the panel directory stands in for the + // drive's current directory: `D:old` names the listed entry `old`. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"D:\work"); + state.left.entries = vec![dir_entry("old")]; + let state = type_line(state, "cd D:old"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from(r"D:\work\old")); + let effects = without_git_info_effects(effects); + assert!( + effects.contains(&start_listing(&state, PanelSide::Left, r"D:\work\old")), + "the listing targets the panel-relative resolution, got {effects:?}" + ); + + // On any other drive there is no per-drive current directory to lean + // on, so the target resolves against the drive's root: the probe is + // issued for exactly the resolved path, and a `Missing` reply names it. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work"); + let state = type_line(state, "cd D:old"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: PathBuf::from(r"D:\old") }], "the probe takes the drive-root resolution"); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Missing }); + assert!(effects.is_empty(), "a nonexistent target starts no listing, got {effects:?}"); + let expected = format!("{} not found", PathBuf::from(r"D:\old").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); +} + +/// command-line "del with a parent-relative path names the resolved file": +/// the `..` resolves against the panel's directory, so the probe and the +/// delete-confirmation dialog it opens are for the file the path actually +/// points at. +#[cfg(windows)] +#[test] +fn del_with_a_parent_relative_path_names_the_resolved_file() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\work\proj"); + let state = type_line(state, r"del ..\old.txt"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: PathBuf::from(r"C:\work\old.txt") }]); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert!(effects.is_empty(), "nothing is deleted until the dialog is accepted, got {effects:?}"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].path, PathBuf::from(r"C:\work\old.txt"), "the dialog names the resolved, normalized path"); + assert_eq!(sources[0].original_name, OsString::from("old.txt")); + assert!(!sources[0].is_dir); + } + other => panic!("expected the delete-confirmation dialog, got {other:?}"), + } +} + +// --------------------------------------------------------------------- +// command-line-builtin-whitelist: `del`/`rmdir` typed builtins +// --------------------------------------------------------------------- + +#[test] +fn del_on_a_file_opens_the_delete_confirm_dialog_naming_it() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("notes.txt", 1)]; + let state = type_line(state, "del notes.txt"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty(), "nothing is deleted until the dialog is accepted, got {effects:?}"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, confirmed_once, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("notes.txt")); + assert_eq!(sources[0].path, PathBuf::from(r"C:\NORTON\notes.txt")); + assert!(!sources[0].is_dir); + assert!(!needs_second_confirm, "a single file needs only one confirmation"); + assert!(!confirmed_once); + } + other => panic!("expected the delete-confirm dialog to open, got {other:?}"), + } + assert!(state.history.is_empty(), "`del` is not recorded in command history"); +} + +#[test] +fn rmdir_on_a_directory_opens_the_delete_confirm_dialog_and_needs_second_confirmation() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![dir_entry("docs")]; + let state = type_line(state, "rmdir docs"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty()); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, confirmed_once, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("docs")); + assert!(sources[0].is_dir); + assert!(needs_second_confirm, "a directory requires the existing second confirmation"); + assert!(!confirmed_once); + } + other => panic!("expected the delete-confirm dialog to open, got {other:?}"), + } + + let (state, effects) = update(state, Command::FileOpConfirm); + assert!(effects.is_empty(), "first confirm just arms the second confirmation"); + let (_, effects) = update(state, Command::FileOpConfirm); + assert!(matches!(effects.as_slice(), [Effect::RunJob(Job { kind: JobKind::Delete, .. })]), "nothing was deleted until the second confirm"); +} + +#[test] +fn del_rejects_a_directory_target() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![dir_entry("docs")]; + let state = type_line(state, "del docs"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels, "no dialog opens"); + let expected = format!("{} is a directory", PathBuf::from(r"C:\NORTON\docs").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); +} + +#[test] +fn rmdir_rejects_a_file_target() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![file_entry("notes.txt", 1)]; + let state = type_line(state, "rmdir notes.txt"); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels, "no dialog opens"); + let expected = format!("{} is not a directory", PathBuf::from(r"C:\NORTON\notes.txt").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str())); +} + +#[test] +fn del_and_rmdir_reject_dot_targets() { + for verb in ["del", "rmdir"] { + for target in [".", "..", r".\", r"..\"] { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![dir_entry("docs")]; + let state = type_line(state, &format!("{verb} {target}")); + let (state, effects) = update(state, Command::Enter); + assert!(effects.is_empty(), "{verb} {target}: no dialog and no effects, got {effects:?}"); + assert_eq!(state.phase, UiPhase::Panels, "{verb} {target}: the delete-confirm dialog must never open on the current or parent directory"); + let expected = format!("{verb}: invalid target {target}"); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str()), "{verb} {target}: invalid-target error"); + } + } +} + +#[test] +fn del_dotdot_sibling_is_not_caught_by_the_dot_target_guard() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"del ..\sibling.txt"); + let (state, effects) = update(state, Command::Enter); + assert!( + matches!(effects.as_slice(), [Effect::ProbePath { .. }]), + "a multi-component relative target follows the ordinary probe path, got {effects:?}" + ); + assert_eq!(state.phase, UiPhase::Panels); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Missing }); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); + let err = state.left.last_error.unwrap(); + assert!(err.ends_with("not found"), "a multi-component relative target follows the ordinary not-found path, got: {err}"); +} + +/// command-line "del/rmdir on a nonexistent target is rejected": the +/// unlisted target is probed, and the `Missing` reply rejects it with no +/// dialog. +#[test] +fn del_and_rmdir_on_a_nonexistent_target_are_rejected() { + for verb in ["del", "rmdir"] { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, &format!(r"{verb} \NOSUCHFILE.TXT")); + let (state, effects) = update(state, Command::Enter); + assert_eq!( + effects, + vec![Effect::ProbePath { request: 3, path: PathBuf::from(r"C:\NOSUCHFILE.TXT") }], + "{verb}: the unlisted target is probed" + ); + assert_eq!(state.phase, UiPhase::Panels, "{verb}: no dialog opens before the reply"); + assert_eq!( + state.command_probe.as_ref().map(|p| p.intent.clone()), + Some(ProbeIntent::Delete { want_dir: verb == "rmdir" }), + "{verb}: the pending probe records the verb" + ); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Missing }); + assert!(effects.is_empty(), "{verb}: nothing spawns and no dialog opens, got {effects:?}"); + assert_eq!(state.phase, UiPhase::Panels, "{verb}: no dialog opens"); + let expected = format!("{} not found", PathBuf::from(r"C:\NOSUCHFILE.TXT").display()); + assert_eq!(state.left.last_error.as_deref(), Some(expected.as_str()), "{verb}: the panel shows a not-found error"); + } +} + +/// command-line "del on an unlisted target on a slow share does not freeze +/// the UI": the probe reply of the matching type opens the dialog naming +/// the target, and a mismatched type gives the verb's own rejection. +#[test] +fn del_and_rmdir_on_an_unlisted_target_open_the_dialog_on_a_type_match() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"del \\nas\share\old.log"); + let (state, effects) = update(state, Command::Enter); + assert_eq!(effects, vec![Effect::ProbePath { request: 3, path: PathBuf::from(r"\\nas\share\old.log") }]); + assert_eq!(state.left.pending_note.as_deref(), Some(r"Checking \\nas\share\old.log…")); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert!(effects.is_empty(), "nothing is deleted until the dialog is accepted"); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("old.log")); + assert_eq!(sources[0].path, PathBuf::from(r"\\nas\share\old.log")); + assert!(!sources[0].is_dir); + assert!(!needs_second_confirm); + } + other => panic!("expected the delete-confirmation dialog, got {other:?}"), + } + assert_eq!(state.left.pending_note, None); + + // `rmdir` on a directory reply: the dialog, with its second confirmation. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"rmdir \\nas\share\tmp"); + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { ref sources, needs_second_confirm, .. }) => { + assert_eq!(sources[0].original_name, OsString::from("tmp")); + assert!(sources[0].is_dir); + assert!(needs_second_confirm); + } + other => panic!("expected the delete-confirmation dialog, got {other:?}"), + } + + // Type mismatches give the verb's own rejection and open nothing. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"del \\nas\share\tmp"); + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.left.last_error.as_deref(), Some(format!("{} is a directory", PathBuf::from(r"\\nas\share\tmp").display()).as_str())); + + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"rmdir \\nas\share\old.log"); + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.left.last_error.as_deref(), Some(format!("{} is not a directory", PathBuf::from(r"\\nas\share\old.log").display()).as_str())); +} + +/// command-line "A late answer does not open the dialog over a menu": the +/// panels no longer own input, so the reply is discarded — no dialog, no +/// error — and the pending entry is cleared. +#[test] +fn a_delete_probe_reply_after_the_menu_opened_opens_no_dialog() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"rmdir \\nas\share\tmp"); + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::MenuOpen); + assert!(state.menu().is_some()); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::Dir }); + assert!(effects.is_empty()); + assert!(state.menu().is_some(), "the menu stays open"); + assert_eq!(state.phase, UiPhase::Panels, "the delete-confirmation dialog does not open"); + assert_eq!(state.left.last_error, None, "no error either: the reply is simply dropped"); + assert_eq!(state.command_probe, None); + assert_eq!(state.left.pending_note, None); +} + +/// A `del`/`rmdir` reply superseded by a newer command-line check, or one +/// arriving after the panel navigated, is ignored. +#[test] +fn a_delete_probe_reply_that_is_stale_or_whose_panel_navigated_is_ignored() { + // Superseded by a newer probe. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + let state = type_line(state, r"del \\nas\share\a.log"); + let (state, _) = update(state, Command::Enter); + let state = type_line(state, r"del \\nas\share\b.log"); + let (state, _) = update(state, Command::Enter); + let before = state.clone(); + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert!(effects.is_empty()); + assert_eq!(state, before, "the stale reply changes nothing"); + + // The panel navigated meanwhile. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\NORTON"); + state.left.entries = vec![dir_entry("docs")]; + let state = type_line(state, r"del \\nas\share\a.log"); + let (state, _) = update(state, Command::Enter); + let (state, _) = update(state, Command::Enter); // into `docs` + let (state, effects) = update(state, Command::PathProbed { request: 3, kind: ProbeKind::File }); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels, "no dialog opens for a panel that moved on"); + assert_eq!(state.left.last_error, None); + assert_eq!(state.command_probe, None); +} + +#[test] +fn del_and_rmdir_verbs_are_case_insensitive() { + assert_eq!(parse_del("DEL notes.txt"), Some("notes.txt".to_string())); + assert_eq!(parse_rmdir("RmDir docs"), Some("docs".to_string())); + assert_eq!(parse_del("delete notes.txt"), None, "`delete` must not falsely match the `del` verb"); + assert_eq!(parse_rmdir("rd docs"), None, "`rd` is not an alias for `rmdir` (design.md non-goal)"); +} + +// --------------------------------------------------------------------- +// M3: sort modes +// --------------------------------------------------------------------- + +#[test] +fn sort_mode_commands_reorder_without_issuing_a_read() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("b.txt", 10), file_entry("a.txt", 30), file_entry("c.txt", 20)]; + + let (state, effects) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); + assert!(effects.is_empty(), "sorting must not issue a directory read"); + let names: Vec = state.left.entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); + assert_eq!(names, vec!["b.txt", "c.txt", "a.txt"]); + assert_eq!(state.left.sort_mode, SortMode::Size); +} + +#[test] +fn sort_mode_is_set_per_panel_and_leaves_the_other_alone() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("b", 1), file_entry("a", 2)]; + state.right.entries = vec![file_entry("b", 1), file_entry("a", 2)]; + let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Size }); + assert_eq!(state.left.sort_mode, SortMode::Size); + assert_eq!(state.right.sort_mode, SortMode::Name); + let right_names: Vec = state.right.entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect(); + assert_eq!(right_names, vec!["b", "a"], "the untouched panel keeps its order"); +} + +#[test] +fn every_sort_mode_command_lands_on_its_mode() { + for mode in [SortMode::Name, SortMode::Extension, SortMode::Time, SortMode::Size, SortMode::Unsorted] { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::SetSortMode { side: PanelSide::Left, mode }); + assert_eq!(state.left.sort_mode, mode); + } +} + +#[test] +fn reread_preserves_the_sort_mode() { + let mut state = test_state(UiPhase::Panels); + state.left.sort_mode = SortMode::Size; + let (state, effects) = update(state, Command::RereadPanel(PanelSide::Left)); + assert_eq!(state.left.sort_mode, SortMode::Size); + assert_eq!(without_git_info_effects(effects), vec![start_listing(&state, PanelSide::Left, "/left")]); + assert!(state.left.entries.is_empty(), "a re-read discards the current entries and streams fresh ones"); + assert!(state.left.progress.is_streaming()); +} + +#[test] +fn swap_panels_exchanges_everything_and_keeps_focus_and_split() { + // Spec scenario "Swap keeps focus on the same side". + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("C:\\a"); + state.right.cwd = PathBuf::from("D:\\b"); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.right.entries = vec![file_entry("b.txt", 2), file_entry("c.txt", 3)]; + state.right.cursor = 1; + state.right.scroll_offset = 0; + state.right.selected.insert(OsString::from("b.txt")); + state.left.sort_mode = SortMode::Size; + state.split_percent = 60; + complete_panel_listings(&mut state); + + let (state, effects) = update(state, Command::SwapPanels); + assert_eq!(state.left.cwd, PathBuf::from("D:\\b")); + assert_eq!(state.right.cwd, PathBuf::from("C:\\a")); + assert_eq!(state.left.entries.len(), 2, "the listing moved with the panel"); + assert_eq!(state.left.cursor, 1, "the cursor moved with the panel"); + assert!(state.left.selected.contains(&OsString::from("b.txt")), "the selection moved with the panel"); + assert_eq!(state.right.sort_mode, SortMode::Size, "the sort mode moved with the panel"); + assert_eq!(state.active, PanelSide::Left, "the active side is unchanged"); + assert_eq!(state.split_percent, 60, "the split is unchanged"); + assert!(state.prompt().starts_with("D:\\b>"), "the command-line prompt follows: {}", state.prompt()); + // No directory is re-read (design D3); both sides' git queries are + // re-issued with fresh ids so pre-swap replies drop as stale. + assert!(effects.iter().all(|e| !matches!(e, Effect::StartListing { .. })), "no re-read: {effects:?}"); + assert!(effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { panel: PanelSide::Left, path, .. } if *path == Path::new("D:\\b")))); + assert!(effects.iter().any(|e| matches!(e, Effect::QueryGitInfo { panel: PanelSide::Right, path, .. } if *path == Path::new("C:\\a")))); +} + +#[test] +fn swap_panels_carries_tabs_and_selection() { + // Spec scenario "Swap carries tabs and selection". + let mut state = test_state(UiPhase::Panels); + state.right.open_tab(); + state.right.open_tab(); + state.right.selected.insert(OsString::from("x")); + state.right.selected.insert(OsString::from("y")); + complete_panel_listings(&mut state); + + let (state, _) = update(state, Command::SwapPanels); + assert_eq!(state.left.tab_count(), 3, "the tabs moved to the left panel"); + assert_eq!(state.right.tab_count(), 1); + assert_eq!(state.left.selected.len(), 2, "the selection moved with them"); +} + +#[test] +fn swap_panels_is_refused_while_either_listing_is_streaming() { + // Spec scenario "Swap refused while reading". + for streaming_side in [PanelSide::Left, PanelSide::Right] { + let mut state = test_state(UiPhase::Panels); + complete_panel_listings(&mut state); + state.panel_mut(streaming_side).progress = crate::panel::ListingProgress::Streaming { count: 0 }; + let before = state.clone(); + let (state, effects) = update(state, Command::SwapPanels); + assert!(effects.is_empty()); + assert_eq!(state, before, "a streaming panel ({streaming_side:?}) refuses the swap"); + } +} + +#[test] +fn swap_panels_is_ignored_during_a_drag() { + let mut state = test_state(UiPhase::Panels); + complete_panel_listings(&mut state); + state.drag = Some(DragState { + source: PanelSide::Left, + source_dir: state.left.cwd.clone(), + items: vec![SourceItem { original_name: OsString::from("a.txt"), path: PathBuf::from("/left/a.txt"), is_dir: false }], + op: JobKind::Copy, + target: None, + }); + let (state, effects) = update(state, Command::SwapPanels); + assert!(effects.is_empty()); + assert_eq!(state.left.cwd, PathBuf::from("/left"), "nothing swapped"); +} + +#[test] +fn swap_panels_re_issues_info_and_git_queries_with_fresh_ids() { + // Spec scenario "Git info follows the swap" + design D3: pending queries + // are re-issued under fresh request ids, so a reply from before the swap + // is discarded as stale. + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from("C:\\repo"); + state.left.display_mode = DisplayMode::Info; + state.left.info_request = Some(10); + state.left.git_request = Some(11); + state.right.git_request = Some(12); + state.request_seq = 100; + complete_panel_listings(&mut state); + + let (state, effects) = update(state, Command::SwapPanels); + // The left panel (the old right, still in Info? no — Info moved right): + // Info mode moved with the panel, so the *right* panel is now in Info. + assert_eq!(state.right.display_mode, DisplayMode::Info, "display mode moved with the panel"); + let info_query = effects.iter().find_map(|e| match e { + Effect::QueryInfo { panel, request, .. } => Some((*panel, *request)), + _ => None, + }); + assert_eq!(info_query.map(|(p, _)| p), Some(PanelSide::Right), "QueryInfo is re-issued for the side now in Info mode"); + let fresh: Vec = effects + .iter() + .filter_map(|e| match e { + Effect::QueryInfo { request, .. } | Effect::QueryGitInfo { request, .. } => Some(*request), + _ => None, + }) + .collect(); + assert_eq!(fresh.len(), 3, "one Info query (right) plus two git queries"); + assert!(fresh.iter().all(|id| *id > 100), "every re-issued query carries a fresh id: {fresh:?}"); + assert!(state.right.info_request > Some(100) && state.left.git_request > Some(100) && state.right.git_request > Some(100)); + + // A reply to a pre-swap request id is stale and dropped. + let (state2, _) = update( + state.clone(), + Command::GitInfoResolved { + panel: PanelSide::Right, + path: PathBuf::from("C:\\repo"), + request: 11, + info: GitInfo::none(), + timed_out_root: None, + }, + ); + assert_eq!(state2, state, "a pre-swap git reply never applies"); +} + +// --------------------------------------------------------------------- +// M3: Info mode +// --------------------------------------------------------------------- + +#[test] +fn ctrl_l_toggles_info_for_one_panel_only() { + let state = test_state(UiPhase::Panels); + let (state, effects) = update(state, Command::ToggleInfoMode(PanelSide::Left)); + assert_eq!(state.left.display_mode, DisplayMode::Info); + assert_eq!(state.right.display_mode, DisplayMode::Full, "the opposite panel is untouched"); + let request = state.left.info_request.expect("a request id was minted"); + assert_eq!(effects, vec![Effect::QueryInfo { panel: PanelSide::Left, path: PathBuf::from("/left"), request }]); + + let (state, effects) = update(state, Command::ToggleInfoMode(PanelSide::Left)); + assert_eq!(state.left.display_mode, DisplayMode::Full); + assert!(effects.is_empty(), "leaving Info mode queries nothing"); +} + +#[test] +fn info_values_start_pending_and_resolve_in_place() { + let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); + assert_eq!(state.left.info, InfoValues::default(), "every value starts unresolved"); + + let request = state.left.info_request.unwrap(); + let values = InfoValues { file_count: Some(12), dir_count: Some(3), ..InfoValues::default() }; + let (state, effects) = + update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, values: values.clone() }); + assert!(effects.is_empty()); + assert_eq!(state.left.info, values); +} + +#[test] +fn an_info_result_for_a_directory_the_panel_left_is_discarded() { + let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); + let request = state.left.info_request.unwrap(); + let values = InfoValues { file_count: Some(12), ..InfoValues::default() }; + let (state, _) = update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/elsewhere"), request, values }); + assert_eq!(state.left.info, InfoValues::default(), "a result for another directory is dropped"); +} + +#[test] +fn an_info_result_arriving_after_info_mode_was_left_is_discarded() { + let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); + let request = state.left.info_request.unwrap(); + let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); // back to Full + let values = InfoValues { file_count: Some(12), ..InfoValues::default() }; + let (state, _) = update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request, values }); + assert_eq!(state.left.info, InfoValues::default()); +} + +#[test] +fn navigating_while_in_info_mode_re_queries_for_the_new_directory() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.cwd, PathBuf::from("/left/sub")); + let request = state.left.info_request.expect("navigating while in Info mode mints a fresh request"); + assert!(effects.contains(&Effect::QueryInfo { panel: PanelSide::Left, path: PathBuf::from("/left/sub"), request })); + assert_eq!(state.left.info, InfoValues::default(), "the previous directory's figures are cleared"); +} + +#[test] +fn a_stale_info_result_from_an_out_of_order_reread_is_discarded_but_the_fresher_one_applies() { + // Regression coverage for the out-of-order-completion race: a double + // Ctrl+R (or any two RereadPanel commands landing before the first + // reply arrives) mints two different request ids for the *same* + // directory, so `path` equality alone can't tell the stale reply apart + // from the current one. + let (state, _) = update(test_state(UiPhase::Panels), Command::ToggleInfoMode(PanelSide::Left)); + let first_request = state.left.info_request.unwrap(); + + let (state, _) = update(state, Command::RereadPanel(PanelSide::Left)); + let second_request = state.left.info_request.unwrap(); + assert_ne!(first_request, second_request, "re-reading mints a fresh request id even for the same path"); + + let stale_values = InfoValues { file_count: Some(999), ..InfoValues::default() }; + let (state, _) = + update(state, Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request: first_request, values: stale_values }); + assert_eq!(state.left.info, InfoValues::default(), "the stale (first-request) answer is dropped"); + + let fresh_values = InfoValues { file_count: Some(7), ..InfoValues::default() }; + let (state, _) = update( + state, + Command::InfoResolved { panel: PanelSide::Left, path: PathBuf::from("/left"), request: second_request, values: fresh_values.clone() }, + ); + assert_eq!(state.left.info, fresh_values, "the current request's answer applies"); +} + +#[test] +fn a_panel_not_in_info_mode_issues_no_info_query_when_it_navigates() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (_, effects) = update(state, Command::Enter); + assert!(!effects.iter().any(|e| matches!(e, Effect::QueryInfo { .. }))); +} + +#[test] +fn entering_tree_mode_roots_at_the_drive_and_requests_the_root_children() { + let mut state = test_state(UiPhase::Panels); + state.left.cwd = PathBuf::from(r"C:\Users\demo"); + state.left.display_mode = DisplayMode::Info; + let (state, effects) = update(state, Command::EnterTreeMode(PanelSide::Left)); + assert_eq!(state.left.display_mode, DisplayMode::Tree); + let tree = state.left.tree.as_ref().expect("Tree mode populates tree state"); + assert_eq!(tree.nodes.len(), 1); + assert_eq!(tree.nodes[0].path, PathBuf::from(r"C:\")); + assert_eq!(tree.prior_mode, DisplayMode::Info, "the pre-Tree display mode is recorded for Enter to restore"); + assert!(effects.contains(&Effect::ExpandTreeNode { panel: PanelSide::Left, path: PathBuf::from(r"C:\") })); +} + +#[test] +fn tree_node_expanded_splices_children_into_the_matching_panels_tree() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + let (state, _) = update( + state, + Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, + ); + let tree = state.left.tree.as_ref().unwrap(); + assert_eq!(tree.nodes.len(), 3); + assert_eq!(tree.nodes[1].path, PathBuf::from(r"C:\alpha")); +} + +#[test] +fn a_tree_node_expanded_reply_for_the_other_panel_is_dropped() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + // The right panel never entered Tree mode, so it has no tree at all — + // a reply naming it must not panic or fabricate one. + let (state, _) = + update(state, Command::TreeNodeExpanded { panel: PanelSide::Right, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }); + assert!(state.right.tree.is_none()); + assert_eq!(state.left.tree.as_ref().unwrap().nodes.len(), 1, "the left panel's tree is untouched by a reply addressed elsewhere"); +} + +#[test] +fn moving_the_tree_cursor_relists_the_opposite_panel_at_the_highlighted_directory() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + let (state, _) = update( + state, + Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, + ); + let (state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert_eq!(state.left.tree.as_ref().unwrap().cursor, 1); + assert!( + without_git_info_effects(effects).contains(&start_listing(&state, PanelSide::Right, r"C:\alpha")), + "the opposite (right) panel must be re-listed at the newly highlighted directory" + ); + // Landing on "alpha" (not yet expanded) also requests its children. + assert!(!state.left.tree.as_ref().unwrap().nodes[1].expanded); +} + +#[test] +fn moving_onto_an_unexpanded_node_requests_its_children_but_not_an_already_expanded_one() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + let (state, _) = + update(state, Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha")] }); + let (state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert!(effects.contains(&Effect::ExpandTreeNode { panel: PanelSide::Left, path: PathBuf::from(r"C:\alpha") })); + + // Expand it, then move away and back — no second expand request for an + // already-expanded node. + let (state, _) = update(state, Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\alpha"), children: vec![] }); + let (state, _) = update(state, Command::MoveCursor(CursorMove::Up(1))); + let (_state, effects) = update(state, Command::MoveCursor(CursorMove::Down(1))); + assert!( + !effects.iter().any(|e| matches!(e, Effect::ExpandTreeNode { path, .. } if path == std::path::Path::new(r"C:\alpha"))), + "an already-expanded node must not be re-requested: {effects:?}" + ); +} + +#[test] +fn enter_on_a_tree_node_restores_the_prior_mode_and_navigates_this_panel_there() { + let mut state = test_state(UiPhase::Panels); + state.left.display_mode = DisplayMode::Brief; + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + let (state, _) = update( + state, + Command::TreeNodeExpanded { panel: PanelSide::Left, path: PathBuf::from(r"C:\"), children: vec![dir_child("alpha"), dir_child("beta")] }, + ); + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); // highlight "alpha" + let (state, effects) = update(state, Command::Enter); + assert_eq!(state.left.display_mode, DisplayMode::Brief, "Enter restores the pre-Tree display mode"); + assert!(state.left.tree.is_none(), "leaving Tree mode clears the tree state"); + assert!( + without_git_info_effects(effects).contains(&start_listing(&state, PanelSide::Left, r"C:\alpha")), + "Enter navigates *this* panel to the highlighted directory, not the opposite one" + ); +} + +// --------------------------------------------------------------------- +// M5: Brief/Full/Quick view display-mode switch (design D7) +// --------------------------------------------------------------------- + +#[test] +fn set_display_mode_switches_the_named_panel_and_clears_tree_state() { + let mut state = test_state(UiPhase::Panels); + state.right.tree = Some(crate::panel::TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full)); + state.right.display_mode = DisplayMode::Tree; + let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Right, mode: DisplayMode::Brief }); + assert_eq!(state.right.display_mode, DisplayMode::Brief); + assert!(state.right.tree.is_none()); + assert_eq!(state.left.display_mode, DisplayMode::Full, "the other panel is untouched"); +} + +// --------------------------------------------------------------------- +// M5 review fix: a quick filter must not linger invisibly across a +// display-mode switch, since Brief/Tree/Info's renderers don't surface it +// the same way Full mode does (quick-filter "Substring narrowing as the +// pattern is typed"). +// --------------------------------------------------------------------- + +#[test] +fn set_display_mode_clears_an_active_quick_filter_on_the_named_panel() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 1), file_entry("readme.md", 2)]; + state.left.set_quick_filter(Some("rep".to_string())); + let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Left, mode: DisplayMode::Brief }); + assert_eq!(state.left.display_mode, DisplayMode::Brief); + assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Brief mode"); +} + +#[test] +fn set_display_mode_does_not_clear_the_opposite_panels_quick_filter() { + let mut state = test_state(UiPhase::Panels); + state.right.entries = vec![file_entry("report.txt", 1)]; + state.right.set_quick_filter(Some("rep".to_string())); + let (state, _) = update(state, Command::SetDisplayMode { side: PanelSide::Left, mode: DisplayMode::Brief }); + assert_eq!(state.right.quick_filter.as_deref(), Some("rep"), "the opposite panel's filter must be untouched"); +} + +#[test] +fn entering_tree_mode_clears_an_active_quick_filter() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 1)]; + state.left.set_quick_filter(Some("rep".to_string())); + let (state, _) = update(state, Command::EnterTreeMode(PanelSide::Left)); + assert_eq!(state.left.display_mode, DisplayMode::Tree); + assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Tree mode"); +} + +#[test] +fn toggling_into_info_mode_clears_an_active_quick_filter() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 1)]; + state.left.set_quick_filter(Some("rep".to_string())); + let (state, _) = update(state, Command::ToggleInfoMode(PanelSide::Left)); + assert_eq!(state.left.display_mode, DisplayMode::Info); + assert_eq!(state.left.quick_filter, None, "a stale filter must not linger invisibly into Info mode"); +} + +// --------------------------------------------------------------------- +// Mouse (mouse-basics, design D2) +// --------------------------------------------------------------------- + +/// mouse-input "Click focuses and places the cursor" — "Click on the +/// inactive panel". +#[test] +fn click_entry_focuses_the_clicked_panel_and_moves_its_cursor() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.right.entries = vec![file_entry("notes.txt", 1), file_entry("other.txt", 2)]; + let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Right, name: OsString::from("notes.txt"), mods: ClickMods::Plain }); + assert_eq!(state.active, PanelSide::Right); + assert_eq!(state.right.cursor, 0); +} + +/// mouse-input "Click on a selected entry keeps it selected" — a plain +/// click never touches the selection set, only the cursor. +#[test] +fn click_entry_plain_never_changes_selection() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.left.selected.insert(OsString::from("a.txt")); + let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("b.txt"), mods: ClickMods::Plain }); + assert_eq!(state.left.cursor, 1); + assert!(state.left.selected.contains(&OsString::from("a.txt")), "the existing selection survives an unrelated plain click"); + assert!(!state.left.selected.contains(&OsString::from("b.txt")), "a plain click never selects the clicked entry either"); +} + +/// mouse-input "Ctrl+click toggles selection" — "Toggle on". +#[test] +fn ctrl_click_toggles_selection_and_moves_the_cursor() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("a.txt"), mods: ClickMods::Ctrl }); + assert!(state.left.selected.contains(&OsString::from("a.txt"))); + assert_eq!(state.left.cursor, 0); + + let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from("a.txt"), mods: ClickMods::Ctrl }); + assert!(!state.left.selected.contains(&OsString::from("a.txt")), "a second Ctrl+click toggles it back off"); +} + +/// mouse-input "Ctrl+click toggles selection" — "Parent entry ignored". +#[test] +fn ctrl_click_on_parent_entry_never_selects_it() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::new(OsString::from(".."), EntryKind::ParentDir, 0, None, false, false)]; + let (state, _) = update(state, Command::ClickEntry { side: PanelSide::Left, name: OsString::from(".."), mods: ClickMods::Ctrl }); + assert!(state.left.selected.is_empty()); + assert_eq!(state.left.cursor, 0, "the cursor still moves to `..`"); +} + +/// A name the panel no longer lists is a silent no-op, not a panic — the +/// hit map can go briefly stale between a frame being drawn and the click +/// landing (e.g. a re-read completing in between). +#[test] +fn click_entry_on_a_vanished_name_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.right.entries = vec![file_entry("a.txt", 1)]; + let before_cursor = state.right.cursor; + let (state, effects) = update(state, Command::ClickEntry { side: PanelSide::Right, name: OsString::from("gone.txt"), mods: ClickMods::Plain }); + assert!(effects.is_empty()); + assert_eq!(state.right.cursor, before_cursor); + assert_eq!(state.active, PanelSide::Right, "focus still moves to the clicked side even though the name missed"); +} + +/// mouse-input "Click focuses and places the cursor" — clicking blank body +/// area or the title focuses without moving the cursor. +#[test] +fn focus_panel_switches_active_without_touching_the_cursor() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.right.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.right.cursor = 1; + let (state, effects) = update(state, Command::FocusPanel(PanelSide::Right)); + assert!(effects.is_empty()); + assert_eq!(state.active, PanelSide::Right); + assert_eq!(state.right.cursor, 1, "the cursor is untouched"); +} + +/// mouse-input "Wheel moves the cursor of the panel under the pointer" — +/// "Wheel over the inactive panel": the cursor of the scrolled panel moves, +/// the active panel does not change. +#[test] +fn scroll_panel_moves_the_cursor_of_the_named_side_without_changing_focus() { + let mut state = test_state(UiPhase::Panels); + state.active = PanelSide::Left; + state.right.entries = (0..10).map(|i| file_entry(&format!("f{i}.txt"), 1)).collect(); + let (state, _) = update(state, Command::ScrollPanel { side: PanelSide::Right, delta: 3 }); + assert_eq!(state.active, PanelSide::Left, "the active panel never changes"); + assert_eq!(state.right.cursor, 3, "three rows per notch"); +} + +#[test] +fn scroll_panel_upward_never_underflows_past_the_first_row() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = (0..10).map(|i| file_entry(&format!("f{i}.txt"), 1)).collect(); + state.left.cursor = 1; + let (state, _) = update(state, Command::ScrollPanel { side: PanelSide::Left, delta: -3 }); + assert_eq!(state.left.cursor, 0); +} + +/// mouse-input "Key bar, menu bar, pull-down items, and dialog buttons are +/// clickable" — "Key bar Copy". +#[test] +fn keybar_press_five_opens_the_copy_setup_dialog() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::KeybarPress(5)); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DestinationInput { kind: JobKind::Copy, .. }))); +} + +#[test] +fn keybar_press_ten_requests_quit_exactly_like_f10() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::KeybarPress(10)); + assert!(state.quit_confirm); +} + +#[test] +fn keybar_press_out_of_range_is_a_no_op() { + let state = test_state(UiPhase::Panels); + let before = state.clone(); + let (state, effects) = update(state, Command::KeybarPress(0)); + assert!(effects.is_empty()); + assert_eq!(state, before); +} + +/// mouse-input "Key bar, menu bar, pull-down items, and dialog buttons are +/// clickable" — a menu-title click opens that pull-down. +#[test] +fn menu_title_click_opens_the_named_pulldown() { + let state = test_state(UiPhase::Panels); + let (state, _) = update(state, Command::MenuTitleClick(MenuId::Files)); + let menu = state.menu().expect("the bar opens"); + assert_eq!(menu.active, MenuId::Files); + assert!(menu.pulldown_open); +} + +/// A menu-title click while a different pull-down is already open switches +/// to the clicked one instead of stacking overlays. +#[test] +fn menu_title_click_switches_from_an_already_open_menu() { + let mut state = test_state(UiPhase::Panels); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Left))); + let (state, _) = update(state, Command::MenuTitleClick(MenuId::Options)); + assert_eq!(state.menu().unwrap().active, MenuId::Options); +} + +/// mouse-input "Menu item activation" — clicking `Files` then `Delete` +/// starts the delete-confirmation flow exactly as F8 would. +#[test] +fn menu_item_click_activates_the_item_exactly_like_menu_activate() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + let delete_index = crate::menu::entries(MenuId::Files).iter().position(|e| matches!(e, MenuEntry::Item(i) if i.label == "Delete")).unwrap(); + let (state, _) = update(state, Command::MenuItemClick(delete_index)); + assert!(state.menu().is_none(), "activating an item closes the bar"); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. }))); +} + +#[test] +fn menu_item_click_on_a_separator_is_a_no_op() { + // No shipped item is disabled any more (pulldown-menu-completion design + // D5), so the "unselectable row" click case is pinned via a separator — + // the disabled-item guard itself is covered by menu.rs's local-fixture + // tests. + let mut state = test_state(UiPhase::Panels); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + let separator_index = crate::menu::entries(MenuId::Files).iter().position(|e| matches!(e, MenuEntry::Separator)).unwrap(); + let (state, effects) = update(state, Command::MenuItemClick(separator_index)); + assert!(state.menu().is_some(), "an unselectable row never closes the bar"); + assert!(effects.is_empty()); +} + +#[test] +fn menu_item_click_with_no_menu_open_is_a_no_op() { + let state = test_state(UiPhase::Panels); + let before = state.clone(); + let (state, effects) = update(state, Command::MenuItemClick(0)); + assert!(effects.is_empty()); + assert_eq!(state, before); +} + +/// mouse-input "Dialog button" — clicking `Skip All` on the conflict dialog +/// applies the choice exactly as if selected by keyboard. +#[test] +fn dialog_button_click_conflict_skip_all_sends_the_same_effect_as_the_key() { + let progress_state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update(progress_state, Command::JobConflict(sample_conflict())); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. }), "setup precondition"); + + let (_, effects) = update(state.clone(), Command::FileOpConflictChoice(ConflictChoice::SkipAll)); + let (_, click_effects) = update(state, Command::DialogButtonClick(ButtonId::ConflictSkipAll)); + assert_eq!(effects, click_effects); +} + +/// operation-dialogs "Overwrite-conflict dialog" scenario "The Cancel +/// control and hotkey match Esc" (process-lifecycle-hardening task 4.5): a +/// Cancel answer — by key or by the `(C)ancel` control — is forwarded to +/// the job as `SendConflictReply(Cancel)` and the dialog returns to +/// Progress until `JobDone` arrives, exactly like every other answer. +#[test] +fn conflict_cancel_answer_is_forwarded_and_returns_the_dialog_to_progress() { + let progress_state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (state, _) = update(progress_state, Command::JobConflict(sample_conflict())); + assert!(matches!(state.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. }), "setup precondition"); + + let (after_key, effects) = update(state.clone(), Command::FileOpConflictChoice(ConflictChoice::Cancel)); + assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Cancel)]); + assert!( + matches!(after_key.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. }), + "back to Progress until JobDone: {:?}", + after_key.phase + ); + + let (after_click, click_effects) = update(state, Command::DialogButtonClick(ButtonId::ConflictCancel)); + assert_eq!(click_effects, effects, "the control routes to the same command as the key"); + assert_eq!(after_click.phase, after_key.phase); +} + +/// mouse-input "Running job accepts Cancel only" — the Cancel button click +/// signals the same cancellation the keyboard path does. +#[test] +fn dialog_button_click_progress_cancel_cancels_the_job() { + let state = running_progress_state(JobKind::Copy, "/left", "/right"); + let (_, effects) = update(state, Command::DialogButtonClick(ButtonId::ProgressCancel)); + assert_eq!(effects, vec![Effect::CancelJob]); +} + +/// Quit-dialog buttons reach the same global `quit_confirm` handling the +/// keyboard's Y/N does, regardless of what phase is underneath. +#[test] +fn dialog_button_click_quit_yes_confirms_quit() { + let mut state = test_state(UiPhase::Panels); + state.quit_confirm = true; + let (state, effects) = update(state, Command::DialogButtonClick(ButtonId::QuitYes)); + assert!(effects.contains(&Effect::Quit)); + assert!(!state.quit_confirm); +} + +#[test] +fn dialog_button_click_quit_no_cancels_the_quit_dialog_and_restores_context() { + let mut state = test_state(UiPhase::Viewer(crate::viewer::ViewerState::new(PathBuf::from("f.txt"), 0))); + state.quit_confirm = true; + let (state, effects) = update(state, Command::DialogButtonClick(ButtonId::QuitNo)); + assert!(effects.is_empty()); + assert!(!state.quit_confirm); + assert!(matches!(state.phase, UiPhase::Viewer(_)), "cancelling only clears the flag, restoring whatever was underneath"); +} + +/// `Command::OpenActionMenuAt`: files get the same single-target menu +/// `handle_enter` builds; directories get the same menu minus View, Edit, +/// and Run (file-action-menu "Directory targets and selection-scoped +/// invocation"). +#[test] +fn open_action_menu_at_opens_the_menu_for_a_file() { + let mut state = test_state(UiPhase::Panels); + state.right.entries = vec![file_entry("notes.txt", 1)]; + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Right, name: OsString::from("notes.txt") }); + assert_eq!(state.active, PanelSide::Right); + assert_eq!(state.right.cursor, 0); + let menu = state.file_action_menu().expect("the menu opens for a file target"); + assert_eq!(menu.target_name, OsString::from("notes.txt")); + assert!(!menu.selection_scoped, "not a member of any selection"); +} + +/// file-action-menu "Directory targets and selection-scoped invocation" — +/// "Directory menu contents". +#[test] +fn open_action_menu_at_on_a_directory_opens_the_menu_without_view_edit_or_run() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("src")]; + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("src") }); + assert_eq!(state.left.cursor, 0, "the cursor moves to the target"); + let menu = state.file_action_menu().expect("the menu opens for a directory target too"); + assert_eq!( + menu.entries, + vec![ + FileActionMenuEntry::Open, + FileActionMenuEntry::SendToClipboard, + FileActionMenuEntry::Copy, + FileActionMenuEntry::Rename, + FileActionMenuEntry::Move, + FileActionMenuEntry::Delete, + ] + ); + assert_eq!(menu.selected(), FileActionMenuEntry::Open, "Open is highlighted first for a directory target"); +} + +#[test] +fn open_action_menu_at_on_the_parent_entry_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::new(OsString::from(".."), EntryKind::ParentDir, 0, None, false, false)]; + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("..") }); + assert!(state.file_action_menu().is_none(), "`..` is never a valid action-menu target"); +} + +/// file-action-menu "Directory targets and selection-scoped invocation" — +/// "Selection-scoped delete": right-clicking a member of a multi-entry +/// selection scopes Delete to the whole selection, not just the clicked +/// entry. +#[test] +fn open_action_menu_at_on_a_selected_entry_scopes_delete_to_the_whole_selection() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3), file_entry("d.txt", 4)]; + state.left.selected.insert(OsString::from("a.txt")); + state.left.selected.insert(OsString::from("b.txt")); + state.left.selected.insert(OsString::from("c.txt")); + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("b.txt") }); + let menu = state.file_action_menu().unwrap(); + assert!(menu.selection_scoped); + + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { + assert_eq!(sources.len(), 3, "the dialog is scoped to the whole selection, naming the count"); + } + other => panic!("expected DeleteConfirm scoped to the selection, got {other:?}"), + } +} + +/// Right-clicking an entry that is *not* selected stays scoped to that one +/// entry, exactly like the pre-existing Enter behavior. +#[test] +fn open_action_menu_at_on_an_unselected_entry_stays_single_target() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2)]; + state.left.selected.insert(OsString::from("a.txt")); + let (state, _) = update(state, Command::OpenActionMenuAt { side: PanelSide::Left, name: OsString::from("b.txt") }); + let menu = state.file_action_menu().unwrap(); + assert!(!menu.selection_scoped); + + let (state, _) = update(state, Command::FileActionMenuHotkey('D')); + match state.phase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { sources, .. }) => { + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].original_name, OsString::from("b.txt")); + } + other => panic!("expected DeleteConfirm scoped to b.txt alone, got {other:?}"), + } +} + +/// file-action-menu "Enter stays single-target": the keyboard path never +/// scopes to the selection even when the cursor entry is itself selected. +#[test] +fn enter_on_a_selected_entry_still_opens_a_single_target_menu() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("b.txt", 2), file_entry("c.txt", 3)]; + state.left.selected.insert(OsString::from("a.txt")); + state.left.selected.insert(OsString::from("b.txt")); + state.left.selected.insert(OsString::from("c.txt")); + state.left.cursor = 1; // b.txt, itself selected + let (state, _) = update(state, Command::Enter); + let menu = state.file_action_menu().unwrap(); + assert!(!menu.selection_scoped, "Enter-key invocation SHALL remain single-target and file-only"); +} + +// --------------------------------------------------------------------- +// resize-preserves-session-state: shrinking below the 60x16 floor raises +// `State::undersized` and leaves the phase and every overlay intact +// (application-shell "Resize handling with 60x16 hard floor and +// placeholder"; design D1-D7) +// --------------------------------------------------------------------- + +/// Task 4.1: shrink to 50x12, then enlarge back to 80x24. The phase and +/// every overlay field — in fact the whole `State` apart from `term_size` +/// and `undersized`, which are restored too — must be equal before and +/// after, with `undersized` set in between. The input is first normalised +/// to 80x24 so the comparison is against a reconciled 80x24 state. +fn assert_survives_shrink_and_enlarge(state: State) -> State { + let (state, _) = update(state, Command::Resize(80, 24)); + let before = state.clone(); + + let (shrunk, effects) = update(state, Command::Resize(50, 12)); + assert!(effects.is_empty(), "a resize emits no effects"); + assert!(shrunk.undersized, "50x12 is below the floor"); + assert_eq!(shrunk.term_size, (50, 12)); + assert_eq!(shrunk.phase, before.phase, "the shrink must not touch the phase"); + assert_eq!(shrunk.menu(), before.menu()); + assert_eq!(shrunk.drive_select(), before.drive_select()); + assert_eq!(shrunk.fuzzy_jump(), before.fuzzy_jump()); + assert_eq!(shrunk.find_file(), before.find_file()); + assert_eq!(shrunk.user_menu(), before.user_menu()); + assert_eq!(shrunk.history_dialog(), before.history_dialog()); + assert_eq!(shrunk.theme_picker(), before.theme_picker()); + assert_eq!(shrunk.file_action_menu(), before.file_action_menu()); + assert_eq!(shrunk.help(), before.help()); + assert_eq!(shrunk.quick_search, before.quick_search); + assert_eq!(shrunk.command_line, before.command_line); + assert_eq!(shrunk.left.quick_filter, before.left.quick_filter); + + let (restored, effects) = update(shrunk, Command::Resize(80, 24)); + assert!(effects.is_empty()); + assert!(!restored.undersized); + assert_eq!(restored, before, "enlarging must restore the state exactly as it was"); + restored +} + +fn twenty_line_editor() -> EditorState { + let text: String = (1..=20).map(|n| format!("line {n}\n")).collect(); + EditorState::from_bytes(PathBuf::from("/left/notes.txt"), text.as_bytes()).expect("valid UTF-8 fixture") +} + +/// Task 4.2: the editor with a modified buffer, the caret on line 12 and +/// overwrite mode (application-shell "Unsaved editor buffer survives a +/// shrink"). +#[test] +fn undersized_preserves_a_modified_editor_buffer_caret_and_mode() { + let mut state = editor_state(twenty_line_editor()); + state.term_size = (80, 24); + let (state, _) = update(state, Command::EditorChar('x')); + let mut state = state; + for _ in 0..12 { + let (s, _) = update(state, Command::EditorMove(EditorMove::Down)); + state = s; + } + let (state, _) = update(state, Command::EditorToggleMode); + let UiPhase::Editor(before) = &state.phase else { panic!("expected the editor") }; + assert!(before.is_modified()); + assert_eq!(before.caret.line, 12); + assert_eq!(before.mode, crate::editor::EntryMode::Overwrite); + let before = before.clone(); + + let restored = assert_survives_shrink_and_enlarge(state); + let UiPhase::Editor(after) = &restored.phase else { panic!("expected the editor") }; + assert_eq!(*after, before); + assert!(after.is_modified(), "the modified indicator survives"); + assert_eq!(after.caret.line, 12); + assert_eq!(after.mode, crate::editor::EntryMode::Overwrite); + assert!(after.lines[0].starts_with('x'), "the unsaved edit is still in the buffer"); +} + +/// Task 4.2: the viewer in hex mode at a non-zero offset (application-shell +/// "Viewer position and mode survive a shrink"). +#[test] +fn undersized_preserves_the_viewer_mode_and_offset() { + let mut viewer = ViewerState::new(PathBuf::from("/left/big.bin"), 1 << 20); + viewer.mode = crate::viewer::ViewMode::Hex; + viewer.top_offset = 0x4000; + let state = test_state(UiPhase::Viewer(viewer.clone())); + let restored = assert_survives_shrink_and_enlarge(state); + assert_eq!(restored.phase, UiPhase::Viewer(viewer)); +} + +/// Task 4.2: the F5 destination dialog with typed text (application-shell +/// "Open dialog and typed text survive a shrink"). +#[test] +fn undersized_preserves_the_destination_dialog_and_its_text() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestCopy); + let mut state = state; + for c in r"D:\backup".chars() { + let (s, _) = update(state, Command::TextEdit(TextEditOp::Insert(c))); + state = s; + } + // The dialog opens pre-filled with the other panel's directory; the + // typed text lands after it. + let typed = match &state.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => { + assert!(input.text().ends_with(r"D:\backup"), "typed text landed: {:?}", input.text()); + input.clone() + } + other => panic!("expected the destination dialog, got {other:?}"), + }; + let restored = assert_survives_shrink_and_enlarge(state); + match &restored.phase { + UiPhase::FileOpSetup(FileOpSetup::DestinationInput { input, .. }) => assert_eq!(*input, typed, "text and caret are where they were"), + other => panic!("expected the destination dialog, got {other:?}"), + } +} + +/// Task 4.2: the F8 delete confirmation. +#[test] +fn undersized_preserves_the_delete_confirmation() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let (state, _) = update(state, Command::RequestDelete); + assert!(matches!(state.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. }))); + let restored = assert_survives_shrink_and_enlarge(state); + assert!(matches!(restored.phase, UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { .. }))); +} + +/// Task 4.2: a running job with each of its dialogs (application-shell +/// "Running job keeps reporting progress while undersized"). +#[test] +fn undersized_preserves_a_running_job_and_each_of_its_dialogs() { + // Progress. + let progress = running_progress_state(JobKind::Copy, "/left", "/right"); + let restored = assert_survives_shrink_and_enlarge(progress.clone()); + assert!(matches!(restored.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Progress { .. }, .. })); + + // Conflict, without a rename in progress. + let (conflict, _) = update(progress.clone(), Command::JobConflict(sample_conflict())); + assert!(matches!(conflict.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { rename_input: None, .. }, .. })); + let restored = assert_survives_shrink_and_enlarge(conflict.clone()); + assert!(matches!(restored.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { rename_input: None, .. }, .. })); + + // Conflict, with a rename being typed. + let (renaming, _) = update(conflict, Command::FileOpBeginRename); + let (renaming, _) = update(renaming, Command::TextEdit(TextEditOp::Insert('b'))); + assert!(matches!(renaming.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { rename_input: Some(_), .. }, .. })); + let restored = assert_survives_shrink_and_enlarge(renaming); + match &restored.phase { + UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { rename_input: Some(input), .. }, .. } => assert_eq!(input.text(), "b"), + other => panic!("expected the conflict dialog mid-rename, got {other:?}"), + } + + // Error. + let info = ErrorInfo { path: PathBuf::from("/left/a.txt"), message: "permission denied".to_string() }; + let (error, _) = update(progress, Command::JobError(info)); + assert!(matches!(error.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { .. }, .. })); + let restored = assert_survives_shrink_and_enlarge(error); + assert!(matches!(restored.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { .. }, .. })); +} + +/// Task 4.2: the end-of-job summary. +#[test] +fn undersized_preserves_the_end_of_job_summary() { + let skipped = vec![SkippedItem::skipped(PathBuf::from("/left/a.txt"), "exists".to_string())]; + let state = test_state(UiPhase::FileOpSummary(skipped.clone())); + let restored = assert_survives_shrink_and_enlarge(state); + assert_eq!(restored.phase, UiPhase::FileOpSummary(skipped)); +} + +/// One way of opening an overlay beside the `Panels` phase, for task 4.3. +type OpenOverlay = Box; + +/// Task 4.3: every overlay that lives beside the `Panels` phase. +#[test] +fn undersized_preserves_every_overlay_over_the_panels() { + let overlays: Vec<(&str, OpenOverlay)> = vec![ + ("F9 menu", Box::new(|s| s.open_overlay(Overlay::Menu(MenuState::opened())))), + ("drive select", Box::new(|s| s.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C', 'D'], Some('C')))))), + ("fuzzy jump", Box::new(|s| s.open_overlay(Overlay::FuzzyJump(FuzzyJumpState::new())))), + ("find file", Box::new(|s| s.open_overlay(Overlay::FindFile(FindFileState::new(PathBuf::from("/left")))))), + ("user menu", Box::new(|s| s.open_overlay(Overlay::UserMenu(UserMenuState::new())))), + ("history dialog", Box::new(|s| s.open_overlay(Overlay::HistoryDialog(HistoryDialogState::new())))), + ("theme picker", Box::new(|s| s.open_overlay(Overlay::ThemePicker(ThemePickerState::open("nc-classic"))))), + ("file-action menu", Box::new(|s| s.open_overlay(Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))))), + ("help", Box::new(|s| s.open_overlay(Overlay::Help(HelpState::new())))), + ("quick filter", Box::new(|s| s.left.set_quick_filter(Some("rep".to_string())))), + ("type-ahead", Box::new(|s| s.quick_search = Some("no".to_string()))), + ("command-line text", Box::new(|s| s.command_line = LineEdit::from("dir /w"))), + ]; + for (name, open) in overlays { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1), file_entry("notes.txt", 2)]; + open(&mut state); + let restored = assert_survives_shrink_and_enlarge(state.clone()); + assert_eq!(restored.phase, UiPhase::Panels, "{name}"); + } +} + +/// Task 4.4: the splash is the one exception — it is dismissed by the shrink +/// and does not return (startup-splash "Shrinking below the floor +/// mid-splash", "Below the floor at startup"). +#[test] +fn undersized_dismisses_the_splash_for_good() { + let state = test_state(UiPhase::Splash { started_at_ms: 0 }); + let (state, _) = update(state, Command::Resize(50, 12)); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels); + let (state, _) = update(state, Command::Resize(80, 24)); + assert!(!state.undersized); + assert_eq!(state.phase, UiPhase::Panels, "enlarging reveals the panels, never the splash"); + + let (state, _) = State::initial(Theme::classic(), (50, 12), 0, 0, PathBuf::from("/l"), PathBuf::from("/r"), true); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::Panels, "a start below the floor skips the splash"); +} + +fn shrunk(state: State) -> State { + let (state, _) = update(state, Command::Resize(50, 12)); + assert!(state.undersized); + state +} + +/// Task 4.5: job replies keep being applied to the hidden running dialog +/// (application-shell "Running job keeps reporting progress while +/// undersized", "Conflict raised while undersized waits for an answer", +/// "Job finishing while undersized still refreshes the panels"). +#[test] +fn undersized_still_applies_job_progress_conflict_and_error() { + let state = shrunk(running_progress_state(JobKind::Copy, "/left", "/right")); + + let info = ProgressInfo { files_done: 2, files_total: 3, bytes_done: 20, bytes_total: 30, current_file: OsString::from("b.txt") }; + let (state, effects) = update(state, Command::JobProgress(info.clone())); + assert!(effects.is_empty()); + assert!(state.undersized); + match &state.phase { + UiPhase::FileOpRunning { dialog: RunningDialog::Progress { progress, .. }, .. } => assert_eq!(*progress, info), + other => panic!("expected the hidden progress dialog to update, got {other:?}"), + } + + let (conflict, effects) = update(state.clone(), Command::JobConflict(sample_conflict())); + assert!(effects.is_empty(), "no default answer is sent: the job waits for the user"); + assert!(conflict.undersized); + assert!(matches!(conflict.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. })); + // Enlarging shows the conflict dialog, and answering it resumes the job. + let (conflict, _) = update(conflict, Command::Resize(80, 24)); + assert!(matches!(conflict.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Conflict { .. }, .. })); + let (_, effects) = update(conflict, Command::FileOpConflictChoice(ConflictChoice::Overwrite)); + assert_eq!(effects, vec![Effect::SendConflictReply(ConflictChoice::Overwrite)]); + + let info = ErrorInfo { path: PathBuf::from("/left/a.txt"), message: "permission denied".to_string() }; + let (error, effects) = update(state, Command::JobError(info)); + assert!(effects.is_empty()); + assert!(error.undersized); + assert!(matches!(error.phase, UiPhase::FileOpRunning { dialog: RunningDialog::Error { .. }, .. })); +} + +#[test] +fn undersized_job_done_still_rereads_the_panels() { + let state = shrunk(running_progress_state(JobKind::Copy, "/left", "/right")); + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert!(state.undersized, "the placeholder stays up"); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!( + without_git_info_effects(effects), + vec![start_listing(&state, PanelSide::Left, "/left"), start_listing(&state, PanelSide::Right, "/right"),] + ); + + let state = shrunk(running_progress_state(JobKind::Copy, "/left", "/right")); + let skipped = vec![SkippedItem::skipped(PathBuf::from("/left/a.txt"), "exists".to_string())]; + let (state, effects) = update( + state, + Command::JobDone { + outcome: JobOutcome::Completed { skipped: skipped.clone() }, + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + }, + ); + assert!(state.undersized); + assert_eq!(state.phase, UiPhase::FileOpSummary(skipped), "the summary waits under the placeholder"); + assert!(without_git_info_effects(effects).iter().all(|e| matches!(e, Effect::StartListing { .. }))); +} + +/// Task 4.6: replies to requests made before the shrink are applied to the +/// hidden screen. +#[test] +fn undersized_still_applies_editor_saved_viewer_opened_and_editor_opened() { + // EditorSaved: the saved buffer replaces the hidden one, and the caret + // is not re-scrolled against the sub-floor viewport (design D6). + let mut state = editor_state(twenty_line_editor()); + state.term_size = (80, 24); + let mut state = state; + for _ in 0..12 { + let (s, _) = update(state, Command::EditorMove(EditorMove::Down)); + state = s; + } + let (state, _) = update(state, Command::EditorChar('x')); + let state = shrunk(state); + let mut saved = twenty_line_editor(); + for _ in 0..12 { + saved.move_down(); + } + let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: false }); + assert!(effects.is_empty()); + assert!(state.undersized); + match &state.phase { + UiPhase::Editor(editor) => { + assert!(!editor.is_modified(), "the saved buffer replaced the hidden one"); + assert_eq!(editor.caret.line, 12); + assert_eq!(editor.top_line, 0, "no re-scroll against a 10-row window while undersized"); + } + other => panic!("expected the hidden editor, got {other:?}"), + } + // A failed save likewise lands on the hidden editor without a re-scroll. + let (state, _) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); + match &state.phase { + UiPhase::Editor(editor) => { + assert_eq!(editor.save_error.as_deref(), Some("disk full")); + assert_eq!(editor.top_line, 0); + } + other => panic!("expected the hidden editor, got {other:?}"), + } + + // ViewerOpened over hidden panels: being undersized is not "leaving the + // panels", so the reply is still current (offload-blocking-io D4). + let state = with_pending_open(shrunk(test_state(UiPhase::Panels)), 3); + let (state, _) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/a.txt"), file_len: 10 }); + assert!(state.undersized); + assert!(matches!(state.phase, UiPhase::Viewer(_)), "the viewer opens under the placeholder"); + + // EditorOpened over hidden panels. + let state = with_pending_open(shrunk(test_state(UiPhase::Panels)), 3); + let (state, _) = update(state, Command::EditorOpened { request: 3, editor: Box::new(twenty_line_editor()) }); + assert!(state.undersized); + assert!(matches!(state.phase, UiPhase::Editor(_)), "the editor opens under the placeholder"); +} + +/// Design D6: the first resize back above the floor re-scrolls a hidden +/// editor so its caret is visible at the restored size; the caret itself +/// never moves. +#[test] +fn enlarging_rescrolls_the_hidden_editor_to_its_caret() { + let text: String = (1..=40).map(|n| format!("line {n}\n")).collect(); + let mut editor = EditorState::from_bytes(PathBuf::from("/left/notes.txt"), text.as_bytes()).expect("valid UTF-8 fixture"); + for _ in 0..30 { + editor.move_down(); + } + assert_eq!((editor.caret.line, editor.top_line), (30, 0), "built unreconciled on purpose"); + let mut state = editor_state(editor); + state.term_size = (80, 24); + let state = shrunk(state); + match &state.phase { + UiPhase::Editor(editor) => assert_eq!(editor.top_line, 0, "the shrink touches nothing"), + other => panic!("expected the editor, got {other:?}"), + } + let (state, _) = update(state, Command::Resize(80, 24)); + match &state.phase { + UiPhase::Editor(editor) => { + assert_eq!(editor.caret.line, 30, "the caret never moves"); + assert!(editor.top_line > 0 && editor.top_line <= 30, "the caret is scrolled into the 22-row body, got top_line {}", editor.top_line); + assert!(editor.caret.line < editor.top_line + 22); + } + other => panic!("expected the editor, got {other:?}"), + } +} + +/// Task 4.7: quitting from the placeholder over a running job cancels the +/// job first, as it does at full size (design D4). Before this change the +/// placeholder phase had replaced `FileOpRunning`, so only `Quit` was sent. +#[test] +fn undersized_confirm_quit_over_a_running_job_cancels_it() { + let state = shrunk(running_progress_state(JobKind::Copy, "/left", "/right")); + let (state, _) = update(state, Command::RequestQuit); + assert!(state.quit_confirm); + let (_, effects) = update(state, Command::ConfirmQuit); + assert_eq!(effects, vec![Effect::CancelJob, Effect::Quit]); +} + +/// Task 4.8 is `drag_clears_on_resize_below_the_minimum` above; this pins +/// the other half of design D6 — the drag cannot be started under the +/// placeholder either. +#[test] +fn undersized_refuses_a_new_drag() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("a.txt", 1)]; + let state = shrunk(state); + let (state, _) = update(state, Command::DragBegin { side: PanelSide::Left, name: OsString::from("a.txt"), op: JobKind::Copy }); + assert!(state.drag.is_none()); +} diff --git a/crates/filecommand-core/src/update/tests/routing.rs b/crates/filecommand-core/src/update/tests/routing.rs new file mode 100644 index 0000000..e939fa2 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/routing.rs @@ -0,0 +1,348 @@ +//! The single precedence order and the overlay slot (reducer-overlay- +//! structure design D1/D2/D3/D5/D6; application-shell "Modal overlays are +//! mutually exclusive by construction"). + +use super::*; +use crate::dialogs::HelpState; +use crate::fs_ops::dialog::FileOpSetup; +use crate::fs_ops::ProgressInfo; +use crate::listing::FindMatch; +use crate::menu::{entries, MenuAction, MenuId, MenuState}; + +fn panels() -> State { + test_state(UiPhase::Panels) +} + +fn with_overlay(overlay: Overlay) -> State { + let mut state = panels(); + state.open_overlay(overlay); + state +} + +fn delete_confirm_phase() -> UiPhase { + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + }) +} + +fn viewer_phase() -> UiPhase { + UiPhase::Viewer(ViewerState::new(PathBuf::from("/left/a.txt"), 10)) +} + +fn editor_phase() -> UiPhase { + UiPhase::Editor(EditorState::from_bytes(PathBuf::from("/left/a.txt"), b"hello").expect("valid UTF-8 fixture")) +} + +/// One state per overlay variant, each opened the way its opener does. +fn every_overlay() -> Vec<(&'static str, Overlay)> { + vec![ + ("menu", Overlay::Menu(MenuState::opened())), + ("drive select", Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C'], Some('C')))), + ("fuzzy jump", Overlay::FuzzyJump(FuzzyJumpState::new())), + ("find file", Overlay::FindFile(FindFileState::new(PathBuf::from("/left")))), + ("user menu", Overlay::UserMenu(UserMenuState::new())), + ("history dialog", Overlay::HistoryDialog(HistoryDialogState::new())), + ("theme picker", Overlay::ThemePicker(ThemePickerState::open("nc-classic"))), + ("file-action menu", Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))), + ("help", Overlay::Help(HelpState::new())), + ("startup warning", Overlay::StartupWarning("usermenu.toml is malformed".to_string())), + ] +} + +// --------------------------------------------------------------------- +// `input_layer`: one unit test per layer (task 2.2; design D2) +// --------------------------------------------------------------------- + +#[test] +fn quit_confirm_is_the_top_layer_over_every_context() { + for phase in [UiPhase::Panels, UiPhase::Splash { started_at_ms: 0 }, delete_confirm_phase(), viewer_phase(), editor_phase()] { + let mut state = test_state(phase); + state.quit_confirm = true; + assert_eq!(input_layer(&state), InputLayer::QuitConfirm); + state.undersized = true; + assert_eq!(input_layer(&state), InputLayer::QuitConfirm, "above the placeholder too"); + } + for (name, overlay) in every_overlay() { + let mut state = with_overlay(overlay); + state.quit_confirm = true; + assert_eq!(input_layer(&state), InputLayer::QuitConfirm, "{name}"); + } +} + +#[test] +fn undersized_is_the_layer_below_quit_and_hides_every_screen_but_the_startup_warning() { + for phase in [UiPhase::Panels, delete_confirm_phase(), viewer_phase(), editor_phase()] { + let mut state = test_state(phase); + state.undersized = true; + assert_eq!(input_layer(&state), InputLayer::Undersized); + } + for (name, overlay) in every_overlay() { + let mut state = with_overlay(overlay); + state.undersized = true; + let expected = if name == "startup warning" { InputLayer::Overlay } else { InputLayer::Undersized }; + assert_eq!(input_layer(&state), expected, "{name}"); + } +} + +#[test] +fn a_file_operation_dialog_is_the_file_op_layer() { + assert_eq!(input_layer(&test_state(delete_confirm_phase())), InputLayer::FileOp); + assert_eq!(input_layer(&running_progress_state(JobKind::Copy, "/left", "/right")), InputLayer::FileOp); + assert_eq!(input_layer(&test_state(UiPhase::FileOpSummary(vec![]))), InputLayer::FileOp); +} + +#[test] +fn the_viewer_is_the_viewer_layer() { + assert_eq!(input_layer(&test_state(viewer_phase())), InputLayer::Viewer); +} + +#[test] +fn the_editor_is_the_editor_layer() { + assert_eq!(input_layer(&test_state(editor_phase())), InputLayer::Editor); +} + +#[test] +fn an_open_overlay_is_the_overlay_layer_over_the_panels_and_the_splash() { + for (name, overlay) in every_overlay() { + assert_eq!(input_layer(&with_overlay(overlay.clone())), InputLayer::Overlay, "{name} over the panels"); + let mut over_splash = test_state(UiPhase::Splash { started_at_ms: 0 }); + over_splash.open_overlay(overlay); + assert_eq!(input_layer(&over_splash), InputLayer::Overlay, "{name} over the splash"); + } +} + +#[test] +fn the_bare_panels_are_the_panels_layer() { + assert_eq!(input_layer(&panels()), InputLayer::Panels); + let mut typing = panels(); + typing.command_line = LineEdit::from("dir"); + typing.quick_search = Some("a".to_string()); + assert_eq!(input_layer(&typing), InputLayer::Panels, "the typing sinks are part of the panels layer"); +} + +#[test] +fn the_splash_is_the_splash_layer() { + assert_eq!(input_layer(&test_state(UiPhase::Splash { started_at_ms: 0 })), InputLayer::Splash); +} + +// --------------------------------------------------------------------- +// The overlay slot (design D1/D5) +// --------------------------------------------------------------------- + +#[test] +fn the_typed_accessors_answer_only_for_their_own_variant() { + let state = with_overlay(Overlay::Help(HelpState::new())); + assert!(state.help().is_some()); + assert!(state.menu().is_none() && state.drive_select().is_none() && state.fuzzy_jump().is_none() && state.find_file().is_none()); + assert!(state.user_menu().is_none() && state.history_dialog().is_none() && state.theme_picker().is_none()); + assert!(state.file_action_menu().is_none() && state.startup_warning().is_none()); + assert!(matches!(state.overlay(), Some(Overlay::Help(_)))); + let mut state = state; + assert!(matches!(state.close_overlay(), Some(Overlay::Help(_)))); + assert!(state.overlay().is_none()); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "open_overlay over an open")] +fn opening_an_overlay_over_another_is_a_routing_bug_caught_in_debug_builds() { + let mut state = with_overlay(Overlay::Help(HelpState::new())); + state.open_overlay(Overlay::Menu(MenuState::opened())); +} + +/// Task 3.1 / design D5: startup warnings are last-writer-wins — the second +/// message replaces the first in the slot rather than asserting it empty. +#[test] +fn two_successive_startup_warnings_leave_the_second_in_the_slot() { + let mut state = panels(); + state.set_startup_warning("first".to_string()); + state.set_startup_warning("second".to_string()); + assert_eq!(state.startup_warning(), Some("second")); + assert!(matches!(state.overlay(), Some(Overlay::StartupWarning(_)))); + let (state, effects) = update(state, Command::DismissStartupWarning); + assert!(effects.is_empty()); + assert!(state.overlay().is_none(), "any key dismisses the warning and empties the slot"); +} + +#[test] +fn a_malformed_user_menu_reload_raises_the_startup_warning_in_the_slot() { + let state = panels(); + let result = config::UserMenuLoadResult { entries: vec![], warnings: vec![], created_default: false, malformed: true, problem: None }; + let (state, _) = update(state, Command::UserMenuReloaded(result)); + assert!(state.startup_warning().is_some_and(|m| m.contains(config::USERMENU_FILE))); +} + +/// Task 3.5 (application-shell "The user menu's Themes entry replaces the +/// user menu"): confirming the built-in Themes slot swaps the overlay in +/// place, with the active theme highlighted. +#[test] +fn the_user_menus_themes_entry_replaces_it_with_the_theme_picker() { + let state = with_overlay(Overlay::UserMenu(UserMenuState::new())); + // No user entries, so cursor 0 is the compiled-in Themes slot. + let (state, effects) = update(state, Command::UserMenuConfirm); + assert!(effects.is_empty(), "no shell command runs"); + assert!(state.user_menu().is_none()); + let picker = state.theme_picker().expect("the theme picker opened in the user menu's place"); + assert_eq!(crate::theme::BUILTIN_THEME_NAMES[picker.highlight], state.theme.name); +} + +/// Task 6.7 (design D5/D6), the reducer form of the key router's old +/// `the_drive_dialog_outranks_the_menu`: the drive list, the one +/// asynchronous opener, replaces whatever overlay is in the slot — drive +/// select wins, as it always did in the key router. +#[test] +fn drive_list_ready_replaces_an_open_menu_in_the_slot() { + let state = with_overlay(Overlay::Menu(MenuState::opened())); + let (state, effects) = update(state, Command::DriveListReady { target: PanelSide::Left, drives: vec!['C', 'D'] }); + assert!(state.menu().is_none(), "the menu is gone"); + let dialog = state.drive_select().expect("the drive dialog is in the slot"); + assert_eq!(dialog.target, PanelSide::Left); + assert_eq!(effects.iter().filter(|e| matches!(e, Effect::FetchDriveLabel { .. })).count(), 2); +} + +/// Task 3.6 / design D5: a drive label applies only to a drive dialog in +/// the slot with the matching target and generation; with another overlay +/// open it changes nothing. +#[test] +fn a_drive_label_for_a_closed_dialog_touches_no_other_overlay() { + let (opened, _) = update(panels(), Command::DriveListReady { target: PanelSide::Left, drives: vec!['C'] }); + let generation = opened.drive_select().unwrap().generation; + let (closed, _) = update(opened, Command::DriveSelectCancel); + let mut help_open = closed; + help_open.open_overlay(Overlay::Help(HelpState::new())); + let before = help_open.clone(); + let (after, effects) = + update(help_open, Command::DriveLabelResolved { target: PanelSide::Left, letter: 'C', label: Some("SYS".to_string()), generation }); + assert!(effects.is_empty()); + assert_eq!(after, before); +} + +// --------------------------------------------------------------------- +// Reducer tests of the application-shell scenarios (tasks 6.1–6.6a) +// --------------------------------------------------------------------- + +/// 6.1 "A menu item opens its dialog in place of the menu". +#[test] +fn activating_commands_find_file_leaves_only_the_find_file_dialog_in_the_slot() { + let state = with_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Commands))); + let index = entries(MenuId::Commands) + .iter() + .position(|e| matches!(e, MenuEntry::Item(item) if item.action == MenuAction::FindFile)) + .expect("Commands lists Find file"); + let (state, effects) = update(state, Command::MenuItemClick(index)); + assert!(effects.is_empty()); + assert!(state.menu().is_none(), "the menu closed"); + let dialog = state.find_file().expect("the find-file dialog opened in its place"); + assert_eq!(dialog.root, PathBuf::from("/left")); + // The same through the keyboard: highlight the item, then Enter. + let mut keyboard = with_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Commands))); + keyboard.menu_mut().unwrap().selected = index; + let (keyboard, _) = update(keyboard, Command::MenuActivate); + assert!(keyboard.menu().is_none() && keyboard.find_file().is_some()); +} + +/// 6.4 "Late results of a dismissed search touch no other overlay". +#[test] +fn late_results_of_a_dismissed_search_leave_an_open_help_window_untouched() { + let (state, _) = update(panels(), Command::FindFileOpen); + let (state, effects) = update(state, Command::FindFileSubmit); + let request = effects + .iter() + .find_map(|e| match e { + Effect::FindInSubtree { request, .. } => Some(*request), + _ => None, + }) + .expect("the search started"); + let (state, _) = update(state, Command::FindFileCancel); + assert!(state.find_file().is_none()); + let (state, _) = update(state, Command::HelpOpen); + let before = state.clone(); + let late_match = FindMatch { relative_path: PathBuf::from("sub/a.txt"), entry: file_entry("a.txt", 1) }; + let (state, effects) = update(state, Command::FindFileMatch { request, m: late_match }); + assert!(effects.is_empty()); + assert_eq!(state, before, "the late match is dropped and the Help window is unchanged"); + let (state, effects) = update(state, Command::FindFileSearchDone { request }); + assert!(effects.is_empty()); + assert_eq!(state, before); + assert!(state.find_file().is_none() && state.help().is_some()); +} + +/// 6.5 "An overlay command with its overlay closed does nothing". +#[test] +fn an_overlay_command_with_the_slot_empty_is_a_no_op() { + let commands = [ + Command::MenuActivate, + Command::MenuSelectNext, + Command::MenuClose, + Command::MenuItemClick(0), + Command::DriveSelectConfirm, + Command::DriveSelectMove(1), + Command::FuzzyJumpConfirm, + Command::FuzzyJumpMove(1), + Command::FindFileConfirm, + Command::FindFileSubmit, + Command::FindFileMove(1), + Command::FindFileMatch { request: 1, m: FindMatch { relative_path: PathBuf::from("a.txt"), entry: file_entry("a.txt", 1) } }, + Command::FindFileSearchDone { request: 1 }, + Command::UserMenuConfirm, + Command::UserMenuMove(1), + Command::HistoryDialogConfirm, + Command::HistoryDialogMove(1), + Command::ThemePickerConfirm, + Command::ThemePickerMove(1), + Command::FileActionMenuConfirm, + Command::FileActionMenuMove(1), + Command::FileActionMenuItemClick(0), + Command::FileActionMenuHotkey('o'), + Command::HelpActivate, + Command::HelpMove(1), + Command::HelpCancel, + Command::DismissStartupWarning, + ]; + let before = panels(); + for cmd in commands { + let (after, effects) = update(before.clone(), cmd.clone()); + assert!(effects.is_empty(), "{cmd:?} returned {effects:?}"); + assert_eq!(after, before, "{cmd:?} changed the state"); + } +} + +/// 6.6 "The quit dialog stacks above an open overlay": cancelling it leaves +/// the pull-down exactly as it was. (That the next key then maps to a menu +/// command is the key router's half, in `filecommand-tui`.) +#[test] +fn cancelling_the_quit_dialog_over_an_open_menu_leaves_the_menu_in_the_slot() { + let state = with_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + let (state, _) = update(state, Command::MenuSelectNext); + let menu_before = state.menu().cloned().expect("the menu is open"); + let (state, _) = update(state, Command::RequestQuit); + assert!(state.quit_confirm && state.menu().is_some(), "the dialog opens above the menu, not instead of it"); + // The menu's own keys are not claimed by the quit layer, and the slot is + // untouched by the cancel. + let (state, effects) = update(state, Command::CancelQuit); + assert!(effects.is_empty()); + assert!(!state.quit_confirm); + assert_eq!(state.menu(), Some(&menu_before)); + let (state, _) = update(state, Command::MenuSelectNext); + assert_eq!(state.menu().unwrap().selected, menu_before.selected + 1, "the menu receives the next command"); +} + +/// 6.6a "Worker replies pass beneath the quit dialog" (design D2): the quit +/// layer claims only its own two commands. +#[test] +fn job_progress_passes_beneath_the_quit_dialog_to_the_running_job() { + let mut state = running_progress_state(JobKind::Copy, "/left", "/right"); + state.quit_confirm = true; + let progress = ProgressInfo { files_done: 2, files_total: 3, bytes_done: 20, bytes_total: 30, current_file: OsString::from("b.txt") }; + let (state, effects) = update(state, Command::JobProgress(progress.clone())); + assert!(effects.is_empty()); + assert!(state.quit_confirm, "the quit dialog stays open"); + match &state.phase { + UiPhase::FileOpRunning { dialog: RunningDialog::Progress { progress: shown, .. }, .. } => assert_eq!(shown, &progress), + other => panic!("the progress dialog underneath shows the new progress: {other:?}"), + } +} diff --git a/crates/filecommand-core/src/update/tests/viewer.rs b/crates/filecommand-core/src/update/tests/viewer.rs new file mode 100644 index 0000000..b5b9d66 --- /dev/null +++ b/crates/filecommand-core/src/update/tests/viewer.rs @@ -0,0 +1,773 @@ +use super::*; + +// --------------------------------------------------------------------- +// M4: F3 viewer & F4 external editor +// --------------------------------------------------------------------- + +#[test] +fn f3_on_a_file_dispatches_open_viewer_without_touching_the_filesystem() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 42)]; + let (state, effects) = update(state, Command::RequestViewer); + assert_eq!(effects, vec![Effect::OpenViewer { request: 3, path: PathBuf::from("/left/notes.txt") }]); + // Nothing changed yet — the phase flips only once `ViewerOpened` comes + // back, since opening is I/O and `update` performs none itself. + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.pending_open, Some(PendingOpen { request: 3, side: PanelSide::Left, kind: PendingOpenKind::Viewer })); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening notes.txt…"), "the panel announces the outstanding open"); +} + +#[test] +fn f3_on_a_directory_or_parent_dir_is_a_no_op() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![dir_entry("sub")]; + let (_, effects) = update(state, Command::RequestViewer); + assert!(effects.is_empty()); + + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![Entry::parent_dir()]; + let (_, effects) = update(state, Command::RequestViewer); + assert!(effects.is_empty()); +} + +#[test] +fn f3_on_an_empty_panel_is_a_no_op() { + let state = test_state(UiPhase::Panels); + let (_, effects) = update(state, Command::RequestViewer); + assert!(effects.is_empty()); +} + +#[test] +fn viewer_opened_enters_the_viewer_phase_at_the_start_of_the_file() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("big.log", 5_000_000_000)]; + let (state, _) = update(state, Command::RequestViewer); + let (state, effects) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/big.log"), file_len: 5_000_000_000 }); + assert!(effects.is_empty()); + assert_eq!(state.pending_open, None, "the applied reply clears the pending open"); + assert_eq!(state.left.pending_note, None, "and takes the `Opening…` note down"); + match state.phase { + UiPhase::Viewer(v) => { + assert_eq!(v.path, PathBuf::from("/left/big.log")); + assert_eq!(v.file_len, 5_000_000_000); + assert_eq!(v.top_offset, 0); + assert_eq!(v.mode, crate::viewer::ViewMode::Text); + assert!(!v.wrap); + } + other => panic!("expected UiPhase::Viewer, got {other:?}"), + } +} + +#[test] +fn viewer_open_failed_surfaces_an_inline_error_on_the_active_panel_instead_of_opening() { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let (state, effects) = update(state, Command::ViewerOpenFailed { request: 3, message: "access denied".to_string() }); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.left.last_error.as_deref(), Some("access denied")); + assert_eq!(state.pending_open, None); +} + +#[test] +fn a_subsequently_successful_f3_open_clears_a_stale_last_error_from_an_earlier_failed_attempt() { + let mut state = test_state(UiPhase::Panels); + let (state_after_failure, _) = + update(with_pending_open(state.clone(), 3), Command::ViewerOpenFailed { request: 3, message: "access denied".to_string() }); + assert_eq!(state_after_failure.left.last_error.as_deref(), Some("access denied")); + + state.left.last_error = Some("access denied".to_string()); + let (state, _) = update(with_pending_open(state, 3), Command::ViewerOpened { request: 3, path: PathBuf::from("/left/notes.txt"), file_len: 10 }); + assert_eq!(state.left.last_error, None, "a successful F3 open must clear a stale error left by an earlier failed attempt"); + assert!(matches!(state.phase, UiPhase::Viewer(_))); +} + +#[test] +fn f10_closes_the_viewer_and_returns_focus_to_the_panels() { + let state = opened_viewer_state(1000); + let (state, effects) = update(state, Command::ViewerClose); + assert_eq!(state.phase, UiPhase::Panels); + assert!(effects.is_empty()); +} + +#[test] +fn f4_in_the_viewer_toggles_mode_and_the_key_bar_label_swaps() { + let state = opened_viewer_state(1000); + let (state, effects) = update(state, Command::ViewerToggleMode); + assert!(effects.is_empty()); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.mode, crate::viewer::ViewMode::Hex); + assert_eq!(v.mode.toggle_label(), "ASCII"); + + let (state, _) = update(state, Command::ViewerToggleMode); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.mode, crate::viewer::ViewMode::Text); + assert_eq!(v.mode.toggle_label(), "Hex"); +} + +#[test] +fn f2_in_the_viewer_toggles_wrap_and_resets_horizontal_scroll() { + let mut state = opened_viewer_state(1000); + if let UiPhase::Viewer(v) = &mut state.phase { + v.h_scroll = 12; + } + let (state, _) = update(state, Command::ViewerToggleWrap); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert!(v.wrap); + assert_eq!(v.h_scroll, 0); +} + +#[test] +fn viewer_set_top_clamps_to_the_file_length() { + let state = opened_viewer_state(100); + let (state, _) = update(state, Command::ViewerSetTop(1_000_000)); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.top_offset, 100); +} + +#[test] +fn viewer_set_h_scroll_updates_the_column_indicator() { + let state = opened_viewer_state(1000); + let (state, _) = update(state, Command::ViewerSetHScroll(30)); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.h_scroll, 30); +} + +#[test] +fn viewer_keys_are_not_forwarded_to_panels_while_the_viewer_is_open() { + // ToggleActivePanel is a Panels-phase command; while the viewer owns + // the phase it must be swallowed rather than mutating panel focus + // (viewer: Frame-less full-screen chrome — "Viewer owns focus while + // open"). + let state = opened_viewer_state(1000); + let active_before = state.active; + let (state, effects) = update(state, Command::ToggleActivePanel); + assert_eq!(state.active, active_before); + assert!(effects.is_empty()); + assert!(matches!(state.phase, UiPhase::Viewer(_))); +} + +#[test] +fn viewer_search_confirm_dispatches_the_search_effect_and_keeps_the_pattern() { + let state = opened_viewer_state(1000); + let (state, _) = update(state, Command::ViewerSearchStart); + let (state, _) = update(state, Command::ViewerSearchChar('a')); + let (state, _) = update(state, Command::ViewerSearchChar('b')); + let (state, effects) = update(state, Command::ViewerSearchConfirm); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + let request = v.search_request.expect("a search request id must be minted and recorded"); + assert_eq!(effects, vec![Effect::RunViewerSearch { path: PathBuf::from("/left/f.txt"), start_offset: 0, pattern: b"ab".to_vec(), request }]); + assert_eq!(v.search_pattern, Some(b"ab".to_vec())); + assert_eq!(v.search_input, None, "the prompt closes once the search is dispatched"); +} + +#[test] +fn viewer_search_backspace_and_cancel_edit_the_in_progress_pattern() { + let state = opened_viewer_state(1000); + let (state, _) = update(state, Command::ViewerSearchStart); + let (state, _) = update(state, Command::ViewerSearchChar('x')); + let (state, _) = update(state, Command::ViewerSearchBackspace); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.search_input, Some(String::new())); + + let (state, _) = update(state, Command::ViewerSearchCancel); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.search_input, None); +} + +#[test] +fn empty_search_pattern_does_not_dispatch_a_search() { + let state = opened_viewer_state(1000); + let (state, _) = update(state, Command::ViewerSearchStart); + let (state, effects) = update(state, Command::ViewerSearchConfirm); + assert!(effects.is_empty()); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.search_pattern, None); +} + +#[test] +fn viewer_search_result_moves_the_top_anchor_and_highlights_the_match() { + let state = opened_viewer_state(1000); + let (state, request) = viewer_with_pattern_confirmed(state, "ab"); + let (state, _) = update(state, Command::ViewerSearchResult { offset: Some(250), match_range: Some((250, 256)), request }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.top_offset, 250); + assert_eq!(v.last_match, Some((250, 256))); +} + +#[test] +fn viewer_search_result_with_no_match_leaves_the_top_anchor_untouched() { + let mut state = opened_viewer_state(1000); + if let UiPhase::Viewer(v) = &mut state.phase { + v.top_offset = 40; + } + let (state, request) = viewer_with_pattern_confirmed(state, "zz"); + let (state, _) = update(state, Command::ViewerSearchResult { offset: None, match_range: None, request }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.top_offset, 40); +} + +#[test] +fn a_stale_out_of_order_search_reply_is_dropped_not_applied() { + // The M3-style staleness race: a first search is confirmed (minting + // request id N), then — before its reply arrives — a second search is + // confirmed (minting N+1) which is the one still outstanding. The first + // search's (now stale) reply must be silently dropped rather than + // clobbering the top offset/match the second search is still waiting + // on to fill in. + let mut state = opened_viewer_state(1000); + if let UiPhase::Viewer(v) = &mut state.phase { + v.top_offset = 5; + } + let (state, stale_request) = viewer_with_pattern_confirmed(state, "first"); + let (state, current_request) = viewer_with_pattern_confirmed(state, "second"); + assert_ne!(stale_request, current_request); + + // The stale reply from the first (superseded) search arrives late. + let (state, _) = update(state, Command::ViewerSearchResult { offset: Some(900), match_range: Some((900, 905)), request: stale_request }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.top_offset, 5, "a stale reply must not move the top offset"); + assert_eq!(v.last_match, None, "a stale reply must not set a phantom match highlight"); + + // The still-current search's reply, once it arrives, is applied. + let (state, _) = update(state, Command::ViewerSearchResult { offset: Some(700), match_range: Some((700, 706)), request: current_request }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.top_offset, 700); + assert_eq!(v.last_match, Some((700, 706))); +} + +#[test] +fn a_stale_search_reply_for_a_closed_and_reopened_viewer_session_is_dropped() { + // A search is confirmed against one open file, then the viewer is + // closed and a different file opened (or even the same file reopened — + // it is a fresh session either way, per `ViewerState::new`). The first + // session's stale reply must not be applied to the new session even + // though `state.phase` is `UiPhase::Viewer(_)` again by the time it + // arrives. + let state = opened_viewer_state(1000); + let (state, stale_request) = viewer_with_pattern_confirmed(state, "needle"); + + let (state, _) = update(state, Command::ViewerClose); + let (state, _) = + update(with_pending_open(state, 50), Command::ViewerOpened { request: 50, path: PathBuf::from("/left/other.txt"), file_len: 200 }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.search_request, None, "a freshly opened session has no outstanding search"); + + let (state, _) = update(state, Command::ViewerSearchResult { offset: Some(50), match_range: Some((50, 56)), request: stale_request }); + let UiPhase::Viewer(v) = &state.phase else { panic!("expected viewer phase") }; + assert_eq!(v.path, PathBuf::from("/left/other.txt")); + assert_eq!(v.top_offset, 0, "the stale reply from the closed session must not move the new session's top offset"); + assert_eq!(v.last_match, None, "the stale reply must not set a phantom match highlight on the new session"); +} + +#[test] +fn f4_from_a_panel_with_no_editor_configured_shows_the_message_and_spawns_nothing() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("report.txt", 10)]; + let (state, effects) = update(state, Command::RequestExternalEditor); + assert!(effects.is_empty()); + assert_eq!(state.left.last_error.as_deref(), Some(crate::external_editor::NO_EDITOR_CONFIGURED_MESSAGE)); +} + +#[test] +fn f4_from_a_panel_with_a_blank_editor_command_is_also_treated_as_unset() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some(" ".to_string()); + state.left.entries = vec![file_entry("report.txt", 10)]; + let (_, effects) = update(state, Command::RequestExternalEditor); + assert!(effects.is_empty()); +} + +#[test] +fn f4_on_a_directory_entry_does_not_launch_the_editor() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![dir_entry("sub")]; + let (state, effects) = update(state, Command::RequestExternalEditor); + assert!(effects.is_empty()); + assert_eq!(state.left.last_error, None, "a directory target is silently ignored, not an error dialog"); +} + +#[test] +fn f4_on_parent_dir_does_not_launch_the_editor() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![Entry::parent_dir()]; + let (_, effects) = update(state, Command::RequestExternalEditor); + assert!(effects.is_empty()); +} + +#[test] +fn f4_launches_the_configured_editor_on_the_file_under_the_cursor() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.active = PanelSide::Right; + state.right.cwd = PathBuf::from(r"C:\work"); + state.right.entries = vec![file_entry("report.txt", 10)]; + let (_, effects) = update(state, Command::RequestExternalEditor); + match effects.as_slice() { + [Effect::RunExternalEditor(inv, PanelSide::Right)] => { + assert_eq!(inv.program, "notepad"); + assert_eq!(inv.file_arg, OsString::from("report.txt")); + assert_eq!(inv.cwd, PathBuf::from(r"C:\work")); + } + other => panic!("expected a single RunExternalEditor effect, got {other:?}"), + } +} + +#[test] +fn f4_passes_the_original_os_string_file_name_through_without_lossy_conversion() { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let raw = std::ffi::OsStr::from_bytes(&[0x66, 0x6f, 0x80, 0x6f]).to_os_string(); + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![Entry::new(raw.clone(), EntryKind::File, 0, None, false, false)]; + let (_, effects) = update(state, Command::RequestExternalEditor); + match effects.as_slice() { + [Effect::RunExternalEditor(inv, _)] => assert_eq!(inv.file_arg, raw), + other => panic!("expected a single RunExternalEditor effect, got {other:?}"), + } + } +} + +#[test] +fn external_editor_spawn_failure_surfaces_an_inline_error_without_crashing() { + let state = test_state(UiPhase::Panels); + let (state, effects) = update(state, Command::ExternalEditorSpawnFailed { message: "program not found".to_string() }); + assert!(effects.is_empty()); + assert_eq!(state.left.last_error.as_deref(), Some("program not found")); + assert_eq!(state.phase, UiPhase::Panels, "still running, not crashed"); +} + +#[test] +fn a_subsequently_successful_f4_launch_clears_a_stale_last_error_from_an_earlier_failed_attempt() { + let mut state = test_state(UiPhase::Panels); + state.editor = Some("notepad".to_string()); + state.left.entries = vec![file_entry("report.txt", 10)]; + state.left.last_error = Some("program not found".to_string()); + + let (state, effects) = update(state, Command::RequestExternalEditor); + assert_eq!(state.left.last_error, None, "successfully dispatching the F4 editor spawn must clear a stale error from an earlier failed attempt"); + assert!(matches!(effects.as_slice(), [Effect::RunExternalEditor(_, PanelSide::Left)])); +} + +// --------------------------------------------------------------------- +// offload-blocking-io: open replies are gated on the pending request +// (application-shell "Slow storage never blocks the event loop"; design D4) +// --------------------------------------------------------------------- + +/// application-shell "A newer F3 supersedes an outstanding open": the late +/// answer for the first file is discarded without touching the newer +/// pending open, and the newer answer still applies. +#[test] +fn an_open_reply_with_a_stale_request_id_is_discarded() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("slow.log", 10), file_entry("fast.txt", 10)]; + let (state, effects) = update(state, Command::RequestViewer); + assert_eq!(effects, vec![Effect::OpenViewer { request: 3, path: PathBuf::from("/left/slow.log") }]); + let (state, _) = update(state, Command::MoveCursor(CursorMove::Down(1))); + let (state, effects) = update(state, Command::RequestViewer); + assert_eq!(effects, vec![Effect::OpenViewer { request: 4, path: PathBuf::from("/left/fast.txt") }]); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening fast.txt…"), "the newer request owns the note"); + + let before = state.clone(); + let (state, effects) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/slow.log"), file_len: 10 }); + assert!(effects.is_empty()); + assert_eq!(state, before, "the stale reply changes nothing, and the newer pending open is untouched"); + let (state, effects) = update(state, Command::ViewerOpenFailed { request: 3, message: "late failure".to_string() }); + assert!(effects.is_empty()); + assert_eq!(state, before, "a stale failure lands nowhere either"); + + let (state, _) = update(state, Command::ViewerOpened { request: 4, path: PathBuf::from("/left/fast.txt"), file_len: 10 }); + match &state.phase { + UiPhase::Viewer(v) => assert_eq!(v.path, PathBuf::from("/left/fast.txt"), "the viewer opens the newer file"), + other => panic!("expected the viewer, got {other:?}"), + } + assert_eq!(state.pending_open, None); +} + +/// application-shell "A late open does not pull the user out of a menu": +/// the reply arriving with the F9 menu open is discarded, the menu stays, +/// and the pending state is cleared. +#[test] +fn an_open_reply_arriving_with_the_menu_open_is_discarded() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + let (state, _) = update(state, Command::RequestEditor); + let (state, _) = update(state, Command::MenuOpen); + assert!(state.menu().is_some()); + let (state, effects) = update(state, Command::EditorOpened { request: 3, editor: Box::new(editor_with(&["abc"])) }); + assert!(effects.is_empty()); + assert!(state.menu().is_some(), "the menu stays open"); + assert_eq!(state.phase, UiPhase::Panels, "the editor does not open"); + assert_eq!(state.pending_open, None, "the late result is discarded and the pending open cleared"); + assert_eq!(state.left.pending_note, None, "the `Opening…` note comes down"); + + // The same for the viewer, and for a reply while a dialog owns input. + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + let (state, _) = update(state, Command::RequestViewer); + let (state, _) = update(state, Command::HelpOpen); + let (state, _) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/notes.txt"), file_len: 10 }); + assert_eq!(state.phase, UiPhase::Panels, "the viewer does not open over the Help window"); + assert!(state.help().is_some()); + + // A reply after the user left the panels (a file operation dialog). + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + let (state, _) = update(state, Command::RequestViewer); + let (state, _) = update(state, Command::RequestDelete); + assert!(matches!(state.phase, UiPhase::FileOpSetup(_)), "precondition: the delete dialog owns the phase"); + let (state, _) = update(state, Command::ViewerOpened { request: 3, path: PathBuf::from("/left/notes.txt"), file_len: 10 }); + assert!(matches!(state.phase, UiPhase::FileOpSetup(_)), "the dialog is untouched"); + assert_eq!(state.pending_open, None); +} + +/// application-shell "Launching a slow handler leaves the panels +/// interactive", applied to the opens: a failure lands on the panel the +/// open was requested from, not on the panel active when it arrives. +#[test] +fn an_open_failure_lands_on_the_recorded_side_after_a_tab() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + let (state, _) = update(state, Command::RequestEditor); + let (state, _) = update(state, Command::ToggleActivePanel); + assert_eq!(state.active, PanelSide::Right); + let (state, _) = update(state, Command::EditorOpenFailed { request: 3, message: "access denied".to_string() }); + assert_eq!(state.left.last_error.as_deref(), Some("access denied"), "the failure lands on the requesting panel"); + assert_eq!(state.right.last_error, None); + assert_eq!(state.left.pending_note, None); + + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + let (state, _) = update(state, Command::RequestViewer); + let (state, _) = update(state, Command::ToggleActivePanel); + let (state, _) = update(state, Command::ViewerOpenFailed { request: 3, message: "access denied".to_string() }); + assert_eq!(state.left.last_error.as_deref(), Some("access denied")); + assert_eq!(state.right.last_error, None); +} + +/// Requesting an open supersedes an earlier failure on the same panel: the +/// `Opening…` note takes the stale error down (design D3). +#[test] +fn requesting_an_open_clears_the_panels_last_error() { + let mut state = test_state(UiPhase::Panels); + state.left.entries = vec![file_entry("notes.txt", 10)]; + state.left.last_error = Some("an older failure".to_string()); + let (state, _) = update(state, Command::RequestViewer); + assert_eq!(state.left.last_error, None); + assert_eq!(state.left.pending_note.as_deref(), Some("Opening notes.txt…")); +} + +#[test] +fn editor_open_failed_surfaces_an_inline_error() { + let state = with_pending_open(test_state(UiPhase::Panels), 3); + let (state, effects) = update(state, Command::EditorOpenFailed { request: 3, message: "access denied".to_string() }); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); + assert_eq!(state.left.last_error.as_deref(), Some("access denied")); +} + +#[test] +fn typing_in_the_editor_inserts_and_marks_the_buffer_modified() { + let state = editor_state(editor_with(&["abc"])); + let (state, _) = update(state, Command::EditorChar('X')); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.lines[0], "Xabc"); + assert!(e.is_modified()); +} + +#[test] +fn editor_move_commands_move_the_caret() { + let state = editor_state(editor_with(&["hello"])); + let (state, _) = update(state, Command::EditorMove(EditorMove::Right)); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.caret.col, 1); + let (state, _) = update(state, Command::EditorMove(EditorMove::End)); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.caret.col, 5); +} + +#[test] +fn insert_toggles_overwrite_mode() { + let state = editor_state(editor_with(&["abc"])); + let (state, _) = update(state, Command::EditorToggleMode); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.mode, EntryMode::Overwrite); +} + +#[test] +fn mark_cut_copy_paste_flow_through_the_reducer() { + let state = editor_state(editor_with(&["a", "b", "c"])); + let (state, _) = update(state, Command::EditorMark); + let (state, _) = update(state, Command::EditorMove(EditorMove::Down)); + let (state, _) = update(state, Command::EditorCut); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.lines, vec!["c".to_string(), String::new()]); + assert_eq!(e.clipboard, vec!["a".to_string(), "b".to_string()]); + + let (state, _) = update(state, Command::EditorPaste); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.lines, vec!["a".to_string(), "b".to_string(), "c".to_string(), String::new()]); +} + +#[test] +fn undo_flows_through_the_reducer() { + let state = editor_state(editor_with(&["abc"])); + let (state, _) = update(state, Command::EditorMove(EditorMove::End)); + let (state, _) = update(state, Command::EditorChar('d')); + let (state, _) = update(state, Command::EditorUndo); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.lines[0], "abc"); +} + +#[test] +fn search_prompt_confirm_moves_the_caret_to_the_next_match() { + let state = editor_state(editor_with(&["needle here", "another needle"])); + let (state, _) = update(state, Command::EditorSearchStart); + let (state, _) = update(state, Command::EditorSearchChar('n')); + let (state, _) = update(state, Command::EditorSearchChar('e')); + let (state, _) = update(state, Command::EditorSearchConfirm); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(e.search_prompt.is_none(), "the prompt closes on confirm"); + assert_eq!(e.caret, crate::editor::Caret { line: 1, col: 8 }); +} + +#[test] +fn search_prompt_backspace_and_cancel_edit_and_close_the_prompt() { + let state = editor_state(editor_with(&["abc"])); + let (state, _) = update(state, Command::EditorSearchStart); + let (state, _) = update(state, Command::EditorSearchChar('x')); + let (state, _) = update(state, Command::EditorSearchBackspace); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.search_prompt.as_deref(), Some("")); + let (state, _) = update(state, Command::EditorSearchCancel); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.search_prompt, None); +} + +#[test] +fn replace_prompt_advances_from_pattern_to_replacement_then_replaces() { + let state = editor_state(editor_with(&["hello world"])); + let (state, _) = update(state, Command::EditorReplaceStart); + let (state, _) = update(state, Command::EditorReplaceChar('w')); + let (state, _) = update(state, Command::EditorReplaceChar('o')); + let (state, _) = update(state, Command::EditorReplaceChar('r')); + let (state, _) = update(state, Command::EditorReplaceChar('l')); + let (state, _) = update(state, Command::EditorReplaceChar('d')); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.replace_prompt, Some(ReplacePrompt::Pattern("world".to_string()))); + + let (state, _) = update(state, Command::EditorReplaceConfirm); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.replace_prompt, Some(ReplacePrompt::Replacement { pattern: "world".to_string(), replacement: String::new() })); + + let (state, _) = update(state, Command::EditorReplaceChar('X')); + let (state, _) = update(state, Command::EditorReplaceConfirm); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.replace_prompt, None, "the prompt closes once the replacement runs"); + assert_eq!(e.lines[0], "hello X"); + assert!(e.is_modified()); +} + +#[test] +fn replace_confirm_with_an_empty_pattern_cancels_rather_than_advancing() { + let state = editor_state(editor_with(&["hello"])); + let (state, _) = update(state, Command::EditorReplaceStart); + let (state, _) = update(state, Command::EditorReplaceConfirm); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.replace_prompt, None); +} + +#[test] +fn editor_save_dispatches_the_save_effect_and_the_reply_updates_the_saved_snapshot() { + let mut editor = editor_with_temp_file("save-effect", &["abc"]); + editor.type_char('X'); + assert!(editor.is_modified()); + let state = editor_state(editor.clone()); + let (state, effects) = update(state, Command::EditorSave); + match effects.as_slice() { + [Effect::SaveEditor { editor: e, then_quit: false }] => { + assert_eq!(e.lines, editor.lines); + assert!(!e.save_pending, "the copy handed to the worker is not itself marked pending"); + } + other => panic!("expected a single SaveEditor effect, got {other:?}"), + } + // The buffer is unchanged until the reply arrives — save is I/O. + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(e.is_modified()); + assert!(e.save_pending, "the save is in flight (offload-blocking-io design D6)"); + + // Mirrors what the TUI's effect executor actually does: clone the + // editor into the effect, call the real (I/O-performing) `save()` on + // it, then reply with the post-save state. + let mut saved = editor; + saved.save().unwrap(); + assert!(!saved.is_modified(), "sanity: a real save clears the modified flag on the saved copy"); + let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: false }); + assert!(effects.is_empty()); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(!e.is_modified(), "the post-save snapshot from the reply clears the modified flag"); + assert!(!e.save_pending, "the reply ends the in-flight save"); +} + +#[test] +fn editor_save_failed_surfaces_an_inline_message_without_losing_the_buffer() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + editor.save_pending = true; + let state = editor_state(editor); + let (state, effects) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); + assert!(effects.is_empty()); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert_eq!(e.save_error.as_deref(), Some("disk full")); + assert!(e.is_modified(), "a failed save must not silently discard the unsaved edit"); + assert!(!e.save_pending, "the failure ends the in-flight save"); +} + +/// application-shell "Saving to a slow share shows progress and accepts no +/// edits": while the save is in flight, edits, caret moves and further +/// save/quit commands leave the editor unchanged and emit nothing; the reply +/// then clears the flag (offload-blocking-io design D6). +#[test] +fn while_a_save_is_pending_the_editor_ignores_everything_but_the_save_reply() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, effects) = update(state, Command::EditorSave); + assert!(matches!(effects.as_slice(), [Effect::SaveEditor { then_quit: false, .. }])); + let UiPhase::Editor(pending) = &state.phase else { panic!("expected editor phase") }; + assert!(pending.save_pending); + let pending = pending.clone(); + + for cmd in [ + Command::EditorChar('Y'), + Command::EditorNewline, + Command::EditorBackspace, + Command::EditorMove(EditorMove::Right), + Command::EditorMove(EditorMove::Down), + Command::EditorSave, + Command::EditorRequestQuit, + Command::EditorConfirmQuitSave, + Command::EditorConfirmQuitDiscard, + Command::EditorUndo, + ] { + let (after, effects) = update(state.clone(), cmd.clone()); + assert!(effects.is_empty(), "{cmd:?} emits nothing while a save is pending, got {effects:?}"); + let UiPhase::Editor(e) = &after.phase else { panic!("{cmd:?}: the editor stays open while a save is pending") }; + assert_eq!(*e, pending, "{cmd:?} leaves the editor unchanged while a save is pending"); + } + + // The reply — the worker's copy with its clean snapshot — clears the + // flag and the modified state, and typing works again. + let saved = editor_with(&["Xabc"]); + let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: false }); + assert!(effects.is_empty()); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(!e.save_pending); + assert!(!e.is_modified(), "the saved buffer is clean"); + let (state, _) = update(state, Command::EditorChar('Z')); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(e.is_modified(), "typing edits the buffer again once the save completed"); +} + +/// The save-on-exit confirm's "Y" is gated the same way, and a failure +/// during it clears the flag so the user can act on the error. +#[test] +fn a_save_on_exit_in_flight_is_gated_until_its_reply() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, _) = update(state, Command::EditorRequestQuit); + let (state, effects) = update(state, Command::EditorConfirmQuitSave); + assert!(matches!(effects.as_slice(), [Effect::SaveEditor { then_quit: true, .. }])); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(e.save_pending); + let (after, effects) = update(state.clone(), Command::EditorCancelQuit); + assert!(effects.is_empty()); + assert_eq!(after, state, "Esc during the in-flight save changes nothing"); + + let (state, _) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(!e.save_pending); + assert_eq!(e.save_error.as_deref(), Some("disk full")); +} + +#[test] +fn f10_on_an_unmodified_buffer_exits_directly() { + let state = editor_state(editor_with(&["abc"])); + let (state, effects) = update(state, Command::EditorRequestQuit); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn f10_on_a_modified_buffer_raises_the_save_on_exit_confirm() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, effects) = update(state, Command::EditorRequestQuit); + assert!(effects.is_empty()); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(e.quit_confirm); +} + +#[test] +fn cancelling_the_save_on_exit_confirm_returns_to_editing() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, _) = update(state, Command::EditorRequestQuit); + let (state, effects) = update(state, Command::EditorCancelQuit); + assert!(effects.is_empty()); + let UiPhase::Editor(e) = &state.phase else { panic!("expected editor phase") }; + assert!(!e.quit_confirm); +} + +#[test] +fn discarding_at_the_save_on_exit_confirm_exits_without_saving() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, _) = update(state, Command::EditorRequestQuit); + let (state, effects) = update(state, Command::EditorConfirmQuitDiscard); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels); +} + +#[test] +fn confirming_save_at_the_save_on_exit_confirm_dispatches_a_save_that_quits() { + let mut editor = editor_with_temp_file("quit-save", &["abc"]); + editor.type_char('X'); + let state = editor_state(editor.clone()); + let (state, _) = update(state, Command::EditorRequestQuit); + let (state, effects) = update(state, Command::EditorConfirmQuitSave); + match effects.as_slice() { + [Effect::SaveEditor { then_quit: true, .. }] => {} + other => panic!("expected a SaveEditor effect with then_quit: true, got {other:?}"), + } + // Still in the editor until the save actually lands. + assert!(matches!(state.phase, UiPhase::Editor(_))); + + let mut saved = editor; + saved.save().unwrap(); + let (state, effects) = update(state, Command::EditorSaved { editor: Box::new(saved), then_quit: true }); + assert!(effects.is_empty()); + assert_eq!(state.phase, UiPhase::Panels, "a successful save-then-quit closes the editor"); +} + +#[test] +fn a_failed_save_during_quit_aborts_the_quit_and_keeps_editing() { + let mut editor = editor_with(&["abc"]); + editor.type_char('X'); + let state = editor_state(editor); + let (state, _) = update(state, Command::EditorRequestQuit); + let (state, effects) = update(state, Command::EditorConfirmQuitSave); + assert!(matches!(effects.as_slice(), [Effect::SaveEditor { then_quit: true, .. }])); + + let (state, _) = update(state, Command::EditorSaveFailed { message: "disk full".to_string() }); + let UiPhase::Editor(e) = &state.phase else { panic!("expected the failed save to abort the quit and stay in the editor") }; + assert_eq!(e.save_error.as_deref(), Some("disk full")); + assert!(!e.quit_confirm, "the confirm dialog itself closes so the user can see and act on the error"); +} diff --git a/crates/filecommand-core/src/update/viewer.rs b/crates/filecommand-core/src/update/viewer.rs new file mode 100644 index 0000000..269ca11 --- /dev/null +++ b/crates/filecommand-core/src/update/viewer.rs @@ -0,0 +1,137 @@ +//! The F3 viewer: opening it on a worker thread and driving it while it owns +//! input. + +use super::*; + +// --------------------------------------------------------------------- +// F3 viewer (M4) +// --------------------------------------------------------------------- + +/// F3: open the viewer on the file under the active panel's cursor. A no-op +/// on a directory (including `..`) or an empty panel; opening itself is an +/// `Effect` because it touches the filesystem, which `update` never does +/// directly (viewer: Instant open). +pub(super) fn handle_request_viewer(state: &mut State) -> Vec { + let side = state.active; + let Some(entry) = state.panel(side).selected() else { return vec![] }; + if entry.is_dir_like() { + return vec![]; + } + let path = state.panel(side).cwd.join(&entry.name); + vec![request_open(state, side, PendingOpenKind::Viewer, path)] +} + +/// Record an outstanding F3/F4 open for `side` under a fresh request id and +/// return the effect that runs it on a worker thread (offload-blocking-io +/// design D4). The panel announces it as `Opening …` on its +/// mini-status line, which also clears a stale `last_error` from an earlier +/// failed attempt (design D3). A newer request replaces the pending entry, +/// which is what supersedes the older open: its reply no longer matches +/// (application-shell "A newer F3 supersedes an outstanding open"). +pub(super) fn request_open(state: &mut State, side: PanelSide, kind: PendingOpenKind, path: PathBuf) -> Effect { + let request = state.next_request_id(); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + state.panel_mut(side).set_pending_note(format!("Opening {name}…")); + state.pending_open = Some(PendingOpen { request, side, kind }); + match kind { + PendingOpenKind::Viewer => Effect::OpenViewer { request, path }, + PendingOpenKind::Editor => Effect::OpenEditor { request, path }, + } +} + +/// Match an F3/F4 open reply against the pending open (design D4). Returns +/// the panel the open was requested from when the reply is current and the +/// panels still own input — the caller then applies it. A reply whose id +/// is not the pending one belongs to a superseded request and is dropped +/// without touching the newer, still-outstanding one. A reply whose id +/// matches but arrives after the user left the panels or opened a menu, +/// dialog or overlay clears the pending state and is discarded +/// (application-shell "A late open does not pull the user out of a menu"). +/// Either way the panel's `Opening…` note comes down once its reply is in. +pub(super) fn take_current_open(state: &mut State, request: u64) -> Option { + if state.pending_open.as_ref().map(|p| p.request) != Some(request) { + return None; + } + let pending = state.pending_open.take()?; + state.panel_mut(pending.side).pending_note = None; + panels_own_input(state).then_some(pending.side) +} + +/// Drive every command while the viewer is open (gated in [`update`] by +/// `UiPhase::Viewer`). Byte-level work (backward scan, search) is never +/// performed here — it requires file I/O that `update` must stay free of — +/// so `ViewerSetTop` carries an already-computed offset and +/// `ViewerSearchConfirm` only kicks off `Effect::RunViewerSearch`, whose +/// result later re-enters as `Command::ViewerSearchResult` (design D1). +pub(super) fn handle_viewer(state: &mut State, cmd: Command) -> Vec { + let mut effects = Vec::new(); + let UiPhase::Viewer(mut viewer) = std::mem::replace(&mut state.phase, UiPhase::Panels) else { + unreachable!("handle_viewer only called when phase is Viewer"); + }; + match cmd { + Command::ViewerClose => { + // A search still scanning is stopped with the viewer, releasing + // the worker's own handle on the file (viewer "Search stops when + // superseded or when the viewer closes"; listing-request-guard + // design D4). The id stays `Some` after a result arrives, but + // cancelling an id with no running worker is a no-op (D3). + effects.extend(viewer.search_request.map(Effect::CancelRequest)); + return effects; + } + Command::ViewerToggleMode => viewer.toggle_mode(), + Command::ViewerToggleWrap => viewer.toggle_wrap(), + Command::ViewerSetTop(offset) => viewer.set_top_offset(offset), + Command::ViewerSetHScroll(col) => viewer.h_scroll = col, + Command::ViewerSearchStart => viewer.search_input = Some(String::new()), + Command::ViewerSearchChar(c) => { + if let Some(input) = &mut viewer.search_input { + input.push(c); + } + } + Command::ViewerSearchBackspace => { + if let Some(input) = &mut viewer.search_input { + input.pop(); + } + } + Command::ViewerSearchCancel => viewer.search_input = None, + Command::ViewerSearchConfirm => { + if let Some(pattern) = viewer.search_input.take().filter(|p| !p.is_empty()) { + let pattern_bytes = pattern.into_bytes(); + viewer.search_pattern = Some(pattern_bytes.clone()); + let path = viewer.path.clone(); + let start_offset = viewer.top_offset; + // A fresh request id per search, mirroring + // `PanelState::info_request` — lets a reply be matched + // against the search that's still outstanding, so an + // out-of-order or superseded-session reply is dropped + // rather than applied (viewer: F7 streaming search + // staleness). The previous search, if still scanning, is + // stopped rather than left to finish a multi-GB scan whose + // result would be dropped anyway (viewer "A newer search + // supersedes a running one"; listing-request-guard design + // D4). + effects.extend(viewer.search_request.map(Effect::CancelRequest)); + let request = state.next_request_id(); + viewer.search_request = Some(request); + state.phase = UiPhase::Viewer(viewer); + effects.push(Effect::RunViewerSearch { path, start_offset, pattern: pattern_bytes, request }); + return effects; + } + } + // Only apply a reply whose id matches this session's outstanding + // search. A mismatch means either a stale reply from a search this + // same session has since superseded, or one from a viewer session + // that has since closed and been reopened (a fresh `ViewerState` + // starts with `search_request: None`, which can never equal a real + // request id) — either way it is silently dropped instead of + // jumping the user to a bogus offset with a phantom match + // highlight. + Command::ViewerSearchResult { offset: Some(offset), match_range, request } if viewer.search_request == Some(request) => { + viewer.set_top_offset(offset); + viewer.last_match = match_range; + } + _ => {} + } + state.phase = UiPhase::Viewer(viewer); + effects +} diff --git a/crates/filecommand-core/src/viewer/backward.rs b/crates/filecommand-core/src/viewer/backward.rs index 85a59b9..3840e2c 100644 --- a/crates/filecommand-core/src/viewer/backward.rs +++ b/crates/filecommand-core/src/viewer/backward.rs @@ -113,6 +113,26 @@ mod tests { assert_eq!(900_000 - anchor, cap as u64); } + /// viewer-mapping-safety (design D3): a file that shrinks while it is + /// open reads short past its new end; the backward scan works on the + /// bytes that still exist, terminates, and returns an in-range anchor. + #[test] + fn truncation_while_open_terminates_the_backward_scan() { + let dir = std::env::temp_dir().join(format!("filecommand-viewer-backward-test-{}-truncated", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("file.bin"); + let mut content = vec![b'x'; 10_000]; + content[5000] = b'\n'; + std::fs::write(&path, &content).unwrap(); + let src = ByteSource::open(&path).unwrap(); + // Truncate away the only newline: the scan from 8000 reads the + // 1000 bytes that still exist, finds no newline, and hard-splits + // at the cap boundary (clamped to 0) instead of panicking. + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(1000).unwrap(); + assert_eq!(previous_line_start(&src, 8000, DEFAULT_MAX_LINE_LEN), 0); + assert_eq!(previous_line_start(&src, 10_000, DEFAULT_MAX_LINE_LEN), 0); + } + mod proptests { use super::*; use proptest::prelude::*; diff --git a/crates/filecommand-core/src/viewer/byte_source.rs b/crates/filecommand-core/src/viewer/byte_source.rs index 1275904..06a9227 100644 --- a/crates/filecommand-core/src/viewer/byte_source.rs +++ b/crates/filecommand-core/src/viewer/byte_source.rs @@ -1,56 +1,38 @@ //! Instant-open byte access for the F3 viewer. //! -//! [`ByteSource::open`] memory-maps the file, falling back to positioned -//! chunk reads when mapping is unavailable (a network path, a zero-length -//! special file, ...). Either way, open cost is O(1): only the file handle -//! and its length are touched, never the content (viewer: Instant open — -//! "First frame reads only the visible window"). The length is captured -//! once as an immutable snapshot and every read is clamped to it, so -//! navigation never reads past what was true at open even if the on-disk -//! file changes underneath us (viewer: Instant open — "Offsets clamped to -//! the snapshot length"). - -use std::fs::File; +//! [`ByteSource::open`] keeps an open read handle on the file and serves +//! every read through bounded positioned reads on it, on every platform — +//! the file is never memory-mapped, so a concurrent writer can neither +//! crash the viewer (a truncated mapping raises SIGBUS on Unix) nor be +//! blocked by it (a mapping pins the file against truncation on Windows). +//! Open cost is O(1): only the handle and its length are touched, never the +//! content (viewer: Instant open — "First frame reads only the visible +//! window"). The length is captured once as an immutable snapshot and every +//! read is clamped to it, so navigation never reads past what was true at +//! open; a file that shrinks underneath simply reads short (viewer: Instant +//! open — "Offsets clamped to the snapshot length", "Truncation while +//! viewing never crashes"). + +use std::fs::{File, OpenOptions}; use std::io; use std::path::Path; -enum Backend { - Mmap(memmap2::Mmap), - File(File), -} - /// A byte-addressable, read-only view onto a file. pub struct ByteSource { len: u64, - backend: Backend, + file: File, } impl ByteSource { - /// Open `path`, memory-mapping it when possible. `Mmap::map` fails on a - /// zero-length file (nothing to map) and on some network/special paths; - /// either way this falls back to positioned reads on the open handle - /// rather than surfacing an error (viewer: Instant open — "Mapping - /// unavailable falls back to chunk reads"). + /// Open `path` for positioned reads. On Windows the handle is opened + /// with read, write, and delete sharing, so other programs can keep + /// appending to, truncating, deleting, or renaming the file while it is + /// shown (viewer: Instant open — "Viewed file can be truncated, + /// rotated, or deleted"). pub fn open(path: &Path) -> io::Result { - let file = File::open(path)?; - let len = file.metadata()?.len(); - let backend = match unsafe { memmap2::Mmap::map(&file) } { - Ok(mmap) => Backend::Mmap(mmap), - Err(_) => Backend::File(file), - }; - Ok(ByteSource { len, backend }) - } - - /// Open `path` via the chunk-read fallback unconditionally, bypassing - /// mmap even when it would succeed. Exists so tests can compare the - /// fallback path's bytes against [`ByteSource::open`]'s on the same file - /// (viewer: Instant open — "Mapping unavailable falls back to chunk - /// reads": "the rendered content is identical to the mmap path for the - /// same byte range"). - pub fn open_chunked(path: &Path) -> io::Result { - let file = File::open(path)?; + let file = open_shared(path)?; let len = file.metadata()?.len(); - Ok(ByteSource { len, backend: Backend::File(file) }) + Ok(ByteSource { len, file }) } /// The length captured at open. Immutable for the lifetime of this @@ -65,25 +47,42 @@ impl ByteSource { /// Read up to `len` bytes starting at `offset`, clamped to the snapshot /// length so a read never crosses what was true at open. Returns fewer - /// than `len` bytes (down to zero) once the window runs past the end. + /// than `len` bytes (down to zero) once the window runs past the end — + /// including when the file has shrunk since open, where the short read + /// is simply the new end of file. An I/O error maps to an empty window, + /// never a panic. pub fn read_range(&self, offset: u64, len: usize) -> Vec { if offset >= self.len { return Vec::new(); } let end = offset.saturating_add(len as u64).min(self.len); let n = (end - offset) as usize; - match &self.backend { - Backend::Mmap(mmap) => mmap[offset as usize..offset as usize + n].to_vec(), - Backend::File(file) => { - let mut buf = vec![0u8; n]; - let read = positioned_read_to_eof(file, &mut buf, offset).unwrap_or(0); - buf.truncate(read); - buf - } - } + let mut buf = vec![0u8; n]; + let read = positioned_read_to_eof(&self.file, &mut buf, offset).unwrap_or(0); + buf.truncate(read); + buf } } +#[cfg(windows)] +fn open_shared(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + // The spec's sharing guarantee, stated explicitly rather than left to + // the standard library's default (which happens to be the same today): + // other programs can keep writing to, truncating, deleting, and + // renaming the viewed file (viewer: Instant open — "The viewer MUST NOT + // prevent other programs from changing the file while it is shown"). + const FILE_SHARE_READ: u32 = 0x1; + const FILE_SHARE_WRITE: u32 = 0x2; + const FILE_SHARE_DELETE: u32 = 0x4; + OpenOptions::new().read(true).share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).open(path) +} + +#[cfg(not(windows))] +fn open_shared(path: &Path) -> io::Result { + OpenOptions::new().read(true).open(path) +} + #[cfg(windows)] fn positioned_read(file: &File, buf: &mut [u8], offset: u64) -> io::Result { use std::os::windows::fs::FileExt; @@ -97,8 +96,15 @@ fn positioned_read(file: &File, buf: &mut [u8], offset: u64) -> io::Result io::Result { - Ok(0) +fn positioned_read(file: &File, buf: &mut [u8], offset: u64) -> io::Result { + use std::io::{Read, Seek, SeekFrom}; + // A `ByteSource` is used by one thread at a time — the render loop owns + // the viewer's source, and the F7 search worker opens its own — so the + // shared cursor this seek moves cannot race. A future change that + // shares one source across threads must revisit this fallback. + let mut handle = file; + handle.seek(SeekFrom::Start(offset))?; + handle.read(buf) } /// Fill `buf` from `file` starting at `offset`, looping past short reads @@ -141,7 +147,7 @@ mod tests { } #[test] - fn open_empty_file_falls_back_and_reports_zero_length() { + fn open_empty_file_reports_zero_length() { let path = temp_file("empty", b""); let src = ByteSource::open(&path).unwrap(); assert_eq!(src.len(), 0); @@ -167,28 +173,59 @@ mod tests { } #[test] - fn chunked_fallback_serves_identical_bytes_to_the_mmap_path() { - let contents: Vec = (0..5000u32).map(|i| (i % 251) as u8).collect(); - let path = temp_file("fallback-parity", &contents); - let mmapped = ByteSource::open(&path).unwrap(); - let chunked = ByteSource::open_chunked(&path).unwrap(); - assert_eq!(mmapped.len(), chunked.len()); - for (offset, len) in [(0u64, 100usize), (1234, 500), (4900, 200), (0, 5000)] { - assert_eq!(mmapped.read_range(offset, len), chunked.read_range(offset, len), "offset={offset} len={len}"); - } + fn open_missing_path_is_an_error_not_a_panic() { + let missing = std::env::temp_dir().join("filecommand-viewer-test-does-not-exist.bin"); + assert!(ByteSource::open(&missing).is_err()); } + /// viewer: Instant open — "Another program keeps writing to the viewed + /// file": the append succeeds while the file is shown, and reads stay + /// clamped to the length captured at open. #[test] - fn chunked_open_offsets_are_also_clamped_to_snapshot_length() { - let path = temp_file("fallback-clamp", b"0123456789"); - let src = ByteSource::open_chunked(&path).unwrap(); - assert_eq!(src.read_range(8, 10), b"89".to_vec()); - assert_eq!(src.read_range(20, 10), Vec::::new()); + fn appending_through_a_second_handle_succeeds_and_reads_stay_clamped_to_the_snapshot() { + let path = temp_file("append", b"0123456789"); + let src = ByteSource::open(&path).unwrap(); + let mut writer = OpenOptions::new().append(true).open(&path).unwrap(); + writer.write_all(b"-appended").unwrap(); + writer.flush().unwrap(); + assert_eq!(src.len(), 10, "the snapshot length does not move when the file grows"); + assert_eq!(src.read_range(0, 100), b"0123456789".to_vec()); + assert_eq!(src.read_range(10, 5), Vec::::new()); } + /// viewer: Instant open — "Truncation while viewing never crashes", + /// "Viewed file can be truncated, rotated, or deleted": truncating the + /// open file from a second handle succeeds (with the old mmap backend + /// this failed on Windows with OS error 1224, ERROR_USER_MAPPED_FILE; + /// on Unix the next read past the new end raised SIGBUS), and reads + /// past the new end come back short or empty without panicking. #[test] - fn open_missing_path_is_an_error_not_a_panic() { - let missing = std::env::temp_dir().join("filecommand-viewer-test-does-not-exist.bin"); - assert!(ByteSource::open(&missing).is_err()); + fn truncating_while_open_succeeds_and_reads_past_the_new_end_come_back_short() { + let contents = vec![b'x'; 10 * 1024]; + let path = temp_file("truncate", &contents); + let src = ByteSource::open(&path).unwrap(); + let writer = OpenOptions::new().write(true).open(&path).unwrap(); + writer.set_len(1024).unwrap(); + drop(writer); + assert_eq!(src.read_range(5000, 100), Vec::::new()); + assert_eq!(src.read_range(0, 2000).len(), 1024); + assert_eq!(src.len(), 10 * 1024, "the snapshot length still clamps navigation"); + } + + /// viewer: Instant open — "Viewed file can be truncated, rotated, or + /// deleted": rename and delete both succeed while the source is open + /// (both need the delete share on Windows), and the open handle keeps + /// serving the bytes it opened. + #[test] + fn renaming_and_deleting_while_open_succeed() { + let path = temp_file("rename-delete", b"hello world"); + let src = ByteSource::open(&path).unwrap(); + let renamed = path.with_file_name("renamed.bin"); + std::fs::rename(&path, &renamed).unwrap(); + assert_eq!(src.read_range(0, 5), b"hello".to_vec()); + std::fs::remove_file(&renamed).unwrap(); + assert_eq!(src.read_range(0, 5), b"hello".to_vec(), "the open handle keeps the bytes readable"); + drop(src); + let _ = std::fs::remove_dir(path.parent().unwrap()); } } diff --git a/crates/filecommand-core/src/viewer/forward.rs b/crates/filecommand-core/src/viewer/forward.rs index 821d475..d269cef 100644 --- a/crates/filecommand-core/src/viewer/forward.rs +++ b/crates/filecommand-core/src/viewer/forward.rs @@ -124,6 +124,26 @@ mod tests { assert_eq!(super::super::backward::previous_line_start(&src, next, 64 * 1024), 4); } + /// viewer-mapping-safety (design D3): a file that shrinks while it is + /// open reads short past its new end; the forward scan treats that + /// short window as end of file and terminates, returning an offset + /// within the snapshot length. + #[test] + fn truncation_while_open_terminates_the_forward_scan() { + let dir = std::env::temp_dir().join(format!("filecommand-viewer-forward-test-{}-truncated", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("file.bin"); + std::fs::write(&path, vec![b'x'; 10_000]).unwrap(); + let src = ByteSource::open(&path).unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(1000).unwrap(); + // No newline in the 1000 bytes that still exist: the short window + // is treated as end of file, landing on the snapshot length. + assert_eq!(next_line_start(&src, 0, 64 * 1024), 10_000); + // Starting past the new end reads empty and also lands on the + // snapshot length — no panic, no read past the real end. + assert_eq!(next_line_start(&src, 5000, 64 * 1024), 10_000); + } + mod proptests { use super::*; use proptest::prelude::*; diff --git a/crates/filecommand-core/src/viewer/search.rs b/crates/filecommand-core/src/viewer/search.rs index 831f2be..3bd3732 100644 --- a/crates/filecommand-core/src/viewer/search.rs +++ b/crates/filecommand-core/src/viewer/search.rs @@ -7,6 +7,7 @@ //! with chunk-boundary overlap). use super::byte_source::ByteSource; +use crate::fs_ops::CancelFlag; /// The default forward-scan chunk size. pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024; @@ -18,8 +19,11 @@ pub const DEFAULT_CHUNK_SIZE: usize = 64 * 1024; /// search — "Match straddling a chunk boundary is found"). Returns the /// match's absolute byte range `[start, end)`, or `None` if the pattern does /// not occur before the source's snapshot length. An empty pattern never -/// matches. -pub fn find_forward(source: &ByteSource, start_offset: u64, pattern: &[u8], chunk_size: usize) -> Option<(u64, u64)> { +/// matches. `cancel` is the search's stop signal (viewer "Search stops when +/// superseded or when the viewer closes"; listing-request-guard design D5): +/// it is checked before each chunk read, and once set the scan stops at that +/// chunk boundary and returns `None`. +pub fn find_forward(source: &ByteSource, start_offset: u64, pattern: &[u8], chunk_size: usize, cancel: &CancelFlag) -> Option<(u64, u64)> { if pattern.is_empty() { return None; } @@ -29,6 +33,9 @@ pub fn find_forward(source: &ByteSource, start_offset: u64, pattern: &[u8], chun let read_len = chunk_size.max(pattern.len()); while pos < file_len { + if cancel.is_cancelled() { + return None; + } let chunk = source.read_range(pos, read_len); if chunk.is_empty() { break; @@ -77,14 +84,14 @@ mod tests { #[test] fn finds_a_match_within_a_single_chunk() { let src = temp_source("single-chunk", b"the quick brown fox jumps over the lazy dog"); - let found = find_forward(&src, 0, b"brown", DEFAULT_CHUNK_SIZE); + let found = find_forward(&src, 0, b"brown", DEFAULT_CHUNK_SIZE, &CancelFlag::new()); assert_eq!(found, Some((10, 15))); } #[test] fn returns_none_when_pattern_absent() { let src = temp_source("absent", b"the quick brown fox"); - assert_eq!(find_forward(&src, 0, b"zebra", DEFAULT_CHUNK_SIZE), None); + assert_eq!(find_forward(&src, 0, b"zebra", DEFAULT_CHUNK_SIZE, &CancelFlag::new()), None); } #[test] @@ -92,7 +99,7 @@ mod tests { let src = temp_source("start-offset", b"aaa needle aaa needle aaa"); // First "needle" is at offset 4; searching from offset 5 must skip // it and find the second occurrence at offset 15. - let found = find_forward(&src, 5, b"needle", DEFAULT_CHUNK_SIZE); + let found = find_forward(&src, 5, b"needle", DEFAULT_CHUNK_SIZE, &CancelFlag::new()); assert_eq!(found, Some((15, 21))); } @@ -105,14 +112,14 @@ mod tests { content.extend_from_slice(b"abcdef"); content.extend_from_slice(&[b'y'; 20]); let src = temp_source("boundary", &content); - let found = find_forward(&src, 0, b"abcdef", 8); + let found = find_forward(&src, 0, b"abcdef", 8, &CancelFlag::new()); assert_eq!(found, Some((5, 11))); } #[test] fn empty_pattern_never_matches() { let src = temp_source("empty-pattern", b"anything"); - assert_eq!(find_forward(&src, 0, b"", DEFAULT_CHUNK_SIZE), None); + assert_eq!(find_forward(&src, 0, b"", DEFAULT_CHUNK_SIZE, &CancelFlag::new()), None); } #[test] @@ -124,10 +131,42 @@ mod tests { let mut content = b"NEEDLE".to_vec(); content.extend(std::iter::repeat_n(b'z', 5_000_000)); let src = temp_source("bounded-large", &content); - let found = find_forward(&src, 0, b"NEEDLE", DEFAULT_CHUNK_SIZE); + let found = find_forward(&src, 0, b"NEEDLE", DEFAULT_CHUNK_SIZE, &CancelFlag::new()); assert_eq!(found, Some((0, 6))); } + /// viewer-mapping-safety (design D3): a file that shrinks while it is + /// open reads short past its new end; the search treats that short + /// chunk as the end of the scan and terminates with no match instead of + /// faulting. + #[test] + fn truncation_while_open_terminates_the_search() { + let dir = std::env::temp_dir().join(format!("filecommand-viewer-search-test-{}-truncated", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("file.bin"); + std::fs::write(&path, vec![b'y'; 3 * DEFAULT_CHUNK_SIZE]).unwrap(); + let src = ByteSource::open(&path).unwrap(); + std::fs::OpenOptions::new().write(true).open(&path).unwrap().set_len(1000).unwrap(); + assert_eq!(find_forward(&src, 0, b"zebra", DEFAULT_CHUNK_SIZE, &CancelFlag::new()), None); + // Starting past the new end reads empty and returns immediately. + assert_eq!(find_forward(&src, 2 * DEFAULT_CHUNK_SIZE as u64, b"zebra", DEFAULT_CHUNK_SIZE, &CancelFlag::new()), None); + } + + /// listing-request-guard (viewer "Search stops when superseded or when + /// the viewer closes"; design D5): a flag already set when the search + /// starts stops it at the very first chunk check — a planted match that + /// the first chunk would otherwise find is never reported. + #[test] + fn a_pre_set_flag_returns_none_before_the_first_chunk_is_scanned() { + let src = temp_source("pre-cancelled", b"the quick brown fox jumps over the lazy dog"); + let cancel = CancelFlag::new(); + cancel.cancel(); + assert_eq!(find_forward(&src, 0, b"brown", DEFAULT_CHUNK_SIZE, &cancel), None); + // The same search with a live flag finds it, so `None` above came + // from the flag, not from the content. + assert_eq!(find_forward(&src, 0, b"brown", DEFAULT_CHUNK_SIZE, &CancelFlag::new()), Some((10, 15))); + } + mod proptests { use super::*; use proptest::prelude::*; @@ -158,7 +197,7 @@ mod tests { std::fs::write(&path, &content).unwrap(); let src = ByteSource::open(&path).unwrap(); - let found = find_forward(&src, 0, &pattern, chunk_size); + let found = find_forward(&src, 0, &pattern, chunk_size, &CancelFlag::new()); prop_assert_eq!(found, Some((expected_start, expected_start + pattern.len() as u64))); } } diff --git a/crates/filecommand-core/tests/realfs.rs b/crates/filecommand-core/tests/realfs.rs new file mode 100644 index 0000000..df483ac --- /dev/null +++ b/crates/filecommand-core/tests/realfs.rs @@ -0,0 +1,717 @@ +//! Integration tests for the job engine (`fs_ops::worker::run_job`) against +//! the real filesystem through [`RealFs`]. Each test runs in its own +//! temporary directory that is removed on drop. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use filecommand_core::fs_ops::{ + run_job, CancelFlag, ConflictChoice, ConflictInfo, ErrorChoice, ErrorInfo, Job, JobKind, JobOutcome, JobSink, ProgressInfo, RealFs, SourceItem, +}; + +/// A per-test directory under `std::env::temp_dir()`, named with the process +/// id and the test name, removed when the guard drops. +struct TempDir(PathBuf); + +impl TempDir { + fn new(test_name: &str) -> Self { + let path = std::env::temp_dir().join(format!("filecommand-realfs-{}-{test_name}", std::process::id())); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + // Read-only files block `remove_dir_all` on Windows, so the bit is + // cleared first. Links are not descended into: the walk clears the + // link's own attributes only, never its target's. + clear_readonly_recursive(&self.0); + let _ = fs::remove_dir_all(&self.0); + } +} + +fn clear_readonly_recursive(path: &Path) { + let Ok(md) = fs::symlink_metadata(path) else { return }; + if md.permissions().readonly() { + let mut perms = md.permissions(); + // A per-test scratch tree that is removed right after this; the + // lint's concern (world-writable Unix modes) does not apply. + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + let _ = fs::set_permissions(path, perms); + } + if md.is_dir() && !md.file_type().is_symlink() { + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + clear_readonly_recursive(&entry.path()); + } + } + } +} + +/// Every entry name in `dir` that carries the worker's temporary-file suffix. +fn partial_files_in(dir: &Path) -> Vec { + fs::read_dir(dir) + .map(|entries| entries.flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().contains(".~fc-partial")).collect()) + .unwrap_or_default() +} + +/// A minimal [`JobSink`] that records the outcome and answers conflicts and +/// errors from scripted queues. The queues default to Overwrite and Skip, so +/// a test that unexpectedly raises a dialog takes a path that fails on its +/// assertions instead of hanging; every raised error is recorded, so a test +/// can assert that no error-recovery dialog was shown. +#[derive(Default)] +struct RecordingSink { + conflicts_seen: usize, + conflict_answers: Vec, + errors_seen: Vec, + error_answers: Vec, + outcome: Option, + cancel: CancelFlag, + progress_events: usize, + /// Raise the cancel flag when the n-th progress event (1-based) arrives. + /// The job's opening `starting` report is event 1, so the first in-copy + /// report is event 2. + cancel_on_progress_event: Option, +} + +impl JobSink for RecordingSink { + fn progress(&mut self, _info: ProgressInfo) { + self.progress_events += 1; + if self.cancel_on_progress_event == Some(self.progress_events) { + self.cancel.cancel(); + } + } + fn conflict(&mut self, _info: ConflictInfo) -> ConflictChoice { + self.conflicts_seen += 1; + if self.conflict_answers.is_empty() { + ConflictChoice::Overwrite + } else { + self.conflict_answers.remove(0) + } + } + fn error(&mut self, info: ErrorInfo) -> ErrorChoice { + self.errors_seen.push(info); + if self.error_answers.is_empty() { + ErrorChoice::Skip + } else { + self.error_answers.remove(0) + } + } + fn done(&mut self, outcome: JobOutcome) { + self.outcome = Some(outcome); + } + fn is_cancelled(&self) -> bool { + self.cancel.is_cancelled() + } +} + +fn file_source(path: &Path) -> SourceItem { + SourceItem { original_name: path.file_name().unwrap().to_os_string(), path: path.to_path_buf(), is_dir: false } +} + +fn copy_job(src: &Path, dest_dir: &Path) -> Job { + Job { + kind: JobKind::Copy, + sources: vec![file_source(src)], + source_dir: src.parent().unwrap().to_path_buf(), + dest_dir: dest_dir.to_path_buf(), + new_dir_name: None, + } +} + +fn move_job(src: &Path, dest_dir: &Path) -> Job { + Job { + kind: JobKind::Move, + sources: vec![file_source(src)], + source_dir: src.parent().unwrap().to_path_buf(), + dest_dir: dest_dir.to_path_buf(), + new_dir_name: None, + } +} + +fn assert_completed_clean(sink: &RecordingSink) { + assert!(sink.errors_seen.is_empty(), "no error dialog: {:?}", sink.errors_seen); + assert!(matches!(&sink.outcome, Some(JobOutcome::Completed { skipped }) if skipped.is_empty()), "{:?}", sink.outcome); +} + +/// A deterministic payload larger than one 256 KiB copy chunk, so a copy +/// reports progress more than once. +fn multi_chunk_content() -> Vec { + (0..1_200_000u32).map(|i| (i % 253) as u8).collect() +} + +// --------------------------------------------------------------------- +// durable-file-copy, cross-platform (filesystem-error-handling "The suite +// exercises the real implementation on temporary directories") +// --------------------------------------------------------------------- + +#[test] +fn copy_round_trip_lands_content_with_no_partial_left() { + let temp = TempDir::new("copy-round-trip"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + let big = multi_chunk_content(); + fs::write(src.join("big.bin"), &big).unwrap(); + fs::write(src.join("small.txt"), b"small").unwrap(); + fs::write(src.join("empty.txt"), b"").unwrap(); + + let job = Job { + kind: JobKind::Copy, + sources: vec![file_source(&src.join("big.bin")), file_source(&src.join("small.txt")), file_source(&src.join("empty.txt"))], + source_dir: src.clone(), + dest_dir: dst.clone(), + new_dir_name: None, + }; + let mut sink = RecordingSink::default(); + run_job(&job, &RealFs::new(), &mut sink); + + assert_completed_clean(&sink); + assert_eq!(fs::read(dst.join("big.bin")).unwrap(), big); + assert_eq!(fs::read(dst.join("small.txt")).unwrap(), b"small"); + assert_eq!(fs::read(dst.join("empty.txt")).unwrap(), b""); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); + assert_eq!(fs::read(src.join("big.bin")).unwrap(), big, "a copy leaves its source alone"); +} + +/// file-operations "Cancel removes the partial file". +#[test] +fn cancel_during_a_copy_leaves_no_target_and_no_partial() { + let temp = TempDir::new("cancel-cleanup"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("big.bin"), multi_chunk_content()).unwrap(); + + let mut sink = RecordingSink { cancel_on_progress_event: Some(2), ..Default::default() }; + run_job(©_job(&src.join("big.bin"), &dst), &RealFs::new(), &mut sink); + + assert!(matches!(sink.outcome, Some(JobOutcome::Cancelled { .. })), "{:?}", sink.outcome); + assert!(sink.errors_seen.is_empty(), "a cancel is not an error: {:?}", sink.errors_seen); + assert!(!dst.join("big.bin").exists(), "no file under the target name"); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); +} + +/// file-operations "Read-only target is cleared before overwrite": the +/// finished copy is renamed over a read-only target after its bit is cleared. +#[test] +fn read_only_target_is_overwritten_on_an_overwrite_answer() { + let temp = TempDir::new("read-only-target"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("report.txt"), b"new content").unwrap(); + let target = dst.join("report.txt"); + fs::write(&target, b"old").unwrap(); + let mut perms = fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&target, perms).unwrap(); + + let mut sink = RecordingSink { conflict_answers: vec![ConflictChoice::Overwrite], ..Default::default() }; + run_job(©_job(&src.join("report.txt"), &dst), &RealFs::new(), &mut sink); + + assert_eq!(sink.conflicts_seen, 1, "the existing target raised the overwrite-conflict dialog"); + assert_completed_clean(&sink); + assert_eq!(fs::read(&target).unwrap(), b"new content"); + assert!(!fs::metadata(&target).unwrap().permissions().readonly(), "the copy carries the source's (writable) state"); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); +} + +/// file-operations "An existing file with the temporary name is left alone". +#[test] +fn an_existing_file_with_the_temporary_name_is_left_alone() { + let temp = TempDir::new("existing-partial-name"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("report.txt"), b"the copy").unwrap(); + let leftover = dst.join("report.txt.~fc-partial"); + fs::write(&leftover, b"someone else's file").unwrap(); + + let mut sink = RecordingSink::default(); + run_job(©_job(&src.join("report.txt"), &dst), &RealFs::new(), &mut sink); + + assert_completed_clean(&sink); + assert_eq!(fs::read(dst.join("report.txt")).unwrap(), b"the copy"); + assert_eq!(fs::read(&leftover).unwrap(), b"someone else's file", "not modified or removed"); + assert_eq!( + partial_files_in(&dst), + vec![OsString::from("report.txt.~fc-partial")], + "only the pre-existing file remains; the -2 temporary was renamed away" + ); +} + +/// file-operations "A copy never overwrites its own source": F5 into the +/// directory the file is already in leaves it unchanged — a self-copy used +/// to truncate the file to zero bytes before the first read. +#[test] +fn copy_into_own_directory_leaves_file_unchanged() { + let temp = TempDir::new("copy-into-own-directory"); + let file = temp.path().join("report.txt"); + let content: Vec = (0..2048u32).map(|i| (i % 251) as u8).collect(); + fs::write(&file, &content).unwrap(); + + let job = Job { + kind: JobKind::Copy, + sources: vec![SourceItem { original_name: OsString::from("report.txt"), path: file.clone(), is_dir: false }], + source_dir: temp.path().to_path_buf(), + dest_dir: temp.path().to_path_buf(), + new_dir_name: None, + }; + let mut sink = RecordingSink::default(); + run_job(&job, &RealFs::new(), &mut sink); + + assert_eq!(fs::read(&file).unwrap(), content, "the file keeps its original 2,048 bytes"); + assert_eq!(sink.conflicts_seen, 0, "a same-file copy must not raise the overwrite-conflict dialog"); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "the same-file item is reported as skipped: {skipped:?}"); + assert_eq!(skipped[0].path, file); + assert!(skipped[0].reason.contains("same file"), "the reason should name the self-copy: {}", skipped[0].reason); + } + other => panic!("expected Completed with one skipped item, got {other:?}"), + } +} + +// --------------------------------------------------------------------- +// Windows-only: real NTFS junctions (move-reparse-point-safety design D6) +// --------------------------------------------------------------------- + +#[cfg(windows)] +mod windows_only { + use super::*; + use std::io; + + use filecommand_core::fs_ops::{ChunkControl, CopyOptions, CopyReport, FileSystem, FsMetadata, RawEntry}; + + /// Create a directory junction `link` → `target` with `cmd /C mklink /J`. + /// Junctions need no privilege, unlike symlinks, so this runs on any + /// developer machine or CI runner. + pub fn mklink_junction(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(link) + .arg(target) + .stdout(std::process::Stdio::null()) + .status() + .expect("cmd /C mklink /J spawns"); + assert!(status.success(), "mklink /J {} {} failed: {status}", link.display(), target.display()); + let md = fs::symlink_metadata(link).expect("the junction exists"); + assert!(md.file_type().is_symlink(), "mklink /J produced a reparse point"); + } + + /// Delegates every [`FileSystem`] call to the inner implementation but + /// answers `same_volume = false`, so a move takes the real + /// copy-then-delete path even though the test fixture lives on one + /// volume (move-reparse-point-safety design D6). It also logs the + /// renames and file removals it sees, so a test can check that a move + /// landed its copy before it touched the source. + pub struct CrossVolume { + pub inner: F, + pub ops: std::sync::Mutex>, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum Op { + Rename(PathBuf, PathBuf), + RemoveFile(PathBuf), + } + + impl CrossVolume { + pub fn new(inner: F) -> Self { + CrossVolume { inner, ops: std::sync::Mutex::new(vec![]) } + } + pub fn ops(&self) -> Vec { + self.ops.lock().unwrap().clone() + } + } + + impl FileSystem for CrossVolume { + fn metadata(&self, path: &Path) -> io::Result { + self.inner.metadata(path) + } + fn metadata_follow(&self, path: &Path) -> io::Result { + self.inner.metadata_follow(path) + } + fn read_dir(&self, path: &Path) -> io::Result> { + self.inner.read_dir(path) + } + fn create_dir(&self, path: &Path) -> io::Result<()> { + self.inner.create_dir(path) + } + fn copy_file(&self, from: &Path, to: &Path, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result { + self.inner.copy_file(from, to, opts, on_progress) + } + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.ops.lock().unwrap().push(Op::Rename(from.to_path_buf(), to.to_path_buf())); + self.inner.rename(from, to) + } + fn remove_file(&self, path: &Path) -> io::Result<()> { + self.ops.lock().unwrap().push(Op::RemoveFile(path.to_path_buf())); + self.inner.remove_file(path) + } + fn remove_dir(&self, path: &Path) -> io::Result<()> { + self.inner.remove_dir(path) + } + fn set_readonly(&self, path: &Path, readonly: bool) -> io::Result<()> { + self.inner.set_readonly(path, readonly) + } + fn supports_named_streams(&self, path: &Path) -> io::Result { + self.inner.supports_named_streams(path) + } + fn same_volume(&self, _a: &Path, _b: &Path) -> io::Result { + Ok(false) + } + } + + /// `src\proj\file.txt`, `outside\keep.txt`, `outside\x86\a.dll`, and the + /// junction `src\proj\shared` → `outside`; an empty `dst`. + struct JunctionFixture { + src: PathBuf, + dst: PathBuf, + outside: PathBuf, + } + + fn junction_fixture(temp: &TempDir) -> JunctionFixture { + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + let outside = temp.path().join("outside"); + fs::create_dir_all(src.join("proj")).unwrap(); + fs::create_dir_all(outside.join("x86")).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("proj").join("file.txt"), b"proj").unwrap(); + fs::write(outside.join("keep.txt"), b"keep me").unwrap(); + fs::write(outside.join("x86").join("a.dll"), b"a.dll").unwrap(); + mklink_junction(&src.join("proj").join("shared"), &outside); + assert_eq!(fs::read(src.join("proj").join("shared").join("keep.txt")).unwrap(), b"keep me", "the junction resolves"); + JunctionFixture { src, dst, outside } + } + + /// A plain file copies with nothing unrestored. The unnamed default + /// stream (`::$DATA`) is not an alternate data stream: listing it as one + /// once made every real-file copy record a "stream not copied" warning, + /// which the junction move test below would have tripped over. + #[test] + fn copy_of_a_plain_file_reports_nothing_unrestored() { + let temp = TempDir::new("copy-plain-file"); + let file = temp.path().join("plain.txt"); + fs::write(&file, b"plain").unwrap(); + let report = RealFs::new() + .copy_file(&file, &temp.path().join("copy.txt"), &CopyOptions { chunk_bytes: 4096, flush: false }, &mut |_| ChunkControl::Continue) + .unwrap(); + assert_eq!(report.bytes, 5); + assert!(report.unrestored.is_empty(), "{:?}", report.unrestored); + assert_eq!(fs::read(temp.path().join("copy.txt")).unwrap(), b"plain"); + } + + /// Audit finding R-01 on real NTFS: file-operations "Cross-volume move + /// of a tree containing a junction to an outside directory leaves that + /// directory's files intact". + #[test] + fn move_of_tree_with_junction_to_outside_dir_keeps_outside_files() { + let temp = TempDir::new("move-tree-with-junction"); + let fx = junction_fixture(&temp); + + let job = Job { + kind: JobKind::Move, + sources: vec![SourceItem { original_name: OsString::from("proj"), path: fx.src.join("proj"), is_dir: true }], + source_dir: fx.src.clone(), + dest_dir: fx.dst.clone(), + new_dir_name: None, + }; + let mut sink = RecordingSink::default(); + run_job(&job, &CrossVolume::new(RealFs::new()), &mut sink); + + assert_completed_clean(&sink); + assert_eq!(fs::read(fx.outside.join("keep.txt")).unwrap(), b"keep me", "R-01: the junction target's file survives the move"); + assert_eq!(fs::read(fx.outside.join("x86").join("a.dll")).unwrap(), b"a.dll", "R-01: the target's subdirectory survives"); + assert_eq!(fs::read(fx.dst.join("proj").join("file.txt")).unwrap(), b"proj"); + assert_eq!( + fs::read(fx.dst.join("proj").join("shared").join("keep.txt")).unwrap(), + b"keep me", + "the destination holds a copy of the target's content" + ); + assert_eq!(fs::read(fx.dst.join("proj").join("shared").join("x86").join("a.dll")).unwrap(), b"a.dll"); + assert!( + !fx.dst.join("proj").join("shared").symlink_metadata().unwrap().file_type().is_symlink(), + "the destination `shared` is a plain directory, not a link" + ); + assert!(fx.src.join("proj").symlink_metadata().is_err(), "the source tree, including the junction, is removed"); + } + + /// Design D4; file-operations "Delete removes the link, not the target": + /// on real NTFS a junction is a directory object, so it must be removed + /// with `RemoveDirectoryW` — `DeleteFileW` answers "Access is denied". + #[test] + fn delete_of_junction_removes_link_and_keeps_target() { + let temp = TempDir::new("delete-junction"); + let fx = junction_fixture(&temp); + let link = fx.src.join("proj").join("shared"); + + let job = Job { + kind: JobKind::Delete, + sources: vec![SourceItem { original_name: OsString::from("shared"), path: link.clone(), is_dir: true }], + source_dir: fx.src.join("proj"), + dest_dir: fx.src.join("proj"), + new_dir_name: None, + }; + let mut sink = RecordingSink::default(); + run_job(&job, &RealFs::new(), &mut sink); + + assert_completed_clean(&sink); + assert!(link.symlink_metadata().is_err(), "the link is gone"); + assert_eq!(fs::read(fx.outside.join("keep.txt")).unwrap(), b"keep me", "the target is intact"); + assert_eq!(fs::read(fx.outside.join("x86").join("a.dll")).unwrap(), b"a.dll"); + assert_eq!(fs::read(fx.src.join("proj").join("file.txt")).unwrap(), b"proj", "the sibling file is untouched"); + } + + // ----------------------------------------------------------------- + // durable-file-copy, Windows/NTFS (design D9) + // ----------------------------------------------------------------- + + use std::fs::FileTimes; + use std::os::windows::fs::{FileTimesExt, MetadataExt, OpenOptionsExt}; + use std::time::{Duration, SystemTime}; + + const FILE_ATTRIBUTE_READONLY: u32 = 0x1; + const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2; + const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4; + const FILE_ATTRIBUTE_ARCHIVE: u32 = 0x20; + const ALL_FOUR: u32 = FILE_ATTRIBUTE_READONLY | FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM | FILE_ATTRIBUTE_ARCHIVE; + + /// 2001-02-03 04:05:00 UTC and 2002-03-04 05:06:00 UTC, the spec's + /// example times (file-operations "Creation time survives a copy"). + const CREATED_SECS: u64 = 981_173_100; + const MODIFIED_SECS: u64 = 1_015_218_360; + + struct Decorated { + created: SystemTime, + modified: SystemTime, + content: &'static [u8], + stream: &'static [u8], + } + + /// `path` with content, a `Zone.Identifier` stream, pinned creation and + /// last-write times, and read-only + hidden + system + archive set with + /// `attrib`, in that order (the attributes last, since read-only would + /// block the writes). + fn decorate(path: &Path) -> Decorated { + let d = Decorated { + created: SystemTime::UNIX_EPOCH + Duration::from_secs(CREATED_SECS), + modified: SystemTime::UNIX_EPOCH + Duration::from_secs(MODIFIED_SECS), + content: b"decorated content", + stream: b"[ZoneTransfer]\r\nZoneId=3\r\n", + }; + fs::write(path, d.content).unwrap(); + let mut stream_path = path.as_os_str().to_os_string(); + stream_path.push(":Zone.Identifier"); + fs::write(&stream_path, d.stream).unwrap(); + let file = fs::OpenOptions::new().write(true).open(path).unwrap(); + file.set_times(FileTimes::new().set_created(d.created).set_modified(d.modified)).unwrap(); + drop(file); + let status = std::process::Command::new("attrib").args(["+r", "+h", "+s", "+a"]).arg(path).status().expect("attrib runs"); + assert!(status.success(), "attrib failed: {status}"); + assert_decorated(path, &d); + d + } + + fn assert_decorated(path: &Path, d: &Decorated) { + let md = fs::symlink_metadata(path).unwrap(); + assert_eq!( + md.file_attributes() & ALL_FOUR, + ALL_FOUR, + "read-only, hidden, system and archive on {}: {:#x}", + path.display(), + md.file_attributes() + ); + assert_eq!(md.created().unwrap(), d.created, "creation time on {}", path.display()); + assert_eq!(md.modified().unwrap(), d.modified, "last-write time on {}", path.display()); + assert_eq!(fs::read(path).unwrap(), d.content); + let mut stream_path = path.as_os_str().to_os_string(); + stream_path.push(":Zone.Identifier"); + assert_eq!(fs::read(&stream_path).unwrap(), d.stream, "the alternate data stream on {}", path.display()); + } + + /// file-operations "Alternate data streams and metadata survive a copy", + /// "Hidden and system attributes survive a copy", "Creation time survives + /// a copy". + #[test] + fn ads_attributes_and_timestamps_survive_a_copy() { + let temp = TempDir::new("metadata-copy"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + let d = decorate(&src.join("f.txt")); + + let mut sink = RecordingSink::default(); + run_job(©_job(&src.join("f.txt"), &dst), &RealFs::new(), &mut sink); + + assert_completed_clean(&sink); + assert_decorated(&dst.join("f.txt"), &d); + assert_decorated(&src.join("f.txt"), &d); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); + } + + /// file-operations "A moved file keeps its metadata": the move's flush + /// path clears and restores read-only around the flush. + #[test] + fn ads_attributes_and_timestamps_survive_a_cross_volume_move() { + let temp = TempDir::new("metadata-move"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + let d = decorate(&src.join("f.txt")); + + let mut sink = RecordingSink::default(); + run_job(&move_job(&src.join("f.txt"), &dst), &CrossVolume::new(RealFs::new()), &mut sink); + + assert_completed_clean(&sink); + assert_decorated(&dst.join("f.txt"), &d); + assert!(fs::symlink_metadata(src.join("f.txt")).is_err(), "the source was removed after the verified copy"); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); + } + + /// file-operations "A failed overwrite keeps the existing target": the + /// source is held open with no sharing, so `CopyFileExW` fails with a + /// sharing violation before a byte reaches the target's name. + #[test] + fn a_failed_overwrite_keeps_the_old_target_and_leaves_no_partial() { + let temp = TempDir::new("failed-overwrite"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("report.txt"), b"new content").unwrap(); + fs::write(dst.join("report.txt"), b"old, complete content").unwrap(); + let _hold = fs::OpenOptions::new().read(true).share_mode(0).open(src.join("report.txt")).unwrap(); + + let mut sink = + RecordingSink { conflict_answers: vec![ConflictChoice::Overwrite], error_answers: vec![ErrorChoice::Skip], ..Default::default() }; + run_job(©_job(&src.join("report.txt"), &dst), &RealFs::new(), &mut sink); + + assert_eq!(sink.conflicts_seen, 1); + assert_eq!(sink.errors_seen.len(), 1, "the sharing violation reached the error dialog: {:?}", sink.errors_seen); + assert_eq!(fs::read(dst.join("report.txt")).unwrap(), b"old, complete content", "the target survives a failed overwrite"); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); + match &sink.outcome { + Some(JobOutcome::Completed { skipped }) => { + assert_eq!(skipped.len(), 1, "{skipped:?}"); + assert_eq!(skipped[0].path, src.join("report.txt")); + } + other => panic!("expected Completed with one skip, got {other:?}"), + } + } + + /// file-operations "Operation on a path longer than the legacy limit + /// succeeds": the fixture is built under `\\?\`; the jobs get plain + /// absolute paths, so the engine's own prefixing is what makes them work. + #[test] + fn long_paths_copy_move_and_delete() { + let temp = TempDir::new("long-paths"); + let deep = |root: &Path| root.join("a".repeat(120)).join("b".repeat(120)); + let src = deep(&temp.path().join("src")); + let dst = deep(&temp.path().join("dst")); + let verbatim = |p: &Path| PathBuf::from(format!(r"\\?\{}", p.display())); + assert!(src.as_os_str().len() > 260 && dst.as_os_str().len() > 260, "{}", src.display()); + fs::create_dir_all(verbatim(&src)).unwrap(); + fs::create_dir_all(verbatim(&dst)).unwrap(); + fs::write(verbatim(&src.join("long.txt")), b"far away").unwrap(); + fs::write(verbatim(&src.join("moved.txt")), b"moving far away").unwrap(); + + let mut sink = RecordingSink::default(); + run_job(©_job(&src.join("long.txt"), &dst), &RealFs::new(), &mut sink); + assert_completed_clean(&sink); + assert_eq!(fs::read(verbatim(&dst.join("long.txt"))).unwrap(), b"far away"); + + let mut sink = RecordingSink::default(); + run_job(&move_job(&src.join("moved.txt"), &dst), &CrossVolume::new(RealFs::new()), &mut sink); + assert_completed_clean(&sink); + assert_eq!(fs::read(verbatim(&dst.join("moved.txt"))).unwrap(), b"moving far away"); + assert!(fs::symlink_metadata(verbatim(&src.join("moved.txt"))).is_err()); + + let job = Job { + kind: JobKind::Delete, + sources: vec![file_source(&dst.join("long.txt"))], + source_dir: dst.clone(), + dest_dir: dst.clone(), + new_dir_name: None, + }; + let mut sink = RecordingSink::default(); + run_job(&job, &RealFs::new(), &mut sink); + assert_completed_clean(&sink); + assert!(fs::symlink_metadata(verbatim(&dst.join("long.txt"))).is_err()); + assert!(partial_files_in(&verbatim(&dst)).is_empty()); + } + + /// file-operations "Case-only rename succeeds". + #[test] + fn case_only_rename_succeeds_without_a_conflict() { + let temp = TempDir::new("case-only-rename"); + let dir = temp.path().join("d"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("foo.txt"), b"foo").unwrap(); + + let job = Job { + kind: JobKind::Rename, + sources: vec![file_source(&dir.join("foo.txt"))], + source_dir: dir.clone(), + dest_dir: dir.clone(), + new_dir_name: Some(OsString::from("Foo.txt")), + }; + let mut sink = RecordingSink::default(); + run_job(&job, &RealFs::new(), &mut sink); + + assert_eq!(sink.conflicts_seen, 0, "a case-only rename is not an overwrite conflict"); + assert_completed_clean(&sink); + let names: Vec = fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, vec![OsString::from("Foo.txt")]); + assert_eq!(fs::read(dir.join("Foo.txt")).unwrap(), b"foo"); + } + + /// file-operations "Cross-volume move copies then deletes the source": + /// the source removal is only attempted once the copy was renamed into + /// place, and the destination then holds the full content. + #[test] + fn cross_volume_move_removes_the_source_only_after_the_copy_landed() { + let temp = TempDir::new("cross-volume-move"); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + let big = multi_chunk_content(); + fs::write(src.join("big.bin"), &big).unwrap(); + + let fs_ = CrossVolume::new(RealFs::new()); + let mut sink = RecordingSink::default(); + run_job(&move_job(&src.join("big.bin"), &dst), &fs_, &mut sink); + + assert_completed_clean(&sink); + assert_eq!(fs::read(dst.join("big.bin")).unwrap(), big, "the destination holds the full content"); + assert!(fs::symlink_metadata(src.join("big.bin")).is_err(), "the source is gone"); + assert!(partial_files_in(&dst).is_empty(), "{:?}", partial_files_in(&dst)); + let ops = fs_.ops(); + let landed = + ops.iter().position(|o| matches!(o, Op::Rename(_, to) if to == &dst.join("big.bin"))).expect("the copy was renamed over the target"); + let removed = ops.iter().position(|o| *o == Op::RemoveFile(src.join("big.bin"))).expect("the source was removed"); + assert!(landed < removed, "copy landed before the source was touched: {ops:?}"); + } + + // 5.8: the junction-in-a-moved-tree test shared with + // move-reparse-point-safety is `move_of_tree_with_junction_to_outside_dir_keeps_outside_files` + // above; it runs the same `CrossVolume` move through `CopyFileExW`. +} diff --git a/crates/filecommand-tui/Cargo.toml b/crates/filecommand-tui/Cargo.toml index d783c1e..277d4d7 100644 --- a/crates/filecommand-tui/Cargo.toml +++ b/crates/filecommand-tui/Cargo.toml @@ -2,11 +2,15 @@ name = "filecommand-tui" version.workspace = true edition.workspace = true +rust-version.workspace = true license.workspace = true authors.workspace = true description = "FileCommand terminal UI binary: event loop, rendering, terminal ownership" default-run = "filecommand" +[lints] +workspace = true + [[bin]] name = "filecommand" path = "src/main.rs" @@ -21,12 +25,25 @@ unicode-width = { workspace = true } # Windows clipboard: CF_HDROP file objects + "Preferred DropEffect" for the # clipboard-export Files action (design D3). clipboard-win = "5.4" +# Shared workspace binding (ci-and-build-tooling design D6). Used here for: +# ShellExecuteW with the `open` verb (open-with-associated-app design D1); +# the console control handler that keeps Ctrl+C during a suspended child run +# from ending FileCommand (process-lifecycle-hardening design D1); the +# CoInitializeEx/CoUninitialize bracket around ShellExecuteW on its worker +# thread (offload-blocking-io design D7); and GetLocalTime for the clock. +windows-sys = { workspace = true } [target.'cfg(not(windows))'.dependencies] # Non-Windows fallback: text-only clipboard (Paths/Names; Files falls back # to Paths text) (design D5). arboard = "3.6" +[target.'cfg(unix)'.dependencies] +# SIGINT/SIGQUIT ignored in the parent while a suspended child runs +# (process-lifecycle-hardening design D2). Already in the lock graph via +# crossterm, mio, and signal-hook, so no new crate is built. +libc = "0.2" + [dev-dependencies] insta = { workspace = true } proptest = { workspace = true } diff --git a/crates/filecommand-tui/src/app.rs b/crates/filecommand-tui/src/app.rs index 51e6493..3e3010e 100644 --- a/crates/filecommand-tui/src/app.rs +++ b/crates/filecommand-tui/src/app.rs @@ -2,28 +2,35 @@ //! `Command`s, applies `core::update`, executes the returned effects, and //! redraws. This is the only place effects are actually performed. +use std::collections::HashMap; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::mpsc::{self, Receiver, Sender}; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant, SystemTime}; -use crossterm::event::{self, Event, KeyEventKind, MouseEventKind}; +use crossterm::event::{self, Event, KeyEvent, KeyEventKind, MouseEventKind}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use filecommand_core::clock::{format_clock, Clock, WallClock}; -use filecommand_core::editor::{EditorMove, EditorState, LoadResult as EditorLoadResult}; +use filecommand_core::editor::EditorMove; use filecommand_core::external_editor::EditorInvocation; +use filecommand_core::fs_ops::CancelFlag; +use filecommand_core::line_edit::flatten_line_breaks; use filecommand_core::listing::DateTime; use filecommand_core::shell::{Invocation, ShellConfig}; use filecommand_core::theme::{ColorDepth, Theme, BUILTIN_THEME_NAMES}; use filecommand_core::update::ClipboardPayloadKind; use filecommand_core::viewer::hex::HEX_BYTES_PER_ROW; use filecommand_core::viewer::{backward, forward, ByteSource, ViewMode, ViewerState}; -use filecommand_core::{config, drives, identity, update, Command, Effect, PanelSide, State, UiPhase}; +use filecommand_core::{anim, config, drives, identity, input_layer, update, Command, Effect, InputLayer, PanelSide, State, UiPhase}; use crate::clipboard::{self, Clipboard, PlatformClipboard}; use crate::clock::{RealClock, RealWallClock}; +use crate::config_writer::{ConfigWrite, ConfigWriter}; +use crate::console; +use crate::diag; +use crate::history_writer::HistoryWriter; use crate::hitmap::HitMap; use crate::input::{self, MouseTracker, ViewerInput}; use crate::terminal::TerminalGuard; @@ -31,6 +38,20 @@ use crate::views; use crate::worker; const POLL_INTERVAL: Duration = Duration::from_millis(33); +/// A burst of already-queued input applies at most this many events before +/// a draw, so the screen never falls far behind the keyboard +/// (idle-aware-event-loop "Bursts of key input render once"; design D5). +const BURST_MAX_EVENTS: usize = 64; +/// The burst's other bound: about this much processing time, then a draw. +const BURST_MAX_DURATION: Duration = Duration::from_millis(8); +/// How long a confirmed quit waits for a running job to finish its +/// cancellation cleanup — removing a partially written destination file — +/// before exiting anyway (application-shell "Quit waits for job cancellation +/// cleanup"; process-lifecycle-hardening design D3). Bounded so a write +/// blocked on an unresponsive share cannot hang quit. +const QUIT_JOB_WAIT: Duration = Duration::from_secs(2); +/// How often [`shutdown_job`] re-checks whether the job thread has ended. +const QUIT_JOB_POLL: Duration = Duration::from_millis(10); const CONFIG_FILE: &str = "config.toml"; const RESUME_PROMPT: &str = "Press any key to return to FileCommand . . ."; @@ -39,15 +60,45 @@ const RESUME_PROMPT: &str = "Press any key to return to FileCommand . . ."; struct Runtime { tx: Sender, active_job: Option, - history_path: PathBuf, - /// Where `Effect::PersistTheme` writes the `theme =` key back - /// (theme-selection "Applied theme persists to configuration"). - config_path: PathBuf, + /// The cancel flags of the cancellable background requests still running + /// — directory listings, find-file walks, viewer searches — keyed by the + /// request id the reducer minted for them, so `Effect::CancelRequest` + /// can stop one (listing-request-guard design D3). Owned here, like + /// `active_job`, because flags are TUI plumbing, not pure state. + cancels: CancelRegistry, + /// The background `history.json` writer `Effect::PersistHistory` hands + /// its snapshot to; it owns the history path, and is the disabled no-op + /// when persistence is off for the session. Owned here so every exit + /// path from `run` flushes it on drop (fuzzy-jump "Quitting flushes + /// unwritten history"; history-persistence-hardening design D4). + history_writer: HistoryWriter, + /// The background `config.toml` writer `Effect::PersistTheme` and + /// `Effect::PersistPanelSplit` hand their values to; it owns the config + /// path, writes one setting at a time with the newest value, and is the + /// disabled no-op when persistence is off for the session. Owned here so + /// every exit path from `run` flushes it on drop, bounded + /// (application-shell "Slow storage never blocks the event loop"; + /// offload-blocking-io design D9). + config_writer: ConfigWriter, + /// Where the F2 user menu lives; `Command::EditUserMenuFile` opens it via + /// `State::user_menu_path`, and when the editor that had it open returns + /// (built-in close or external exit) the file is reloaded and fed back as + /// `Command::UserMenuReloaded` (user-menu "Edit the user menu file from + /// the Commands menu"; design D4). + usermenu_path: PathBuf, /// The F3 viewer's open byte window, kept here rather than in /// `core::State` because `ByteSource` is an I/O handle, not application - /// state (design D1). Set on `Effect::OpenViewer`, cleared on - /// `Command::ViewerClose` — the only path out of `UiPhase::Viewer`. + /// state (design D1). Adopted from `viewer_handoff` when a current + /// `Command::ViewerOpened` is applied, cleared on `Command::ViewerClose` + /// — the only path out of `UiPhase::Viewer`. viewer_source: Option<(PathBuf, ByteSource)>, + /// The channel a viewer-open worker hands its `ByteSource` back on, + /// immediately before its `ViewerOpened` reply (offload-blocking-io + /// design D4). Drained by `apply` after every `ViewerOpened`: the entry + /// whose request the reducer accepted becomes `viewer_source`, and every + /// other entry — a superseded or discarded open — is dropped, closing + /// its handle. + viewer_handoff: (Sender, Receiver), /// The OS clipboard behind `Effect::SetClipboard` — `Box` /// rather than a concrete type so tests can swap in /// `clipboard::RecordingClipboard` (clipboard-export; design D3). @@ -58,6 +109,116 @@ struct Runtime { /// crossterm may otherwise believe a button is still held after a /// suspended shell/editor/scrollback run (design D1 risk). mouse_tracker: MouseTracker, + /// Per-panel git-query slots (left, right) bounding the app to one + /// in-flight git status query per panel: a navigation while one is + /// running queues only the newest request, and a queued request inside a + /// repository that just timed out is never started (git-info "Rapid + /// navigation does not pile up queries"; git-info-timeout-memory design + /// D3). + git_slots: [worker::GitQuerySlot; 2], + /// How many worker threads that can still post to `tx` are alive + /// (idle-aware-event-loop design D3): the loop polls at the usual ~33 ms + /// cadence only while this is nonzero, and sleeps until the next + /// time-driven deadline otherwise. + workers: worker::WorkerGauge, + /// The one worker message a budgeted [`drain_events`] took from the + /// channel but did not apply because its budget had run out — applied + /// first by the next drain, since `mpsc::Receiver` cannot peek + /// (streaming-listing-performance design D7). + held: Option, +} + +/// The cancel flags of the background requests the reducer can supersede +/// (listing-request-guard design D3): one `fs_ops::job::CancelFlag` — the +/// same flag that backs job cancellation — per running `StartListing`, +/// `FindInSubtree`, or `RunViewerSearch`, keyed by request id. The reducer +/// decides supersession (it mints and compares the ids) and emits +/// `Effect::CancelRequest(id)`; this registry owns the flags and flips the +/// right one. Ids are globally unique (`State::next_request_id`), so one map +/// serves all three request kinds. +#[derive(Default)] +struct CancelRegistry { + flags: HashMap, +} + +impl CancelRegistry { + /// A fresh, unset flag for the request `id`, remembered until it is + /// cancelled or retired. The clone handed back travels to the worker. + fn register(&mut self, id: u64) -> CancelFlag { + let flag = CancelFlag::new(); + self.flags.insert(id, flag.clone()); + flag + } + + /// `Effect::CancelRequest(id)`: set the flag and forget the entry. An + /// unknown id — a request that already finished, or one that was never + /// cancellable — is a silent no-op, so the reducer never needs to know + /// whether a worker is still alive. + fn cancel(&mut self, id: u64) { + if let Some(flag) = self.flags.remove(&id) { + flag.cancel(); + } + } + + /// Forget the entry without setting its flag: the request's final reply + /// has passed through, so there is nothing left to stop. + fn retire(&mut self, id: u64) { + self.flags.remove(&id); + } +} + +impl Runtime { + /// `apply`'s post-`update` step for a `Command::ViewerOpened { request }` + /// (offload-blocking-io design D4): drain the hand-off channel, keeping + /// the entry for `request` as `viewer_source` only if the reducer + /// accepted the reply — the phase *became* `Viewer` on this file — and + /// dropping every entry for an older request, which closes its handle. + /// The worker sends the source before the command, so the matching + /// entry is always already queued here; an older one belongs to an open + /// the user superseded, whose own `ViewerOpened` the reducer discarded + /// or will discard. An entry for a *newer* request (ids are monotonic) + /// whose command is still queued behind this one is put back, so two + /// opens completing back to back both find their source. + fn adopt_viewer_handoff(&mut self, request: u64, was_viewer: bool, after: &State) { + let opened_path = match &after.phase { + UiPhase::Viewer(viewer) if !was_viewer => Some(viewer.path.clone()), + _ => None, + }; + let mut newer = Vec::new(); + while let Ok((entry_request, path, source)) = self.viewer_handoff.1.try_recv() { + if entry_request == request { + if opened_path.as_deref() == Some(path.as_path()) { + self.viewer_source = Some((path, source)); + } + // A discarded reply's source is dropped here, releasing the file. + } else if entry_request > request { + newer.push((entry_request, path, source)); + } + // An older entry is dropped here, releasing the file. + } + for entry in newer { + let _ = self.viewer_handoff.0.send(entry); + } + } + + /// `Command::ViewerClose`'s release of the viewer's byte source, so the + /// file handle is closed with the viewer (viewer: Instant open — + /// "Closing the viewer releases the file"). + fn release_viewer_source(&mut self) { + self.viewer_source = None; + } + + /// `apply`'s post-`update` check (resize-preserves-session-state design + /// D7): the source goes when the viewer phase has just ended by any + /// route, or when a `ViewerOpened` was applied but the phase did not + /// become `Viewer` (the reply was discarded). It is keyed to that + /// *transition*, never to "the phase is not `Viewer`": a command applied + /// while the viewer is open must not drop its source. + fn release_viewer_source_if_viewer_ended(&mut self, was_viewer: bool, viewer_opened: bool, after: &State) { + if (was_viewer || viewer_opened) && !matches!(after.phase, UiPhase::Viewer(_)) { + self.release_viewer_source(); + } + } } /// Launch-time command-line switches, parsed once by [`parse_launch_args`] @@ -72,6 +233,11 @@ pub struct LaunchOptions { /// `[mouse] enabled` key, mirroring `--nosplash` (mouse-input "Mouse /// capture configuration"). pub no_mouse: bool, + /// `--noanimations`: run with every decorative animation off regardless + /// of `config.toml`'s `animations` key, mirroring `--nosplash` + /// (ui-animations "Animations are disabled by config or launch flag" — + /// the flag wins when the two disagree). + pub no_animations: bool, /// `--theme ` or `--theme=`: `None` when the switch was not /// passed at all; `Some(String::new())` when it was passed with no /// following value (e.g. as the final argument); `Some(name)` otherwise, @@ -79,6 +245,22 @@ pub struct LaunchOptions { /// resolution happens in `resolve_startup_theme`, not here /// (theme-selection "Launch-time theme override via --theme"). pub theme: Option, + /// `--config-dir ` or `--config-dir=`: `None` when the + /// switch was not passed at all; `Some(String::new())` when it was + /// passed with no following value (a startup warning downstream, never + /// a crash); `Some(path)` otherwise, where `path` wins outright over + /// portable mode and the platform default — resolution happens in + /// `resolve_profile_dir`, not here (config-profile-directory + /// "Configuration profile directory": "Explicit --config-dir + /// override"). Same absent/empty/value convention as `theme` above. + pub config_dir: Option, + /// `--log ` or `--log=`: enable the diagnostic log at + /// `path` for this session, winning over the `FILECOMMAND_LOG` + /// environment variable. Same absent/empty/value convention as `theme` + /// and `config_dir` above; resolution happens in `run` via + /// `resolve_log_path` (error-diagnostics; application-shell "Optional + /// diagnostic log"). + pub log: Option, } /// Parse launch switches from an argument iterator (excluding the program @@ -97,6 +279,8 @@ pub fn parse_launch_args>(args: I) -> LaunchOptio opts.no_splash = true; } else if arg == "--nomouse" { opts.no_mouse = true; + } else if arg == "--noanimations" { + opts.no_animations = true; } else if arg == "--theme" { // A missing value (switch is the final argument) is recorded as // an empty string rather than `None`, so `resolve_startup_theme` @@ -105,20 +289,204 @@ pub fn parse_launch_args>(args: I) -> LaunchOptio opts.theme = Some(iter.next().unwrap_or_default()); } else if let Some(value) = arg.strip_prefix("--theme=") { opts.theme = Some(value.to_string()); + } else if arg == "--config-dir" { + // Same missing-value convention as --theme above: recorded as + // an empty string so startup warns and falls through to normal + // profile resolution rather than crashing (task 1.1). + opts.config_dir = Some(iter.next().unwrap_or_default()); + } else if let Some(value) = arg.strip_prefix("--config-dir=") { + opts.config_dir = Some(value.to_string()); + } else if arg == "--log" { + // Same missing-value convention again (error-diagnostics design + // D2): an empty string so startup warns "--log needs a path" + // instead of silently disabling the log. + opts.log = Some(iter.next().unwrap_or_default()); + } else if let Some(value) = arg.strip_prefix("--log=") { + opts.log = Some(value.to_string()); } } opts } +/// The platform default profile directory: `%APPDATA%\FileCommand` on +/// Windows, `$XDG_CONFIG_HOME/filecommand` (else `~/.config/filecommand`) +/// elsewhere; `None` when no config-home environment variable is set +/// (config-profile-directory "Configuration profile directory"; design D1). +/// `pub` so `main.rs`'s usage text can name it (design D4) — nothing is +/// created here. +pub fn platform_default_profile_dir() -> Option { + #[cfg(windows)] + { + std::env::var_os("APPDATA").map(|appdata| PathBuf::from(appdata).join("FileCommand")) + } + #[cfg(not(windows))] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(|xdg| PathBuf::from(xdg).join("filecommand")) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("filecommand"))) + } +} + +/// Why the last-resort cwd fallback fired, named in its startup warning — +/// the environment variable(s) that would normally resolve the platform +/// default (design D1: "never silent"). +#[cfg(windows)] +const CONFIG_HOME_UNSET_HINT: &str = "APPDATA is not set"; +#[cfg(not(windows))] +const CONFIG_HOME_UNSET_HINT: &str = "neither XDG_CONFIG_HOME nor HOME is set"; + +/// Resolve the one profile directory every configuration file lives in +/// (config-profile-directory "Configuration profile directory"). Order +/// (design D1): an explicit `--config-dir` flag wins outright — a relative +/// value is resolved against the working directory at startup; else, when +/// a file named `portable` exists beside the executable, the executable's +/// own directory (portable mode); else the platform default. When no +/// config-home variable resolves either, the working directory is the +/// last-resort fallback and the returned warning says so — resolution is +/// never silent. +fn resolve_profile_dir(flag: Option, exe_dir: Option<&Path>) -> (PathBuf, Option) { + if let Some(path) = flag { + let path = if path.is_absolute() { path } else { std::env::current_dir().map(|cwd| cwd.join(&path)).unwrap_or(path) }; + return (path, None); + } + if let Some(dir) = exe_dir { + if dir.join("portable").exists() { + return (dir.to_path_buf(), None); + } + } + if let Some(default) = platform_default_profile_dir() { + return (default, None); + } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let warning = format!("{CONFIG_HOME_UNSET_HINT}; using the current directory {} for configuration files", cwd.display()); + (cwd, Some(warning)) +} + +/// Interpret the raw `--config-dir` flag value the way +/// `resolve_startup_theme` interprets `--theme`: `None` (switch absent) is +/// no override; `Some(path)` wins outright; `Some("")` — the switch passed +/// with no value — produces a startup warning, not a crash, and falls +/// through to normal resolution (application-shell "Configuration profile +/// directory": "Explicit --config-dir override"; task 1.1). +fn config_dir_override(raw: Option<&str>) -> (Option, Option) { + match raw { + Some("") => (None, Some("--config-dir requires a value".to_string())), + Some(path) => (Some(PathBuf::from(path)), None), + None => (None, None), + } +} + +/// Resolve the diagnostic-log path chosen at launch (error-diagnostics +/// design D2; application-shell "Optional diagnostic log"): the `--log` +/// flag wins over `FILECOMMAND_LOG` outright, an empty flag value is a +/// warning rather than a path, and an empty or unset variable counts as +/// unset. `Err` is the startup-warning text; `Ok(None)` leaves logging off +/// and creates nothing ("No log unless asked for"). +fn resolve_log_path(flag: Option<&str>, env: Option<&str>) -> Result, String> { + match flag { + Some("") => Err("--log needs a path".to_string()), + Some(path) => Ok(Some(path.to_string())), + None => match env { + Some(value) if !value.is_empty() => Ok(Some(value.to_string())), + _ => Ok(None), + }, + } +} + +/// Open the log [`resolve_log_path`] chose. Runs before profile resolution +/// so every startup warning that follows can be recorded (design D2/D3). An +/// unopenable path downgrades to a startup warning naming the path and the +/// reason — the session continues without logging ("Unwritable log path +/// warns and continues"). +fn init_diagnostic_log(path: Option) -> Option { + let path = path?; + match diag::init(Path::new(&path)) { + Ok(()) => None, + Err(err) => Some(format!("Diagnostic log disabled: cannot open {path} ({err})")), + } +} + +/// Create the profile directory on first run. Failure is soft — the +/// session runs on in-memory defaults with persistence skipped — and the +/// returned warning names the path and the error (design D2; scenario +/// "Unwritable profile falls back without losing the session"). +fn ensure_profile_dir(profile: &Path) -> Option { + std::fs::create_dir_all(profile) + .err() + .map(|err| format!("Could not create {}: {err} — settings will not be saved this session", profile.display())) +} + +/// The legacy-files startup notice (design D3; scenario "Legacy files in +/// the working directory produce a notice, not a migration"): when the +/// profile directory has no `config.toml` and the launch directory still +/// holds pre-profile `config.toml`/`history.json`/`usermenu.toml` files, +/// say where FileCommand now reads from and that the old files can be +/// moved — nothing is copied or deleted. `None` when no notice applies. +fn legacy_notice(cwd: &Path, profile: &Path) -> Option { + let names = config::legacy_files_present(cwd, profile); + if names.is_empty() { + return None; + } + Some(format!( + "Found {} in {}; FileCommand now reads {}. Move them there to keep your settings.", + names.join(", "), + cwd.display(), + profile.display() + )) +} + pub fn run(launch: LaunchOptions) -> io::Result<()> { + // Installed from this thread, which thereby becomes the UI thread the + // hook restores the terminal for; worker panics leave it alone + // (process-lifecycle-hardening design D4). crate::terminal::install_panic_hook(); + // The console control handler that keeps a Ctrl+C typed during a + // suspended child run from ending FileCommand (application-shell + // "Ctrl+C during a suspended child run stops only the child"; design + // D1). Registered once; a failure only leaves the old behavior in place, + // so it is logged rather than fatal. + #[cfg(windows)] + if let Err(e) = console::install_ctrl_handler() { + diag::log("startup", format_args!("console control handler not installed: {e}")); + } + // The diagnostic log is resolved and opened before anything below can + // warn, so every startup warning — including ones a later warning + // replaces in the single-slot dialog — is recorded (error-diagnostics + // design D2/D3; application-shell "Optional diagnostic log"). The flag + // wins over FILECOMMAND_LOG; a missing value or an unopenable file + // degrades to a startup warning rather than aborting the session. + let log_warning = match resolve_log_path(launch.log.as_deref(), std::env::var("FILECOMMAND_LOG").ok().as_deref()) { + Ok(path) => init_diagnostic_log(path), + Err(warning) => Some(warning), + }; + // The profile directory is resolved before anything loads: every + // configuration file (config.toml, history.json, usermenu.toml) lives + // there now, never relative to the launch directory + // (config-profile-directory "Configuration profile directory"). + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let exe_dir = std::env::current_exe().ok().and_then(|exe| exe.parent().map(Path::to_path_buf)); + let (flag, flag_warning) = config_dir_override(launch.config_dir.as_deref()); + let (profile_dir, resolve_warning) = resolve_profile_dir(flag, exe_dir.as_deref()); + let create_warning = ensure_profile_dir(&profile_dir); + let persist_enabled = create_warning.is_none(); + let config_path = profile_dir.join(CONFIG_FILE); + let history_path = profile_dir.join(config::HISTORY_FILE); + let usermenu_path = profile_dir.join(config::USERMENU_FILE); + // Probed before the loads below (none of which create a config.toml): + // legacy files left over from the per-launch-directory layout (design + // D3). + let legacy_warning = legacy_notice(&cwd, &profile_dir); // Loaded before the guard so the guard can enable mouse capture (or not) // on its very first frame rather than flipping it on a moment later // (application-shell "Terminal acquired on startup"; mouse-input "Mouse // capture configuration") — `config::load` is pure file I/O with no // terminal dependency, so this reorder ahead of `TerminalGuard::new` is // safe. - let config = config::load(Path::new(CONFIG_FILE)); + let config::ParsedConfig { config, warnings: parsed_warnings } = config::load(&config_path); + // config.toml warnings first, usermenu.toml warnings appended below, in + // file-then-line order (configuration-files "Several warnings are + // summarized"; design D4). + let mut config_warnings: Vec = parsed_warnings.iter().map(ToString::to_string).collect(); let mouse_enabled = config.mouse_enabled && !launch.no_mouse; let mut guard = TerminalGuard::new(mouse_enabled)?; @@ -130,118 +498,209 @@ pub fn run(launch: LaunchOptions) -> io::Result<()> { let size = guard.terminal.size()?; let term_size = (size.width, size.height); - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let year = DateTime::from_system_time(SystemTime::now()).year; let identity_lines = identity::identity_lines(year); - let (mut state, effects) = State::initial(theme, term_size, clock.now_ms(), cwd.clone(), cwd, show_splash); + let (mut state, effects) = State::initial(theme, term_size, clock.now_ms(), wall_clock.epoch_ms(), cwd.clone(), cwd, show_splash); apply_config(&mut state, &config); - // Session-only: this only ever sets `state.startup_warning` in memory, + apply_launch_options(&mut state, &launch); + // A `--log` switch with no value, an unopenable log file, a + // `--config-dir` switch with no value, and the last-resort cwd fallback + // are session-only, in-memory warnings — same convention as the theme + // override warning just below. Each goes through `set_startup_warning` + // so it lands in the diagnostic log even when a later warning replaces + // it in the dialog (error-diagnostics design D3). + if let Some(warning) = log_warning { + set_startup_warning(&mut state, warning); + } + if let Some(warning) = flag_warning { + set_startup_warning(&mut state, warning); + } + if let Some(warning) = resolve_warning { + set_startup_warning(&mut state, warning); + } + // Session-only: this only ever sets the startup warning in memory, // never touches `config.toml` (theme-selection "Override is // session-only"; design D4). `apply_user_menu` below may overwrite this // with its own warning if both fire at once — matching the pre-existing - // single-`startup_warning`-field behavior, not a new decision here. + // last-writer-wins behavior, with the superseded warning + // kept in the diagnostic log. if let Some(warning) = theme_warning { - state.startup_warning = Some(warning); + set_startup_warning(&mut state, warning); } - let history_file = config::load_history_file(Path::new(config::HISTORY_FILE)); - state.history = history_file.commands; - state.dir_history = history_file.directories; - let user_menu = config::load_user_menu(Path::new(config::USERMENU_FILE)); + // A file from an earlier build (no format marker) carries session- + // relative visit times; they are re-stamped to the startup epoch, future + // times are clamped, and the index is pruned to its cap (fuzzy-jump + // "Legacy history file is migrated at load"; design D2). + let mut loaded = config::load_history_document(&history_path); + config::migrate_loaded_history(&mut loaded.file, loaded.legacy, state.epoch_ms); + state.history = loaded.file.commands; + state.dir_history = loaded.file.directories; + let user_menu = config::load_user_menu(&usermenu_path); + config_warnings.extend(user_menu.warnings.iter().map(ToString::to_string)); apply_user_menu(&mut state, user_menu); + // Commands → Menu file edit reads the path back out of `State` + // (pulldown-menu-completion design D4). + state.user_menu_path = usermenu_path.clone(); + // The legacy notice and the unwritable-profile warning write last, so + // the persistence-breaking one (design D2) stays visible when several + // warnings fire at once; still the same single-field, last-writer-wins + // behavior (config-profile-directory design D3), with every superseded + // warning kept in the diagnostic log. + if let Some(warning) = legacy_warning { + set_startup_warning(&mut state, warning); + } + if let Some(warning) = create_warning { + set_startup_warning(&mut state, warning); + } + // Lowest precedence of all: the configuration-content summary is shown + // only when no other startup warning fired (design D4). + apply_configuration_warnings(&mut state, &config_warnings); + stamp_startup_warning(&mut state); let (tx, rx) = mpsc::channel::(); + // Both background writers exist only when the profile directory is + // writable this session; otherwise every persist effect is a no-op + // (config-profile-directory "Unwritable profile falls back without + // losing the session"; design D2). + let (history_writer, config_writer) = if persist_enabled { + (HistoryWriter::spawn(history_path, crate::history_writer::DEBOUNCE, crate::history_writer::MAX_WAIT), ConfigWriter::spawn(config_path)) + } else { + (HistoryWriter::disabled(), ConfigWriter::disabled()) + }; let mut rt = Runtime { tx, active_job: None, - history_path: PathBuf::from(config::HISTORY_FILE), - config_path: PathBuf::from(CONFIG_FILE), + cancels: CancelRegistry::default(), + history_writer, + config_writer, + usermenu_path, viewer_source: None, - clipboard: Box::new(PlatformClipboard::default()), + viewer_handoff: mpsc::channel(), + clipboard: Box::new(PlatformClipboard), mouse_tracker: MouseTracker::new(), + git_slots: [worker::GitQuerySlot::new(), worker::GitQuerySlot::new()], + workers: worker::WorkerGauge::new(), + held: None, }; if run_effects(effects, &mut guard, &mut rt)? { - return Ok(()); + return shutdown(&mut rt); } let (mut state, _) = drain_events(state, &rx, &mut guard, &mut rt)?; let mut hitmap = draw(&mut guard, &state, &identity_lines, &wall_clock, rt.viewer_source.as_ref().map(|(_, s)| s))?; // A non-mouse event read while draining a burst of mouse events (design - // D7) is stashed here rather than lost — crossterm has no way to push an - // event back onto its queue, so the next loop iteration consumes this - // instead of polling again. + // D7), or a mouse event that ended a key burst (idle-aware-event-loop + // design D5), is stashed here rather than lost — crossterm has no way to + // push an event back onto its queue, so the next loop iteration consumes + // this instead of polling again. let mut pending_event: Option = None; + // Idle-aware-event-loop design D1: `needs_draw` persists across + // iterations and is cleared only by an actual draw, so a worker result + // drained in an iteration whose key then maps to no command still + // repaints (application-shell "Redraw survives an unmapped key") — the + // old per-iteration `dirty` flag lost that redraw. + let mut needs_draw = false; + // The wall-clock reading the last drawn frame's header clock showed; a + // changed reading at a poll timeout means a minute boundary passed and + // the header must be repainted (application-shell "Header clock still + // advances while idle"). + let mut drawn_clock = wall_clock.now_local(); + let mut events = CrosstermEvents; + loop { - let (s, mut dirty) = drain_events(state, &rx, &mut guard, &mut rt)?; + // Budgeted (streaming-listing-performance design D7): at most ~8 ms + // or 64 messages before input is read and the frame drawn, so a + // worker flood — a 100k-entry directory streaming in — never starves + // the keyboard (panel-navigation "Input stays responsive while a + // 100,000-entry directory streams in"). What remains is a `backlog` + // the poll below must not sleep on. + let (s, drained) = drain_events(state, &rx, &mut guard, &mut rt)?; state = s; + needs_draw |= drained.applied; + let mut backlog = drained.backlog; + + // Design D3's race: a worker can post its last message and drop its + // gauge token between the drain above and this gauge read. The send + // happens-before the token's release-decrement, so with a zero + // reading one more drain is guaranteed to see that message — without + // it the loop could sleep a whole deadline with it unapplied. + let workers_alive = rt.workers.live() > 0; + if !workers_alive { + let (s, drained) = drain_events(state, &rx, &mut guard, &mut rt)?; + state = s; + needs_draw |= drained.applied; + backlog = drained.backlog; + } + + // While a draw is already pending there is no sleeping: poll with a + // zero timeout so input that is already queued applies before the + // frame (one draw for the whole batch), then fall through to it. + let timeout = if needs_draw { + Duration::ZERO + } else { + let now = clock.now_ms(); + next_timeout( + now, + anim::next_wake_ms(&state), + Duration::from_millis(crate::clock::ms_until_next_minute(crate::clock::now_unix_ms())), + workers_alive, + backlog, + ) + }; let next_event = match pending_event.take() { Some(e) => Some(e), - None if event::poll(POLL_INTERVAL)? => Some(event::read()?), + None if events.poll(timeout)? => Some(events.read()?), None => None, }; match next_event { Some(Event::Key(key)) if key.kind == KeyEventKind::Press => { - // The quit-confirmation dialog outranks the viewer/editor's - // own direct-dispatch key routing: once it is up, every key - // goes through `map_key` (which checks `quit_confirm` - // first), regardless of `state.phase` — the same way it - // outranks every other overlay inside `map_key` itself - // (application-shell "Quit request keys and - // confirmation"; design D5). - let cmd = if state.quit_confirm { - let page_size = active_panel_page_size(&state); - input::map_key(key, &state, page_size, &keys) - } else { - match &state.phase { - UiPhase::Viewer(viewer) => { - let rows_visible = views::viewer::body_rows(state.term_size.1); - let source = rt.viewer_source.as_ref().map(|(_, s)| s); - input::map_viewer_key(key, viewer, rows_visible) - .and_then(|action| resolve_viewer_navigation(viewer, source, action, rows_visible)) - } - UiPhase::Editor(editor) => { - let rows_visible = views::editor::body_rows(state.term_size.1); - input::map_editor_key(key, editor, rows_visible) - } - _ => { - let page_size = active_panel_page_size(&state); - input::map_key(key, &state, page_size, &keys) - } - } - }; - match cmd { - Some(cmd) => { - let (s, quit) = apply(state, cmd, &mut guard, &mut rt)?; - if quit { - return Ok(()); - } - state = s; - dirty = true; - } - None => continue, + let (s, quit) = tick_before_input(state, &clock, &wall_clock, &mut guard, &mut rt)?; + if quit { + return shutdown(&mut rt); + } + state = s; + // Design D5: with the first key handled, keep applying + // already-queued input — each key mapped against the state + // the previous one produced — then draw once. + let mut handler = LoopHandler { guard: &mut guard, rt: &mut rt, keys: &keys }; + let (s, quit, burst_applied) = drain_key_burst(key, state, &mut events, &mut pending_event, &mut handler)?; + state = s; + needs_draw |= burst_applied; + if quit { + return shutdown(&mut rt); } } Some(Event::Resize(w, h)) => { - let (s, quit) = apply(state, Command::Resize(w, h), &mut guard, &mut rt)?; + let (s, quit) = tick_before_input(state, &clock, &wall_clock, &mut guard, &mut rt)?; + if quit { + return shutdown(&mut rt); + } + let (s, quit) = apply(s, Command::Resize(w, h), &mut guard, &mut rt)?; if quit { - return Ok(()); + return shutdown(&mut rt); } state = s; - dirty = true; + needs_draw = true; } Some(Event::Mouse(first)) => { + let (s, quit) = tick_before_input(state, &clock, &wall_clock, &mut guard, &mut rt)?; + if quit { + return shutdown(&mut rt); + } + state = s; // Drain every mouse event already queued this frame after // the first, so a burst of wheel/drag events causes at most // one redraw (mouse-input "Mouse events are coalesced"; // design D7). A non-mouse event read mid-drain is stashed in // `pending_event` rather than lost. let mut batch = vec![first]; - while event::poll(Duration::ZERO)? { - match event::read()? { + while events.poll(Duration::ZERO)? { + match events.read()? { Event::Mouse(m) => batch.push(m), other => { pending_event = Some(other); @@ -251,31 +710,105 @@ pub fn run(launch: LaunchOptions) -> io::Result<()> { } let (s, quit, batch_dirty) = apply_mouse_batch(state, batch, &hitmap, &mut guard, &mut rt)?; if quit { - return Ok(()); + return shutdown(&mut rt); } state = s; - dirty = dirty || batch_dirty; + needs_draw |= batch_dirty; } - Some(_) => {} - None => { - let (s, quit) = apply(state, Command::Tick(clock.now_ms()), &mut guard, &mut rt)?; + // A bracketed paste (never produced on Windows — design D6) is + // one batch of commands into the field Ctrl+V would paste into, + // drawn once. + Some(Event::Paste(text)) => { + let (s, quit) = tick_before_input(state, &clock, &wall_clock, &mut guard, &mut rt)?; if quit { - return Ok(()); + return shutdown(&mut rt); } state = s; - dirty = true; + for cmd in input::map_paste(&text, &state) { + let (s, quit) = apply(state, cmd, &mut guard, &mut rt)?; + state = s; + needs_draw = true; + if quit { + return shutdown(&mut rt); + } + } + } + // Key releases, focus events: nothing to apply. + Some(_) => {} + None => { + // A poll timeout applies nothing and draws nothing unless a + // deadline actually came due (design D1/D2) — the + // worker-cadence wakeups this arm also gets are what used to + // cost a full frame every 33 ms forever (audit P-05). + let now = clock.now_ms(); + let core_due = anim::next_wake_ms(&state).is_some_and(|wake| now >= wake); + let minute_due = wall_clock.now_local() != drawn_clock; + if core_due || minute_due { + let (s, quit) = apply(state, Command::Tick { now_ms: now, epoch_ms: wall_clock.epoch_ms() }, &mut guard, &mut rt)?; + if quit { + return shutdown(&mut rt); + } + state = s; + needs_draw = true; + } } } - if dirty { + if needs_draw { // Effects that feed a command straight back (drive enumeration) // must land before the frame, so the dialog paints complete on - // its very first appearance rather than a frame later. + // its very first appearance rather than a frame later. Under the + // same budget: a backlog left here is picked up by the next + // iteration's drain, after this frame has been shown. let (s, _) = drain_events(state, &rx, &mut guard, &mut rt)?; state = s; hitmap = draw(&mut guard, &state, &identity_lines, &wall_clock, rt.viewer_source.as_ref().map(|(_, s)| s))?; + needs_draw = false; + drawn_clock = wall_clock.now_local(); + } + } +} + +/// The one exit path for every quit return in [`run`] (application-shell +/// "Quit waits for job cancellation cleanup"; process-lifecycle-hardening +/// design D3). Acts on `rt.active_job` directly — whatever phase core is +/// in, so a quit confirmed from a screen that no longer shows +/// `FileOpRunning` still waits for the job — and returns `Ok(())` so the +/// call sites read `return shutdown(&mut rt)`. With no job there is nothing +/// to wait for and quit exits at once. +fn shutdown(rt: &mut Runtime) -> io::Result<()> { + if let Some(job) = rt.active_job.take() { + if !shutdown_job(job, QUIT_JOB_WAIT) { + // The same partial-file risk as before this change, now confined + // to a job whose write has not returned in two seconds + // (error-diagnostics coordination: the timeout is logged). + diag::log("job", format_args!("quit: job thread still running after {} ms; exiting without its cleanup", QUIT_JOB_WAIT.as_millis())); + } + } + Ok(()) +} + +/// Request `job`'s cancellation and wait up to `wait` for its thread to +/// end, polling `is_finished()` every [`QUIT_JOB_POLL`]. Returns `true` +/// when the thread ended (and was joined), `false` when it was abandoned at +/// the bound. No dialog answer is sent: the sink's reply loop turns the +/// cancel flag into an abort for whichever prompt is pending (design D5), +/// so a conflict and an error reply can never be mismatched. The screen is +/// not redrawn meanwhile — at most `wait` passes with the last frame still +/// showing. +fn shutdown_job(job: worker::JobHandle, wait: Duration) -> bool { + job.cancel.cancel(); + let deadline = Instant::now() + wait; + while !job.thread.is_finished() { + if Instant::now() >= deadline { + return false; } + std::thread::sleep(QUIT_JOB_POLL); } + // Finished: the join cannot block, and a contained panic has already + // been turned into the job's `JobDone` reply by `spawn_job`. + let _ = job.thread.join(); + true } /// A wheel/scroll command not yet applied, accumulated across consecutive @@ -383,8 +916,12 @@ fn apply_mouse_batch( if matches!(m.kind, MouseEventKind::Moved) { continue; } + // While the placeholder hides the screen, the viewer/editor wheel + // arms are skipped and every event goes through `input::map_mouse`, + // whose `context` ignores all of it except the quit dialog's buttons + // (resize-preserves-session-state design D3). match &state.phase { - UiPhase::Viewer(_) => match m.kind { + UiPhase::Viewer(_) if !state.undersized => match m.kind { MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => { let notch = if m.kind == MouseEventKind::ScrollDown { 1 } else { -1 }; pending = match pending { @@ -397,7 +934,7 @@ fn apply_mouse_batch( } _ => flush!(), }, - UiPhase::Editor(_) => match m.kind { + UiPhase::Editor(_) if !state.undersized => match m.kind { MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => { let notch = if m.kind == MouseEventKind::ScrollDown { 1 } else { -1 }; pending = match pending { @@ -437,9 +974,201 @@ fn apply_mouse_batch( Ok((state, false, dirty)) } +/// The loop's terminal-event source, factored behind a trait so the burst +/// drain can be driven by a scripted queue in tests (idle-aware-event-loop +/// task 2.7). Production reads crossterm. +trait EventSource { + fn poll(&mut self, timeout: Duration) -> io::Result; + fn read(&mut self) -> io::Result; +} + +struct CrosstermEvents; + +impl EventSource for CrosstermEvents { + fn poll(&mut self, timeout: Duration) -> io::Result { + event::poll(timeout) + } + fn read(&mut self) -> io::Result { + event::read() + } +} + +/// The poll timeout for one loop iteration (idle-aware-event-loop design +/// D2/D3), factored pure for tests. Zero while a drained worker backlog is +/// still pending (`backlog`, from the budgeted [`drain_events`]; +/// streaming-listing-performance design D7); at most `POLL_INTERVAL` while +/// worker threads that can still post results are alive, so results keep +/// today's latency; otherwise the earliest of the reducer's next wake +/// (`core_wake`, on the same monotonic-millisecond axis as `now`) and the +/// header clock's next minute flip (`minute_wake`). +fn next_timeout(now: u64, core_wake: Option, minute_wake: Duration, workers_alive: bool, backlog: bool) -> Duration { + if backlog { + return Duration::ZERO; + } + let mut timeout = minute_wake; + if let Some(wake) = core_wake { + timeout = timeout.min(Duration::from_millis(wake.saturating_sub(now))); + } + if workers_alive { + timeout = timeout.min(POLL_INTERVAL); + } + timeout +} + +/// Design D4: bring the reducer clock up to date before the first event of +/// an input batch is applied, when the last delivered tick is older than +/// `POLL_INTERVAL` — so a surface opened after an idle stretch is stamped +/// with the real time and animates from its opening frame, and two clicks +/// separated by idle time are not mistaken for a double-click +/// (application-shell "Clicks separated by idle time are not a +/// double-click"). A no-op while the clock is already fresh; applies no +/// transition early (a key during the splash hold still dismisses the +/// splash, exactly as before). +fn tick_before_input( + state: State, + clock: &RealClock, + wall_clock: &RealWallClock, + guard: &mut TerminalGuard, + rt: &mut Runtime, +) -> io::Result<(State, bool)> { + let now = clock.now_ms(); + if now.saturating_sub(state.clock_ms) > POLL_INTERVAL.as_millis() as u64 { + // Both clocks travel in the tick, so a visit recorded right after an + // idle stretch is stamped with fresh wall-clock time too + // (history-persistence-hardening design D1). + apply(state, Command::Tick { now_ms: now, epoch_ms: wall_clock.epoch_ms() }, guard, rt) + } else { + Ok((state, false)) + } +} + +/// What the key-burst drain needs from the loop around it: the per-key +/// mapping (factored out of the loop body, design D5) and the command +/// application path. Production wires [`map_pressed_key`] + [`apply`] through +/// [`LoopHandler`]; tests drive the same drain with a recording handler and +/// no terminal. +trait BurstHandler { + fn map_key(&mut self, key: KeyEvent, state: &State) -> Option; + fn apply(&mut self, state: State, cmd: Command) -> io::Result<(State, bool)>; +} + +struct LoopHandler<'a> { + guard: &'a mut TerminalGuard, + rt: &'a mut Runtime, + keys: &'a config::Keys, +} + +impl BurstHandler for LoopHandler<'_> { + fn map_key(&mut self, key: KeyEvent, state: &State) -> Option { + map_pressed_key(key, state, self.keys, self.rt.viewer_source.as_ref().map(|(_, s)| s)) + } + fn apply(&mut self, state: State, cmd: Command) -> io::Result<(State, bool)> { + apply(state, cmd, self.guard, self.rt) + } +} + +/// The loop's per-key mapping (idle-aware-event-loop design D5), factored +/// out of the loop body so the first key of an iteration and every drained +/// burst key go through the same routing. The layer that owns input comes +/// from the single precedence order (`filecommand_core::input_layer`; +/// reducer-overlay-structure design D2): the viewer and editor layers map +/// through their own functions — the viewer's needs the open `ByteSource`, +/// the editor's its own page size — and every other layer goes through +/// `input::map_key`, which routes by the same function. That is what makes +/// the quit-confirmation dialog outrank the viewer/editor's direct dispatch +/// (application-shell "Quit request keys and confirmation"; design D5), and +/// the "terminal too small" placeholder gate every key of a hidden viewer or +/// editor (resize-preserves-session-state design D3). +fn map_pressed_key(key: KeyEvent, state: &State, keys: &config::Keys, viewer_source: Option<&ByteSource>) -> Option { + match (input_layer(state), &state.phase) { + (InputLayer::Viewer, UiPhase::Viewer(viewer)) => { + let rows_visible = views::viewer::body_rows(state.term_size.1); + input::map_viewer_key(key, viewer, rows_visible).and_then(|action| resolve_viewer_navigation(viewer, viewer_source, action, rows_visible)) + } + (InputLayer::Editor, UiPhase::Editor(editor)) => { + let rows_visible = views::editor::body_rows(state.term_size.1); + input::map_editor_key(key, editor, rows_visible) + } + _ => { + let page_size = active_panel_page_size(state); + input::map_key(key, state, page_size, keys) + } + } +} + +/// One iteration's application of an input burst (idle-aware-event-loop +/// "Bursts of key input render once"; design D5): apply `first` (a pressed +/// key), then keep reading with a zero poll while the burst budget +/// ([`BURST_MAX_EVENTS`] events or [`BURST_MAX_DURATION`]) lasts — each +/// pressed key mapped against the state the previous one produced, so +/// state-dependent routing (Enter opening a dialog, the next characters +/// landing in its field) is exactly as if the keys had arrived one loop +/// iteration at a time. Key releases and focus events are skipped; a resize +/// is applied like any command; a paste goes through `input::map_paste` as +/// one batch; a mouse event ends the burst and is stashed in +/// `pending_event` for the next iteration's mouse-batch path. Returns +/// `(state, quit, applied)`, `applied` feeding the loop's `needs_draw`. +fn drain_key_burst( + first: KeyEvent, + mut state: State, + events: &mut dyn EventSource, + pending_event: &mut Option, + handler: &mut dyn BurstHandler, +) -> io::Result<(State, bool, bool)> { + let started = Instant::now(); + let mut applied = false; + let mut handled = 0usize; + let mut next = Event::Key(first); + loop { + match next { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(cmd) = handler.map_key(key, &state) { + let (s, quit) = handler.apply(state, cmd)?; + state = s; + applied = true; + if quit { + return Ok((state, true, applied)); + } + } + } + Event::Resize(w, h) => { + let (s, quit) = handler.apply(state, Command::Resize(w, h))?; + state = s; + applied = true; + if quit { + return Ok((state, true, applied)); + } + } + Event::Paste(text) => { + for cmd in input::map_paste(&text, &state) { + let (s, quit) = handler.apply(state, cmd)?; + state = s; + applied = true; + if quit { + return Ok((state, true, applied)); + } + } + } + Event::Mouse(mouse) => { + *pending_event = Some(Event::Mouse(mouse)); + return Ok((state, false, applied)); + } + _ => {} + } + handled += 1; + if handled >= BURST_MAX_EVENTS || started.elapsed() >= BURST_MAX_DURATION { + return Ok((state, false, applied)); + } + if !events.poll(Duration::ZERO)? { + return Ok((state, false, applied)); + } + next = events.read()?; + } +} + /// Resolve the startup theme from `config.theme` and an optional `--theme` /// launch override, returning the theme to start with plus an optional -/// warning message for `state.startup_warning`. +/// warning message for the startup-warning dialog. /// /// `override_name`: `None` when `--theme` was not passed (no warning is /// ever produced in that case); `Some("")` when `--theme` was passed with no @@ -487,6 +1216,39 @@ fn apply_config(state: &mut State, config: &config::Config) { state.shell = ShellConfig::from_env(config.shell.clone()); state.editor = config.editor.clone(); state.split_percent = config.panel_split; + state.animations = config.animations; +} + +/// Apply launch switches that override a config-driven `State` value: +/// `--noanimations` forces animations off even when `animations = true` +/// (ui-animations "Flag overrides config"). Run after [`apply_config`], which +/// copies the config's value, so the flag always has the last word. +fn apply_launch_options(state: &mut State, launch: &LaunchOptions) { + if launch.no_animations { + state.animations = false; + } +} + +/// The one writer of the startup warning at startup (error-diagnostics +/// design D3): the dialog stays last-writer-wins — `State::set_startup_ +/// warning` replaces an existing warning's message rather than asserting an +/// empty overlay slot (reducer-overlay-structure design D5) — but every +/// warning, including ones a later assignment replaces, is recorded in the +/// diagnostic log (application-shell "Optional diagnostic log": "Superseded +/// startup warnings are still recorded"). With the log disabled this is +/// exactly the core setter. +fn set_startup_warning(state: &mut State, warning: String) { + diag::log("startup", &warning); + state.set_startup_warning(warning); +} + +/// Stamp the startup-warning dialog's open time so it irises in like every +/// other modal. It is raised here, in `run`, rather than by a reducer +/// command, so the reducer's own stamping post-condition never sees it open. +fn stamp_startup_warning(state: &mut State) { + if state.startup_warning().is_some() { + state.dialog_opened_at_ms = state.clock_ms; + } } /// Snapshot the F2 user menu into `State` at startup, factored out of `run` @@ -494,13 +1256,44 @@ fn apply_config(state: &mut State, config: &config::Config) { /// terminal. On a malformed `usermenu.toml`, `config::load_user_menu` has /// already fallen back to default entries in memory without touching the /// file; this raises the spec-required dismissable startup-warning dialog -/// (`state.startup_warning`) rather than a panel mini-status, since the +/// (`state.startup_warning()`) rather than a panel mini-status, since the /// warning concerns the whole session, not either panel specifically /// (user-menu "Malformed file warns and falls back without overwriting"). fn apply_user_menu(state: &mut State, user_menu: config::UserMenuLoadResult) { state.user_menu_entries = user_menu.entries; if user_menu.malformed { - state.startup_warning = Some(format!("{} is malformed; F2 uses the default user menu", config::USERMENU_FILE)); + let file = config::USERMENU_FILE; + let warning = match user_menu.problem { + // A not-UTF-8 file gets its own message naming the way out, + // because the built-in editor will not open it + // (configuration-files "Non-UTF-8 usermenu.toml is kept, not + // overwritten"; design D3). + Some(config::UserMenuProblem::NotUtf8) => { + format!("{file} is not UTF-8 text; using the default menu — re-save it as UTF-8 or delete it to recreate the defaults") + } + Some(config::UserMenuProblem::Unreadable(error)) => format!("{file} could not be read ({error}); using the default menu"), + Some(config::UserMenuProblem::InvalidToml) | None => format!("{file} is malformed; F2 uses the default user menu"), + }; + set_startup_warning(state, warning); + } +} + +/// Show the `config.toml`/`usermenu.toml` content warnings as one summary +/// line in the startup-warning dialog, but only when no other startup +/// warning is already up: the summary has the lowest precedence, so every +/// warning an existing requirement mandates (profile directory, `--theme`, +/// malformed user menu, legacy notice, unwritable profile) still shows +/// (configuration-files "Profile warning keeps precedence", "Theme override +/// warning keeps precedence"; design D4). Every individual warning goes to +/// the diagnostic log regardless, since the dialog shows only the first. +fn apply_configuration_warnings(state: &mut State, warnings: &[String]) { + for warning in warnings { + diag::log("config", warning); + } + if state.startup_warning().is_none() { + if let Some(summary) = config::summarize_warnings(warnings) { + set_startup_warning(state, summary); + } } } @@ -606,27 +1399,122 @@ fn apply(state: State, cmd: Command, guard: &mut TerminalGuard, rt: &mut Runtime // cached byte source needs to be dropped — checked before `update` // consumes `cmd`. if matches!(cmd, Command::ViewerClose) { - rt.viewer_source = None; + rt.release_viewer_source(); } + // A cancellable request's final reply retires its cancel flag — there is + // nothing left to stop (listing-request-guard design D3). Same pre- + // `update` interception point as the viewer source above. + match &cmd { + Command::ListingComplete { request, .. } + | Command::ListingFailed { request, .. } + | Command::FindFileSearchDone { request } + | Command::ViewerSearchResult { request, .. } => rt.cancels.retire(*request), + _ => {} + } + // The source must also go whenever the viewer phase *ends* by any other + // route, and when a `ViewerOpened` is discarded, so the file is held + // exactly as long as the viewer exists — see + // `Runtime::release_viewer_source_if_viewer_ended` (resize-preserves- + // session-state design D7; viewer "Closing the viewer releases the file"). + let was_viewer = matches!(state.phase, UiPhase::Viewer(_)); + let viewer_opened = match cmd { + Command::ViewerOpened { request, .. } => Some(request), + _ => None, + }; + // Commands → Menu file edit, built-in-editor leg: remember whether the + // editor currently has the user-menu file open, so the transition out of + // `UiPhase::Editor` (any of the quit paths, save-on-exit included) + // triggers the reload below (user-menu "Edit the user menu file from the + // Commands menu"; design D4). + let editing_user_menu = matches!(&state.phase, UiPhase::Editor(editor) if editor.path == rt.usermenu_path); let (state, effects) = update(state, cmd); let quit = run_effects(effects, guard, rt)?; + // The viewer-open worker's source is waiting on the hand-off channel + // ahead of its `ViewerOpened`; it becomes the viewer's source only when + // the reducer accepted the reply (offload-blocking-io design D4). + if let Some(request) = viewer_opened { + rt.adopt_viewer_handoff(request, was_viewer, &state); + } + rt.release_viewer_source_if_viewer_ended(was_viewer, viewer_opened.is_some(), &state); + if editing_user_menu && !matches!(state.phase, UiPhase::Editor(_)) { + reload_user_menu(rt); + } Ok((state, quit)) } -/// Drain every queued worker event, including any command an effect fed -/// straight back. The `bool` is `true` if anything was applied, so the -/// caller knows to redraw. -fn drain_events(mut state: State, rx: &Receiver, guard: &mut TerminalGuard, rt: &mut Runtime) -> io::Result<(State, bool)> { - let mut applied = false; - while let Ok(cmd) = rx.try_recv() { - applied = true; - let (s, quit) = apply(state, cmd, guard, rt)?; +/// Re-read `usermenu.toml` and feed the parse result back as +/// `Command::UserMenuReloaded` (design D4) — drained before the next frame, +/// exactly like the `RereadPanel` an external editor's return sends. +fn reload_user_menu(rt: &Runtime) { + worker::deliver(&rt.tx, "user menu reload", Command::UserMenuReloaded(config::load_user_menu(&rt.usermenu_path))); +} + +/// How many queued worker messages one [`drain_events`] call applies before +/// the loop reads input and redraws (streaming-listing-performance design +/// D7; panel-navigation "Input stays responsive while a 100,000-entry +/// directory streams in"). +const DRAIN_BUDGET_MESSAGES: usize = 64; + +/// How long one [`drain_events`] call may spend applying messages — about a +/// frame — before it stops and lets input and the frame through (design D7). +const DRAIN_BUDGET: Duration = Duration::from_millis(8); + +/// What one budgeted drain did (streaming-listing-performance design D7). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct DrainOutcome { + /// Something was applied, so the caller redraws. + applied: bool, + /// The budget ran out with messages still queued — the one taken by the + /// budget check is held for the next drain — so the loop polls input + /// with a zero timeout and draws before draining further. + backlog: bool, +} + +/// Drain queued worker events, including any command an effect fed straight +/// back, under the drain budget: at most [`DRAIN_BUDGET_MESSAGES`] messages +/// or [`DRAIN_BUDGET`] of wall time, whichever comes first (design D7). A +/// message taken from the channel when the budget turns out to be spent is +/// parked in [`Runtime::held`] and applied first next time, so nothing is +/// lost or reordered. +fn drain_events(state: State, rx: &Receiver, guard: &mut TerminalGuard, rt: &mut Runtime) -> io::Result<(State, DrainOutcome)> { + let mut held = rt.held.take(); + let result = drain_budgeted(state, rx, &mut held, &Instant::now, |state, cmd| apply(state, cmd, guard, rt)); + rt.held = held; + result +} + +/// The budgeted drain behind [`drain_events`], factored over the state type, +/// the one-slot `held` buffer, the clock and the application step so tests +/// can drive it with a scripted channel and no terminal (design D7). Stops +/// when the channel is empty (no backlog), after `apply` reports quit, or +/// when a message is taken with the budget already spent — that message is +/// placed in `held` and `backlog` is reported. The `held` message is always +/// the first one applied. +fn drain_budgeted( + mut state: S, + rx: &Receiver, + held: &mut Option, + now: &dyn Fn() -> Instant, + mut apply: impl FnMut(S, Command) -> io::Result<(S, bool)>, +) -> io::Result<(S, DrainOutcome)> { + let start = now(); + let mut outcome = DrainOutcome::default(); + let mut applied_count = 0usize; + while let Some(cmd) = held.take().or_else(|| rx.try_recv().ok()) { + if applied_count >= DRAIN_BUDGET_MESSAGES || now().saturating_duration_since(start) >= DRAIN_BUDGET { + *held = Some(cmd); + outcome.backlog = true; + break; + } + applied_count += 1; + outcome.applied = true; + let (s, quit) = apply(state, cmd)?; state = s; if quit { break; } } - Ok((state, applied)) + Ok((state, outcome)) } /// Execute effects returned by `update`. Returns `true` if the caller should @@ -635,9 +1523,13 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime let mut quit = false; for effect in effects { match effect { - Effect::StartListing { panel, path } => worker::spawn_listing(panel, path, rt.tx.clone()), + Effect::StartListing { panel, path, request } => { + let cancel = rt.cancels.register(request); + worker::spawn_listing(panel, path, request, cancel, rt.tx.clone(), rt.workers.token()); + } + Effect::CancelRequest(request) => rt.cancels.cancel(request), Effect::Quit => quit = true, - Effect::RunJob(job) => rt.active_job = Some(worker::spawn_job(job, rt.tx.clone())), + Effect::RunJob(job) => rt.active_job = Some(worker::spawn_job(job, rt.tx.clone(), rt.workers.token())), Effect::CancelJob => { if let Some(handle) = &rt.active_job { handle.cancel.cancel(); @@ -645,12 +1537,19 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime } Effect::SendConflictReply(choice) => { if let Some(handle) = &rt.active_job { - let _ = handle.reply_tx.send(worker::JobReply::Conflict(choice)); + // A failed send means the job thread has already ended; + // the answer is lost, so it is logged rather than + // dropped silently (error-diagnostics design D3). + if handle.reply_tx.send(worker::JobReply::Conflict(choice)).is_err() { + diag::log("job", "conflict answer not delivered: job ended"); + } } } Effect::SendErrorReply(choice) => { if let Some(handle) = &rt.active_job { - let _ = handle.reply_tx.send(worker::JobReply::Error(choice)); + if handle.reply_tx.send(worker::JobReply::Error(choice)).is_err() { + diag::log("job", "error answer not delivered: job ended"); + } } } Effect::RunShellCommand(invocation, side) => { @@ -664,54 +1563,84 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime // Fed straight back, same as drive enumeration: the panel // that owned the command must show whatever the command did // to its directory the moment the TUI repaints. - let _ = rt.tx.send(Command::RereadPanel(side)); + worker::deliver(&rt.tx, "panel re-read", Command::RereadPanel(side)); + } + Effect::OpenWithAssociation { side, path, cwd } => { + // Fire-and-forget (open-with-associated-app design D1/D3): + // no suspend, no wait, no mouse-tracker reset, no panel + // re-read. The launch runs on a worker thread with COM + // initialized (offload-blocking-io design D7), so a slow + // handler never freezes the panels; only a launch failure + // reports back, as the command that lands the message on + // the invoking panel's status line. + worker::spawn_open_with_association(side, path, cwd, rt.tx.clone(), rt.workers.token()); + } + Effect::ProbePath { request, path } => { + // The command-line target check the reducer no longer makes + // itself (application-shell "The reducer performs no + // filesystem I/O"; offload-blocking-io design D1/D2). + worker::spawn_path_probe(request, path, rt.tx.clone(), rt.workers.token()); } Effect::ShowScrollback => { show_scrollback(guard)?; rt.mouse_tracker.reset(); } Effect::PersistHistory(file) => { - // A history write failing (read-only directory, full disk) - // must never take the session down with it. - let _ = config::save_history_file_atomic(&rt.history_path, &file); + // Handed to the background writer, which coalesces bursts, + // merges with the on-disk file, and writes durably off this + // thread (fuzzy-jump "Navigation does not wait for the + // history write"; history-persistence-hardening design D4). + // The writer is the disabled no-op when the profile + // directory is unwritable this session + // (config-profile-directory; design D2), and a failed write + // is logged there rather than taking the session down. + rt.history_writer.submit(file); } Effect::PersistTheme(name) => { - // Same tolerance as history: a failed config write must - // never take the session down (the theme is already active - // in memory either way). - let _ = config::save_theme_atomic(&rt.config_path, &name); + // Handed to the background configuration writer, which + // applies writes one at a time with the newest value per + // setting and flushes, bounded, at exit (application-shell + // "Slow storage never blocks the event loop"; offload- + // blocking-io design D9). Same tolerance as history: a + // failed write is logged there and never takes the session + // down (the theme is already active in memory either way); + // the writer is the disabled no-op when persistence is off + // for the session (design D2). + rt.config_writer.submit(ConfigWrite::Theme(name)); } Effect::PersistPanelSplit(percent) => { - // Same tolerance as the theme write above (panel-split - // "Split persistence to configuration"). - let _ = config::save_panel_split_atomic(&rt.config_path, percent); + // Same path as the theme write above (panel-split "Split + // persistence to configuration"; design D2). + rt.config_writer.submit(ConfigWrite::PanelSplit(percent)); } Effect::EnumerateDrives(target) => { // Cheap enough for the input path: a bitmask read, no media // or network probing. Fed straight back so the dialog's // first painted frame already lists every letter. - let _ = rt.tx.send(Command::DriveListReady { target, drives: drives::enumerate_drives() }); + worker::deliver(&rt.tx, "drive list", Command::DriveListReady { target, drives: drives::enumerate_drives() }); + } + Effect::FetchDriveLabel { target, letter, generation } => { + worker::spawn_drive_label(target, letter, generation, rt.tx.clone(), rt.workers.token()) + } + Effect::QueryInfo { panel, path, request } => worker::spawn_info_query(panel, path, request, rt.tx.clone(), rt.workers.token()), + Effect::QueryGitInfo { panel, path, request } => { + let slot = match panel { + PanelSide::Left => &rt.git_slots[0], + PanelSide::Right => &rt.git_slots[1], + }; + slot.request(panel, path, request, rt.tx.clone(), rt.workers.token()); + } + Effect::ExpandTreeNode { panel, path } => worker::spawn_tree_expand(panel, path, rt.tx.clone(), rt.workers.token()), + Effect::OpenViewer { request, path } => { + // Off the event loop: a dropped SMB session blocks the open + // for the redirector timeout (offload-blocking-io design + // D4/D5). The source comes back on the hand-off channel + // ahead of the `ViewerOpened` reply. + worker::spawn_viewer_open(request, path, rt.viewer_handoff.0.clone(), rt.tx.clone(), rt.workers.token()); } - Effect::FetchDriveLabel { target, letter, generation } => worker::spawn_drive_label(target, letter, generation, rt.tx.clone()), - Effect::QueryInfo { panel, path, request } => worker::spawn_info_query(panel, path, request, rt.tx.clone()), - Effect::QueryGitInfo { panel, path, request } => worker::spawn_git_info_query(panel, path, request, rt.tx.clone()), - Effect::ExpandTreeNode { panel, path } => worker::spawn_tree_expand(panel, path, rt.tx.clone()), - Effect::OpenViewer { path } => match ByteSource::open(&path) { - Ok(source) => { - // O(1): opening/mapping touches only the handle and its - // length, never the content (viewer: Instant open), so - // this runs synchronously rather than on a worker - // thread, exactly like `EnumerateDrives`. - let file_len = source.len(); - rt.viewer_source = Some((path.clone(), source)); - let _ = rt.tx.send(Command::ViewerOpened { path, file_len }); - } - Err(e) => { - let _ = rt.tx.send(Command::ViewerOpenFailed { message: e.to_string() }); - } - }, Effect::RunViewerSearch { path, start_offset, pattern, request } => { - worker::spawn_viewer_search(path, start_offset, pattern, request, rt.tx.clone()); + let cancel = rt.cancels.register(request); + worker::spawn_viewer_search(path, start_offset, pattern, request, cancel, rt.tx.clone(), rt.workers.token()); } Effect::RunExternalEditor(invocation, side) => { let result = run_external_editor(guard, &invocation)?; @@ -722,41 +1651,36 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime // panel that owned the cursor entry, mirroring // `RunShellCommand` (external-editor: Synchronous wait // and panel re-read — "Panel refreshed after edit"). - let _ = rt.tx.send(Command::RereadPanel(side)); + worker::deliver(&rt.tx, "panel re-read", Command::RereadPanel(side)); + // Commands → Menu file edit returns through here too: + // reload the user menu when the file the editor had + // open was `usermenu.toml` (design D4). A panel F4 + // edit's `file_arg` is a bare file name, never the + // absolute menu path, so this never false-fires. + if Path::new(&invocation.file_arg) == rt.usermenu_path.as_path() { + reload_user_menu(rt); + } } Err(message) => { - let _ = rt.tx.send(Command::ExternalEditorSpawnFailed { message }); + worker::deliver(&rt.tx, "external editor failure", Command::ExternalEditorSpawnFailed { message }); } } } - Effect::OpenEditor { path } => match EditorState::open(&path) { - // Synchronous, like `Effect::OpenViewer`: the whole read is - // bounded by the 10 MB cap, so this is cheap enough for the - // input path rather than warranting a worker thread (design - // D1). - Ok(EditorLoadResult::Loaded(editor)) => { - let _ = rt.tx.send(Command::EditorOpened(Box::new(editor))); - } - Ok(EditorLoadResult::TooLarge { size }) => { - let _ = rt.tx.send(Command::EditorTooLarge { path, size }); - } - Err(e) => { - let _ = rt.tx.send(Command::EditorOpenFailed { message: e.to_string() }); - } - }, + Effect::OpenEditor { request, path } => { + // Off the event loop: the whole-file read is bounded by the + // 10 MB cap but not in latency on a slow share (offload- + // blocking-io design D4). + worker::spawn_editor_open(request, path, rt.tx.clone(), rt.workers.token()); + } Effect::FindInSubtree { root, pattern, request } => { - worker::spawn_find_subtree(root, pattern, request, rt.tx.clone()); + let cancel = rt.cancels.register(request); + worker::spawn_find_subtree(root, pattern, request, cancel, rt.tx.clone(), rt.workers.token()); } Effect::SaveEditor { editor, then_quit } => { - let mut editor = *editor; - match editor.save() { - Ok(()) => { - let _ = rt.tx.send(Command::EditorSaved { editor: Box::new(editor), then_quit }); - } - Err(e) => { - let _ = rt.tx.send(Command::EditorSaveFailed { message: e.to_string() }); - } - } + // Off the event loop; the reducer keeps the editor gated on + // `save_pending` until the reply (offload-blocking-io design + // D6). + worker::spawn_editor_save(editor, then_quit, rt.tx.clone(), rt.workers.token()); } Effect::SetClipboard(payload) => { // Path normalisation (clipboard-export "Windows file-object @@ -775,7 +1699,25 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime ClipboardPayloadKind::Names => (rt.clipboard.set_text(&clipboard::names_text(&items)), false), }; let normalized_payload = update::ClipboardPayload { kind: payload.kind, items }; - let _ = rt.tx.send(Command::ClipboardResult { payload: normalized_payload, fell_back_to_paths, result }); + worker::deliver(&rt.tx, "clipboard result", Command::ClipboardResult { payload: normalized_payload, fell_back_to_paths, result }); + } + Effect::ReadClipboardText => { + // The read half of paste (text-field-editing "Paste into a + // text field"; design D4): clipboard text comes back as + // `TextEdit(Paste(..))` for whichever field owns the + // keyboard when it arrives. Line breaks flatten to a single + // space here (`LineEdit::insert_str` does the same again, + // so the core behavior never depends on this path — and the + // terminal bracketed-paste route flattens through the same + // helper; idle-aware-event-loop design D6), and a clipboard + // with no text feeds nothing back — the key is then a no-op + // ("Empty clipboard is a no-op"). + if let Some(text) = rt.clipboard.get_text() { + let text = flatten_line_breaks(&text); + if !text.is_empty() { + worker::deliver(&rt.tx, "paste", Command::TextEdit(filecommand_core::line_edit::TextEditOp::Paste(text))); + } + } } } } @@ -787,21 +1729,113 @@ fn run_effects(effects: Vec, guard: &mut TerminalGuard, rt: &mut Runtime /// Restore runs on every path — a spawn failure, a non-zero exit, or a /// child that scribbled over the screen — because `resume` is idempotent /// and the panic hook covers the rest. +/// +/// The whole stretch, from before the suspend until after the resume has +/// re-entered raw mode, runs inside a [`console::ChildRunGuard`], so a +/// Ctrl+C or Ctrl+Break typed meanwhile reaches only the child +/// (application-shell "Ctrl+C during a suspended child run stops only the +/// child"; process-lifecycle-hardening design D1/D2). fn run_shell_command(guard: &mut TerminalGuard, invocation: &Invocation) -> io::Result<()> { + let mut child_run = console::ChildRunGuard::new(); guard.suspend()?; - let status = std::process::Command::new(&invocation.program) - .args(&invocation.args) - .current_dir(&invocation.cwd) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status(); + let status = console::spawn_and_wait( + std::process::Command::new(&invocation.program) + .args(&invocation.args) + .current_dir(&invocation.cwd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()), + &mut child_run, + ); if let Err(e) = &status { // The message lands in the scrollback the user is about to see. let _ = writeln!(io::stdout(), "\r\nCould not run `{}`: {e}", invocation.program); } wait_for_key()?; - guard.resume() + guard.resume()?; + drop(child_run); + Ok(()) +} + +/// Open `path` with the application the platform associates it with +/// (open-with-associated-app; design D1): `ShellExecuteW` with the `open` +/// verb on Windows, a detached `xdg-open`/`open` elsewhere. Never suspends +/// the TUI and never waits on the launched program; `Err(message)` is the +/// user-facing failure text for the invoking panel's status line. Called +/// from `worker::spawn_open_with_association`, on a worker thread with COM +/// initialized (offload-blocking-io design D7). +#[cfg(windows)] +pub(crate) fn open_with_association(path: &Path, cwd: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + fn wide(s: &std::ffi::OsStr) -> Vec { + s.encode_wide().chain(std::iter::once(0)).collect() + } + + // ShellExecuteW predates long-path support, so the `\\?\` verbatim + // prefix must come off first — the same normalization the clipboard- + // export payload applies (its "Long-path prefix is stripped" scenario; + // file-action-menu "Open with the associated application": "Long path is + // passed without the verbatim prefix"). + let path = clipboard::normalize_path(path); + let cwd = clipboard::normalize_path(cwd); + let verb = wide(std::ffi::OsStr::new("open")); + let file = wide(path.as_os_str()); + let directory = wide(cwd.as_os_str()); + + // SAFETY: every pointer is to a null-terminated UTF-16 buffer that + // outlives the call; a null hwnd simply means no owner window. + let result = unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), file.as_ptr(), std::ptr::null(), directory.as_ptr(), SW_SHOWNORMAL) }; + // Per MSDN a return value greater than 32 is success; anything else is + // an error code (a SE_ERR_* / ERROR_* value). + let code = result as usize; + if code > 32 { + return Ok(()); + } + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + Err(open_with_association_error(&name, code as u32)) +} + +/// The user-facing message for a failed `ShellExecuteW` return code, +/// factored out pure so it is testable without touching the shell (design +/// D1): `SE_ERR_NOASSOC` names the missing association, +/// `ERROR_FILE_NOT_FOUND` the missing target, and anything else reports the +/// raw code (file-action-menu "Open with the associated application"). +#[cfg(windows)] +fn open_with_association_error(name: &str, code: u32) -> String { + use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; + use windows_sys::Win32::UI::Shell::SE_ERR_NOASSOC; + + match code { + SE_ERR_NOASSOC => format!("No application is associated with {name}"), + ERROR_FILE_NOT_FOUND => format!("{name} not found"), + _ => format!("Could not open {name} (error {code})"), + } +} + +/// The non-Windows half of the Open action (design D1): `xdg-open` +/// (Linux/BSD) or `open` (macOS), spawned detached — all stdio to null, no +/// wait — so the TUI stays live and the launched program's output can never +/// interleave with it. A spawn error maps to the same failure message shape +/// the Windows path produces. +#[cfg(not(windows))] +pub(crate) fn open_with_association(path: &Path, cwd: &Path) -> Result<(), String> { + let program = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + std::process::Command::new(program) + .arg(path) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + format!("Could not open {name} ({e})") + }) } /// Suspend the TUI, run the F4 external editor on the real terminal, and @@ -815,13 +1849,18 @@ fn run_shell_command(guard: &mut TerminalGuard, invocation: &Invocation) -> io:: /// — so a misbehaving editor can never leave the terminal corrupted /// (external-editor: Terminal restored when the editor crashes). fn run_external_editor(guard: &mut TerminalGuard, invocation: &EditorInvocation) -> io::Result> { + // Same child-run guard as `run_shell_command`: Ctrl+C inside the editor + // never ends FileCommand (application-shell "Ctrl+C inside the external + // editor does not end FileCommand"; design D1/D2). + let mut child_run = console::ChildRunGuard::new(); guard.suspend()?; - let result = spawn_editor_and_wait(invocation); + let result = spawn_editor_and_wait(invocation, &mut child_run); if let Err(message) = &result { let _ = writeln!(io::stdout(), "\r\n{message}"); } wait_for_key()?; guard.resume()?; + drop(child_run); Ok(result) } @@ -831,18 +1870,20 @@ fn run_external_editor(guard: &mut TerminalGuard, invocation: &EditorInvocation) /// `run_shell_command`'s precedent of not treating the child's exit code as /// FileCommand's own error); `Err` only when the process could not be /// spawned at all (external-editor: Editor spawn errors do not crash the -/// app). -fn spawn_editor_and_wait(invocation: &EditorInvocation) -> Result<(), String> { - std::process::Command::new(&invocation.program) - .args(&invocation.leading_args) - .arg(&invocation.file_arg) - .current_dir(&invocation.cwd) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .map(|_| ()) - .map_err(|e| format!("Could not run `{}`: {e}", invocation.program)) +/// app). `child_run` is armed once the editor has started (design D2). +fn spawn_editor_and_wait(invocation: &EditorInvocation, child_run: &mut console::ChildRunGuard) -> Result<(), String> { + console::spawn_and_wait( + std::process::Command::new(&invocation.program) + .args(&invocation.leading_args) + .arg(&invocation.file_arg) + .current_dir(&invocation.cwd) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()), + child_run, + ) + .map(|_| ()) + .map_err(|e| format!("Could not run `{}`: {e}", invocation.program)) } /// Ctrl+O: hand the screen back so the host terminal's scrollback (which is @@ -910,6 +1951,7 @@ fn draw( #[cfg(test)] mod tests { use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; use std::ffi::OsString; /// A harmless, always-present "editor" invocation per platform — exits @@ -926,39 +1968,322 @@ mod tests { EditorInvocation { program, leading_args, file_arg: OsString::from(file_arg), cwd: std::env::temp_dir() } } - /// Regression test for the F4 external editor being silently dead: a - /// config-driven startup (via `config::parse`, the real parsing path, - /// not a hand-built `Config`) must populate `state.editor`, mirroring - /// the already-wired `state.shell`. Before this fix, `run()` never - /// assigned `state.editor` at all, so `editor =` in `config.toml` was - /// parsed correctly but never reached `State`. + /// listing-request-guard design D3 (task 5.14): `cancel` sets the + /// registered flag and forgets it, an unknown id is a no-op, and + /// `retire` forgets without setting. #[test] - fn apply_config_wires_the_configured_editor_and_shell_into_state() { - let config = config::parse("editor = \"code --wait\"\nshell = \"pwsh -NoLogo -Command\"\n"); - let mut state = State::empty(Theme::classic()); - assert_eq!(state.editor, None, "sanity: editor starts unset before wiring"); - apply_config(&mut state, &config); - assert_eq!(state.editor.as_deref(), Some("code --wait")); - assert_eq!(state.shell.shell.as_deref(), Some("pwsh -NoLogo -Command")); + fn cancel_registry_cancels_retires_and_ignores_unknown_ids() { + let mut registry = CancelRegistry::default(); + let listing = registry.register(1); + let walk = registry.register(2); + assert!(!listing.is_cancelled() && !walk.is_cancelled(), "fresh flags are unset"); + + registry.cancel(1); + assert!(listing.is_cancelled(), "cancel sets the worker's clone of the flag"); + assert!(!registry.flags.contains_key(&1), "and removes the entry"); + assert!(!walk.is_cancelled(), "other requests are untouched"); + + registry.cancel(1); + registry.cancel(999); + assert_eq!(registry.flags.len(), 1, "an already-finished or unknown id is a silent no-op"); + + registry.retire(2); + assert!(!walk.is_cancelled(), "retire never sets the flag"); + assert!(registry.flags.is_empty()); } - #[test] - fn apply_config_wires_the_configured_panel_split_into_state() { - let config = config::parse("panel_split = 66\n"); - let mut state = State::empty(Theme::classic()); - assert_eq!(state.split_percent, 50, "sanity: split starts at the default before wiring"); - apply_config(&mut state, &config); - assert_eq!(state.split_percent, 66); + /// A `Runtime` with no terminal and no worker threads, for the viewer- + /// source tests below. + fn headless_runtime(dir: &std::path::Path) -> (Runtime, Receiver) { + let (tx, rx) = mpsc::channel(); + let rt = Runtime { + tx, + active_job: None, + cancels: CancelRegistry::default(), + history_writer: HistoryWriter::disabled(), + config_writer: ConfigWriter::disabled(), + usermenu_path: dir.join("usermenu.toml"), + viewer_source: None, + viewer_handoff: mpsc::channel(), + clipboard: Box::new(clipboard::RecordingClipboard::default()), + mouse_tracker: MouseTracker::new(), + git_slots: [worker::GitQuerySlot::new(), worker::GitQuerySlot::new()], + workers: worker::WorkerGauge::new(), + held: None, + }; + (rt, rx) + } + + /// Mirrors `apply` minus `run_effects` (which needs a `TerminalGuard`): + /// the pre-`update` `ViewerClose` release, the post-`update` hand-off + /// adoption and the transition check, in the same order. + fn apply_headless(state: State, cmd: Command, rt: &mut Runtime) -> State { + if matches!(cmd, Command::ViewerClose) { + rt.release_viewer_source(); + } + let was_viewer = matches!(state.phase, UiPhase::Viewer(_)); + let viewer_opened = match cmd { + Command::ViewerOpened { request, .. } => Some(request), + _ => None, + }; + let (state, _effects) = update(state, cmd); + if let Some(request) = viewer_opened { + rt.adopt_viewer_handoff(request, was_viewer, &state); + } + rt.release_viewer_source_if_viewer_ended(was_viewer, viewer_opened.is_some(), &state); + state + } + + /// Run the viewer-open worker for `path` under `request` the way + /// `run_effects` does, and return its `ViewerOpened` reply once the + /// source is waiting on the hand-off channel. + fn open_on_worker(rt: &Runtime, rx: &Receiver, request: u64, path: &Path) -> Command { + worker::spawn_viewer_open(request, path.to_path_buf(), rt.viewer_handoff.0.clone(), rt.tx.clone(), rt.workers.token()); + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(cmd @ Command::ViewerOpened { .. }) => cmd, + other => panic!("expected ViewerOpened, got {other:?}"), + } + } + + /// A state whose pending open is `request` on the left panel, as + /// `handle_request_viewer` would have recorded it. + fn state_awaiting_open(request: u64) -> State { + let mut state = State::empty(Theme::classic()); + state.term_size = (80, 24); + state.request_seq = request; + state.pending_open = + Some(filecommand_core::update::PendingOpen { request, side: PanelSide::Left, kind: filecommand_core::update::PendingOpenKind::Viewer }); + state + } + + /// viewer: Instant open — "Closing the viewer releases the file": after + /// the worker's source is adopted on `ViewerOpened` and + /// `Command::ViewerClose` clears it, the file and its directory can both + /// be removed — an open handle blocks directory removal on Windows, so + /// this fails if the source leaks. `apply`/`run_effects` need a + /// `TerminalGuard`, which a headless test cannot construct, so this + /// drives the same `Runtime` methods those two call. + #[test] + fn closing_the_viewer_releases_the_file_and_its_directory() { + let dir = std::env::temp_dir().join(format!("filecommand-tui-app-viewer-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("file.bin"); + std::fs::write(&path, b"hello world").unwrap(); + let (mut rt, rx) = headless_runtime(&dir); + + let opened = open_on_worker(&rt, &rx, 1, &path); + assert!(matches!(&opened, Command::ViewerOpened { request: 1, path: opened, file_len: 11 } if *opened == path), "{opened:?}"); + let state = apply_headless(state_awaiting_open(1), opened, &mut rt); + assert!(matches!(state.phase, UiPhase::Viewer(_))); + assert!(rt.viewer_source.is_some(), "the worker's source was adopted"); + + let state = apply_headless(state, Command::ViewerClose, &mut rt); + assert_eq!(state.phase, UiPhase::Panels); + assert!(rt.viewer_source.is_none()); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } + + /// offload-blocking-io design D4 (task 5.7): `spawn_viewer_open` delivers + /// the source on the hand-off channel *before* its `ViewerOpened`, and + /// `apply` keeps only the entry whose request the reducer accepted, + /// dropping a non-matching one so its file is released. + #[test] + fn apply_keeps_the_matching_viewer_source_and_drops_a_superseded_one() { + let dir = std::env::temp_dir().join(format!("filecommand-tui-app-viewer-handoff-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let slow = dir.join("slow.log"); + let fast = dir.join("fast.txt"); + std::fs::write(&slow, b"slow").unwrap(); + std::fs::write(&fast, b"fast!").unwrap(); + let (mut rt, rx) = headless_runtime(&dir); + + // The superseded open (request 1) and the current one (request 2) + // both complete; each source is on the channel before its command. + let opened_slow = open_on_worker(&rt, &rx, 1, &slow); + assert!(rt.viewer_handoff.1.try_recv().is_ok(), "the source arrives ahead of ViewerOpened"); + let opened_fast = open_on_worker(&rt, &rx, 2, &fast); + + // The reducer's pending open is request 2: the stale reply for 1 is + // discarded, and no source is adopted for it. + let state = apply_headless(state_awaiting_open(2), opened_slow, &mut rt); + assert_eq!(state.phase, UiPhase::Panels, "the stale reply did not open the viewer"); + assert!(rt.viewer_source.is_none(), "no source is kept for a discarded reply"); + + let state = apply_headless(state, opened_fast, &mut rt); + match &state.phase { + UiPhase::Viewer(v) => assert_eq!(v.path, fast), + other => panic!("expected the viewer on fast.txt, got {other:?}"), + } + match &rt.viewer_source { + Some((path, source)) => { + assert_eq!(path, &fast, "the adopted source is the one for the accepted reply"); + assert_eq!(source.len(), 5); + } + None => panic!("the matching source was adopted"), + } + // `slow.log`'s handle was dropped with its entry, so the file can go. + std::fs::remove_file(&slow).expect("the superseded open's handle was released"); + + let _state = apply_headless(state, Command::ViewerClose, &mut rt); + assert!(rt.viewer_source.is_none()); + std::fs::remove_file(&fast).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } + + /// resize-preserves-session-state design D7 (task 3.4): the byte source + /// follows the viewer *phase*. It survives a shrink below the floor and + /// the enlarge back, and is released exactly when the viewer closes. + #[test] + fn viewer_source_follows_the_viewer_phase_across_a_resize() { + let dir = std::env::temp_dir().join(format!("filecommand-tui-app-viewer-resize-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("file.bin"); + std::fs::write(&path, b"hello world").unwrap(); + let (mut rt, rx) = headless_runtime(&dir); + + let opened = open_on_worker(&rt, &rx, 1, &path); + // A listing chunk already queued ahead of the reply is applied while + // the phase is still `Panels`: the queued hand-off must survive it. + let state = state_awaiting_open(1); + let request = state.left.listing_request.unwrap_or(0); + let state = apply_headless(state, Command::ListingChunk { panel: PanelSide::Left, entries: vec![], request }, &mut rt); + let state = apply_headless(state, opened, &mut rt); + assert!(matches!(state.phase, UiPhase::Viewer(_))); + assert!(rt.viewer_source.is_some()); + + // Shrink below the floor and enlarge: the viewer phase persists, and + // so does its source. + let state = apply_headless(state, Command::Resize(50, 12), &mut rt); + assert!(state.undersized && matches!(state.phase, UiPhase::Viewer(_))); + assert!(rt.viewer_source.is_some(), "the hidden viewer keeps its file open"); + let state = apply_headless(state, Command::Resize(80, 24), &mut rt); + assert!(!state.undersized && matches!(state.phase, UiPhase::Viewer(_))); + assert!(rt.viewer_source.is_some(), "the source is still there when the viewer reappears"); + + // Closing releases it — and only then. + let state = apply_headless(state, Command::ViewerClose, &mut rt); + assert_eq!(state.phase, UiPhase::Panels); + assert!(rt.viewer_source.is_none()); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } + + /// Design D7's second clause: a `ViewerOpened` the reducer discards + /// (here it arrives while the end-of-job summary owns the phase) must not + /// leave the worker's source behind — the hand-off entry is dropped. + #[test] + fn a_discarded_viewer_opened_releases_the_source() { + let dir = std::env::temp_dir().join(format!("filecommand-tui-app-viewer-discard-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("file.bin"); + std::fs::write(&path, b"hello world").unwrap(); + let (mut rt, rx) = headless_runtime(&dir); + + let opened = open_on_worker(&rt, &rx, 1, &path); + let mut state = state_awaiting_open(1); + state.phase = UiPhase::FileOpSummary(vec![]); + let state = apply_headless(state, opened, &mut rt); + assert!(!matches!(state.phase, UiPhase::Viewer(_)), "the summary dialog swallowed the reply"); + assert!(rt.viewer_source.is_none()); + assert!(rt.viewer_handoff.1.try_recv().is_err(), "the hand-off channel was drained"); + std::fs::remove_file(&path).expect("the discarded reply's handle was released"); + std::fs::remove_dir(&dir).unwrap(); + } + + /// Regression test for the F4 external editor being silently dead: a + /// config-driven startup (via `config::parse`, the real parsing path, + /// not a hand-built `Config`) must populate `state.editor`, mirroring + /// the already-wired `state.shell`. Before this fix, `run()` never + /// assigned `state.editor` at all, so `editor =` in `config.toml` was + /// parsed correctly but never reached `State`. + #[test] + fn apply_config_wires_the_configured_editor_and_shell_into_state() { + let config = config::parse("editor = \"code --wait\"\nshell = \"pwsh -NoLogo -Command\"\n").config; + let mut state = State::empty(Theme::classic()); + assert_eq!(state.editor, None, "sanity: editor starts unset before wiring"); + apply_config(&mut state, &config); + assert_eq!(state.editor.as_deref(), Some("code --wait")); + assert_eq!(state.shell.shell.as_deref(), Some("pwsh -NoLogo -Command")); + } + + #[test] + fn apply_config_wires_the_configured_panel_split_into_state() { + let config = config::parse("panel_split = 66\n").config; + let mut state = State::empty(Theme::classic()); + assert_eq!(state.split_percent, 50, "sanity: split starts at the default before wiring"); + apply_config(&mut state, &config); + assert_eq!(state.split_percent, 66); } #[test] fn apply_config_leaves_editor_unset_when_config_omits_it() { - let config = config::parse("splash = false\n"); + let config = config::parse("splash = false\n").config; let mut state = State::empty(Theme::classic()); apply_config(&mut state, &config); assert_eq!(state.editor, None); } + /// ui-animations "Animations are disabled by config or launch flag": + /// `State::empty` starts with animations off (so existing tests are + /// unaffected); a real launch turns them on from the default config. + #[test] + fn apply_config_turns_animations_on_by_default_and_off_when_configured() { + let mut state = State::empty(Theme::classic()); + assert!(!state.animations, "sanity: a state built without config does not animate"); + apply_config(&mut state, &config::parse("").config); + assert!(state.animations, "default is animated"); + apply_config(&mut state, &config::parse("animations = false\n").config); + assert!(!state.animations, "animations = false disables them"); + } + + /// ui-animations "Flag overrides config": `--noanimations` wins over + /// `animations = true`, and without the flag the config value stands. + #[test] + fn noanimations_flag_overrides_the_config_value() { + let on = config::parse("animations = true\n").config; + let mut state = State::empty(Theme::classic()); + apply_config(&mut state, &on); + apply_launch_options(&mut state, &parse_launch_args(["--noanimations".to_string()])); + assert!(!state.animations, "the flag wins over animations = true"); + + let mut state = State::empty(Theme::classic()); + apply_config(&mut state, &on); + apply_launch_options(&mut state, &parse_launch_args(Vec::::new())); + assert!(state.animations, "no flag leaves the config value alone"); + + let mut state = State::empty(Theme::classic()); + apply_config(&mut state, &config::parse("animations = false\n").config); + apply_launch_options(&mut state, &parse_launch_args(Vec::::new())); + assert!(!state.animations, "the flag never turns animations on"); + } + + #[test] + fn parse_launch_args_reads_noanimations_alongside_other_switches() { + let opts = parse_launch_args(["--noanimations".to_string()]); + assert!(opts.no_animations); + assert!(!opts.no_splash && !opts.no_mouse); + assert!(!parse_launch_args(["--nosplash".to_string()]).no_animations, "absent switch leaves it off"); + + let mixed = parse_launch_args(["--theme=terminal-green".to_string(), "--noanimations".to_string(), "--nosplash".to_string()]); + assert!(mixed.no_animations && mixed.no_splash); + assert_eq!(mixed.theme.as_deref(), Some("terminal-green")); + } + + /// The startup warning is raised outside the reducer, so `run` stamps its + /// open time explicitly for the iris; with no warning nothing is stamped. + #[test] + fn stamp_startup_warning_records_the_clock_only_when_a_warning_is_up() { + let mut state = State::empty(Theme::classic()); + state.clock_ms = 1234; + stamp_startup_warning(&mut state); + assert_eq!(state.dialog_opened_at_ms, 0, "no warning: nothing to stamp"); + state.set_startup_warning("usermenu.toml is malformed".to_string()); + stamp_startup_warning(&mut state); + assert_eq!(state.dialog_opened_at_ms, 1234); + } + /// theme-selection "Applied theme persists to configuration" scenario /// "Unknown configured theme falls back to default": a `config.toml` /// naming a theme that isn't one of the six built-ins (typo, renamed, @@ -966,7 +2291,7 @@ mod tests { /// something else. #[test] fn resolve_startup_theme_falls_back_to_nc_classic_for_an_unknown_name() { - let config = config::parse("theme = \"no-such-theme\"\n"); + let config = config::parse("theme = \"no-such-theme\"\n").config; let (theme, warning) = resolve_startup_theme(&config, None); assert_eq!(theme.name, Theme::classic().name); assert_eq!(warning, None, "no --theme switch was passed, so no warning is raised"); @@ -977,7 +2302,7 @@ mod tests { /// unset -> load -> resolve path end to end. #[test] fn resolve_startup_theme_falls_back_to_nc_classic_when_theme_is_unset() { - let config = config::parse("splash = false\n"); + let config = config::parse("splash = false\n").config; let (theme, warning) = resolve_startup_theme(&config, None); assert_eq!(theme.name, Theme::classic().name); assert_eq!(warning, None); @@ -988,7 +2313,7 @@ mod tests { /// the fallback. #[test] fn resolve_startup_theme_loads_a_known_configured_theme() { - let config = config::parse("theme = \"terminal-green\"\n"); + let config = config::parse("theme = \"terminal-green\"\n").config; let (theme, warning) = resolve_startup_theme(&config, None); assert_eq!(theme.name, Theme::terminal_green().name); assert_eq!(warning, None); @@ -1066,7 +2391,8 @@ mod tests { /// regardless of order, just like any two independent switches. #[test] fn parse_launch_args_reads_nomouse_alongside_other_switches_regardless_of_order() { - let nomouse_first = parse_launch_args(["--nomouse".to_string(), "--nosplash".to_string(), "--theme".to_string(), "terminal-green".to_string()]); + let nomouse_first = + parse_launch_args(["--nomouse".to_string(), "--nosplash".to_string(), "--theme".to_string(), "terminal-green".to_string()]); assert!(nomouse_first.no_mouse); assert!(nomouse_first.no_splash); assert_eq!(nomouse_first.theme.as_deref(), Some("terminal-green")); @@ -1077,11 +2403,258 @@ mod tests { assert_eq!(nomouse_last.theme.as_deref(), Some("terminal-green")); } + // -- config-profile-directory "Configuration profile directory" ------- + + /// Parser: `--config-dir ` (space-separated spelling). + #[test] + fn parse_launch_args_reads_config_dir_with_a_space_separated_value() { + let opts = parse_launch_args(["--config-dir".to_string(), "D:\\fc-test".to_string()]); + assert_eq!(opts.config_dir.as_deref(), Some("D:\\fc-test")); + assert!(!opts.no_splash && !opts.no_mouse && !opts.no_animations); + assert_eq!(opts.theme, None); + } + + /// Parser: `--config-dir=` (equals-separated spelling). + #[test] + fn parse_launch_args_reads_config_dir_with_an_equals_separated_value() { + let opts = parse_launch_args(["--config-dir=relative/dir".to_string()]); + assert_eq!(opts.config_dir.as_deref(), Some("relative/dir")); + } + + /// Parser: `--config-dir` as the final argument has no value to + /// consume, recorded as an empty string (a startup warning downstream, + /// never a crash) — the same convention as `--theme` (task 1.1). + #[test] + fn parse_launch_args_records_an_empty_value_when_config_dir_has_no_following_argument() { + let opts = parse_launch_args(["--config-dir".to_string()]); + assert_eq!(opts.config_dir.as_deref(), Some("")); + } + + /// Parser: no `--config-dir` switch at all leaves the field unset, + /// distinct from the missing-value case above. + #[test] + fn parse_launch_args_leaves_config_dir_unset_when_the_switch_is_absent() { + assert_eq!(parse_launch_args(["--nosplash".to_string()]).config_dir, None); + } + + // -- error-diagnostics "Optional diagnostic log" ---------------------- + + /// Parser: `--log ` (space-separated spelling). + #[test] + fn parse_launch_args_reads_log_with_a_space_separated_value() { + let opts = parse_launch_args(["--log".to_string(), "D:\\logs\\fc.log".to_string()]); + assert_eq!(opts.log.as_deref(), Some("D:\\logs\\fc.log")); + assert!(!opts.no_splash && !opts.no_mouse && !opts.no_animations); + assert_eq!(opts.theme, None); + assert_eq!(opts.config_dir, None); + } + + /// Parser: `--log=` (equals-separated spelling). + #[test] + fn parse_launch_args_reads_log_with_an_equals_separated_value() { + let opts = parse_launch_args(["--log=fc.log".to_string()]); + assert_eq!(opts.log.as_deref(), Some("fc.log")); + } + + /// Parser: `--log` as the final argument has no value to consume, + /// recorded as an empty string (the "--log needs a path" startup warning + /// downstream, never a crash) — the same convention as `--theme` + /// (design D2). + #[test] + fn parse_launch_args_records_an_empty_value_when_log_has_no_following_argument() { + let opts = parse_launch_args(["--log".to_string()]); + assert_eq!(opts.log.as_deref(), Some("")); + } + + /// Parser: no `--log` switch at all leaves the field unset, distinct + /// from the missing-value case above. + #[test] + fn parse_launch_args_leaves_log_unset_when_the_switch_is_absent() { + assert_eq!(parse_launch_args(["--nosplash".to_string()]).log, None); + } + + /// Design D2 / "The flag wins over the environment variable": a present + /// `--log` beats `FILECOMMAND_LOG`; with no flag the variable applies, + /// unless it is empty or unset. + #[test] + fn resolve_log_path_prefers_the_flag_over_the_environment() { + assert_eq!(resolve_log_path(Some("flag.log"), Some("env.log")), Ok(Some("flag.log".to_string()))); + assert_eq!(resolve_log_path(None, Some("env.log")), Ok(Some("env.log".to_string())), "the variable enables logging without the flag"); + assert_eq!(resolve_log_path(None, None), Ok(None), "neither set: logging stays off"); + assert_eq!(resolve_log_path(None, Some("")), Ok(None), "an empty variable counts as unset"); + assert_eq!(resolve_log_path(Some("flag.log"), None), Ok(Some("flag.log".to_string()))); + } + + /// Design D2 / "the flag is given without a path": an empty `--log` + /// value is a startup warning naming the switch, and it still wins over + /// the variable (an empty flag must not silently fall back). + #[test] + fn resolve_log_path_warns_when_the_flag_has_no_path() { + let warning = resolve_log_path(Some(""), Some("env.log")).expect_err("an empty flag value warns"); + assert!(warning.contains("--log"), "the warning names the switch: {warning}"); + assert!(warning.contains("path"), "the warning says what is missing: {warning}"); + } + + /// Design D2 / "Unwritable log path warns and continues": an + /// unopenable log path becomes a startup warning naming the path and + /// the reason it could not be opened; `None` (no log requested) stays + /// silent. The failing call never enables the global log, so this test + /// is safe for the other diag tests in the binary. + #[test] + fn init_diagnostic_log_warns_naming_path_and_reason_when_open_fails() { + let missing_dir = std::env::temp_dir().join(format!("filecommand-tui-app-log-init-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&missing_dir); + let path = missing_dir.join("fc.log"); + let warning = init_diagnostic_log(Some(path.display().to_string())).expect("an unopenable log path warns"); + assert!(warning.contains("Diagnostic log disabled"), "{warning}"); + assert!(warning.contains(&path.display().to_string()), "the warning names the path: {warning}"); + assert_eq!(init_diagnostic_log(None), None, "no log requested, no warning"); + } + + /// Task 1.1: a missing value becomes a warning and normal resolution + /// continues; a real value passes through untouched. + #[test] + fn config_dir_override_warns_on_a_missing_value_and_passes_a_real_one_through() { + let (flag, warning) = config_dir_override(Some("")); + assert_eq!(flag, None, "normal resolution continues after the warning"); + assert!(warning.as_deref().is_some_and(|w| w.contains("--config-dir")), "the warning names the switch: {warning:?}"); + + let (flag, warning) = config_dir_override(Some("D:\\fc-test")); + assert_eq!(flag.as_deref(), Some(Path::new("D:\\fc-test"))); + assert_eq!(warning, None); + + assert_eq!(config_dir_override(None), (None, None), "no switch is neither an override nor a warning"); + } + + /// Design D1: an explicit flag wins outright — the portable marker and + /// the platform default are not even consulted — and a relative value + /// resolves against the working directory at startup. + #[test] + fn resolve_profile_dir_flag_wins_over_everything() { + let exe_dir = std::env::temp_dir().join(format!("filecommand-profile-flag-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&exe_dir); + std::fs::create_dir_all(&exe_dir).unwrap(); + std::fs::write(exe_dir.join("portable"), "").unwrap(); // would win, if consulted + + let absolute = PathBuf::from(if cfg!(windows) { "D:\\fc-test" } else { "/tmp/fc-test" }); + let (dir, warning) = resolve_profile_dir(Some(absolute.clone()), Some(&exe_dir)); + assert_eq!(dir, absolute); + assert_eq!(warning, None); + + let cwd = std::env::current_dir().unwrap(); + let (dir, warning) = resolve_profile_dir(Some(PathBuf::from("fc-rel")), Some(&exe_dir)); + assert_eq!(dir, cwd.join("fc-rel"), "a relative flag value resolves against the cwd"); + assert_eq!(warning, None); + let _ = std::fs::remove_dir_all(&exe_dir); + } + + /// Design D1 / scenario "Portable mode via marker file": with no flag, + /// a `portable` marker beside the executable makes the executable's + /// directory the profile, beating the platform default. + #[test] + fn resolve_profile_dir_portable_marker_wins_over_the_platform_default() { + let exe_dir = std::env::temp_dir().join(format!("filecommand-profile-portable-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&exe_dir); + std::fs::create_dir_all(&exe_dir).unwrap(); + std::fs::write(exe_dir.join("portable"), "").unwrap(); + + let (dir, warning) = resolve_profile_dir(None, Some(&exe_dir)); + assert_eq!(dir, exe_dir); + assert_eq!(warning, None); + let _ = std::fs::remove_dir_all(&exe_dir); + } + + /// Design D1 / scenario "Platform default is used when nothing + /// overrides it": no flag and no marker file means the platform + /// default applies (asserted per-platform just below). + #[test] + fn resolve_profile_dir_without_a_marker_uses_the_platform_default() { + let exe_dir = std::env::temp_dir().join(format!("filecommand-profile-nomarker-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&exe_dir); + std::fs::create_dir_all(&exe_dir).unwrap(); + + let (dir, warning) = resolve_profile_dir(None, Some(&exe_dir)); + assert_eq!(dir, platform_default_profile_dir().expect("a config home is set on this test host")); + assert_eq!(warning, None); + let _ = std::fs::remove_dir_all(&exe_dir); + } + + /// Design D1: the platform default is `%APPDATA%\FileCommand`. + #[cfg(windows)] + #[test] + fn platform_default_profile_dir_is_appdata_filecommand() { + let appdata = std::env::var_os("APPDATA").expect("APPDATA is set on Windows hosts"); + assert_eq!(platform_default_profile_dir(), Some(PathBuf::from(appdata).join("FileCommand"))); + } + + /// Design D1: the platform default is `$XDG_CONFIG_HOME/filecommand`, + /// else `~/.config/filecommand`. + #[cfg(not(windows))] + #[test] + fn platform_default_profile_dir_is_xdg_or_home_filecommand() { + let expected = std::env::var_os("XDG_CONFIG_HOME") + .map(|xdg| PathBuf::from(xdg).join("filecommand")) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("filecommand"))) + .expect("XDG_CONFIG_HOME or HOME is set on Unix hosts"); + assert_eq!(platform_default_profile_dir(), Some(expected)); + } + + /// Design D2 / scenario "Unwritable profile falls back without losing + /// the session": an uncreatable profile yields the fail-soft warning + /// naming the path and the error; a creatable one yields none. + #[test] + fn ensure_profile_dir_warns_naming_path_and_error_when_creation_fails() { + let dir = std::env::temp_dir().join(format!("filecommand-profile-ensure-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let blocker = dir.join("blocker"); + std::fs::write(&blocker, "a file, not a directory").unwrap(); + + let target = blocker.join("sub"); + let warning = ensure_profile_dir(&target).expect("creating below a plain file must fail"); + assert!(warning.contains(&target.display().to_string()), "the warning names the path: {warning}"); + assert!(warning.contains("settings will not be saved this session"), "the warning says persistence is off: {warning}"); + + assert_eq!(ensure_profile_dir(&dir.join("fresh")), None, "a creatable directory is no warning"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Design D3 / scenario "Legacy files in the working directory produce + /// a notice, not a migration": the notice names the found files and + /// both directories, says they can be moved, and leaves them exactly + /// as they were. + #[test] + fn legacy_notice_names_the_files_and_both_directories() { + let root = std::env::temp_dir().join(format!("filecommand-profile-legacy-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let cwd = root.join("cwd"); + let profile = root.join("profile"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + std::fs::write(cwd.join("config.toml"), "theme = \"nc-mono\"\n").unwrap(); + std::fs::write(cwd.join("history.json"), "{}\n").unwrap(); + + let notice = legacy_notice(&cwd, &profile).expect("legacy files raise the notice"); + assert!(notice.contains("config.toml, history.json"), "names the found files: {notice}"); + assert!(notice.contains(&cwd.display().to_string()), "names the working directory: {notice}"); + assert!(notice.contains(&profile.display().to_string()), "names the profile directory: {notice}"); + assert!(notice.contains("Move them there"), "says the files can be moved: {notice}"); + + // Nothing was copied or moved: the old files are untouched and the + // profile stays empty. + assert!(cwd.join("config.toml").exists() && cwd.join("history.json").exists()); + assert!(!profile.join("config.toml").exists()); + + std::fs::write(profile.join("config.toml"), "").unwrap(); + assert_eq!(legacy_notice(&cwd, &profile), None, "a populated profile silences the notice"); + let _ = std::fs::remove_dir_all(&root); + } + /// theme-selection "Valid override wins over configuration": a valid /// `--theme` value beats a different `config.theme`, with no warning. #[test] fn resolve_startup_theme_override_wins_over_configuration() { - let config = config::parse("theme = \"nc-classic\"\n"); + let config = config::parse("theme = \"nc-classic\"\n").config; let (theme, warning) = resolve_startup_theme(&config, Some("yellow-storm")); assert_eq!(theme.name, Theme::yellow_storm().name); assert_eq!(warning, None, "a valid override produces no warning"); @@ -1092,7 +2665,7 @@ mod tests { /// warning naming the rejected value and listing the valid names. #[test] fn resolve_startup_theme_unknown_override_falls_back_with_a_warning() { - let config = config::parse("theme = \"nc-mono\"\n"); + let config = config::parse("theme = \"nc-mono\"\n").config; let (theme, warning) = resolve_startup_theme(&config, Some("no-such-theme")); assert_eq!(theme.name, Theme::mono().name); let warning = warning.expect("an unknown override raises a warning"); @@ -1107,7 +2680,7 @@ mod tests { /// value was missing (distinct wording from the unknown-name case). #[test] fn resolve_startup_theme_missing_value_falls_back_with_a_warning() { - let config = config::parse("theme = \"nc-mono\"\n"); + let config = config::parse("theme = \"nc-mono\"\n").config; let (theme, warning) = resolve_startup_theme(&config, Some("")); assert_eq!(theme.name, Theme::mono().name); let warning = warning.expect("a missing value raises a warning"); @@ -1121,15 +2694,15 @@ mod tests { /// untouched (design D4). #[test] fn picker_apply_persists_normally_during_an_overridden_session() { - let config = config::parse("theme = \"nc-classic\"\n"); + let config = config::parse("theme = \"nc-classic\"\n").config; let (theme, warning) = resolve_startup_theme(&config, Some("terminal-green")); assert_eq!(theme.name, Theme::terminal_green().name); assert_eq!(warning, None); - let (state, _) = State::initial(theme, (80, 24), 0, PathBuf::from("."), PathBuf::from("."), false); + let (state, _) = State::initial(theme, (80, 24), 0, 0, PathBuf::from("."), PathBuf::from("."), false); let (state, _) = update(state, Command::ThemePickerOpen); let target = BUILTIN_THEME_NAMES.iter().position(|n| *n == "purple-lights").unwrap(); - let current = state.theme_picker.as_ref().unwrap().highlight; + let current = state.theme_picker().unwrap().highlight; let (state, _) = update(state, Command::ThemePickerMove(target as isize - current as isize)); let (state, effects) = update(state, Command::ThemePickerConfirm); assert_eq!(state.theme.name, "purple-lights", "the picker's choice wins, not the launch override"); @@ -1140,7 +2713,7 @@ mod tests { /// `usermenu.toml` used to only set the left panel's inline /// `last_error` (with a comment noting there was no startup-warning /// dialog); the spec requires a dismissable modal instead. Verifies - /// `apply_user_menu` raises `state.startup_warning`, falls back to + /// `apply_user_menu` raises `state.startup_warning()`, falls back to /// default entries, and never touches either panel's `last_error`. #[test] fn apply_user_menu_raises_the_startup_warning_dialog_on_malformed_content() { @@ -1151,13 +2724,14 @@ mod tests { warnings: Vec::new(), created_default: false, malformed: true, + problem: Some(config::UserMenuProblem::InvalidToml), }; apply_user_menu(&mut state, malformed); assert_eq!(state.user_menu_entries, default_entries, "falls back to default entries"); assert!( - state.startup_warning.as_deref().is_some_and(|m| m.contains(config::USERMENU_FILE)), + state.startup_warning().is_some_and(|m| m.contains(config::USERMENU_FILE)), "expected a startup warning naming the malformed file: {:?}", - state.startup_warning + state.startup_warning() ); assert_eq!(state.left.last_error, None, "the warning is a dedicated modal, not a panel mini-status"); assert_eq!(state.right.last_error, None); @@ -1167,15 +2741,150 @@ mod tests { fn apply_user_menu_does_not_warn_on_well_formed_content() { let mut state = State::empty(Theme::classic()); let entries = config::default_user_menu_entries(); - let ok = config::UserMenuLoadResult { entries: entries.clone(), warnings: Vec::new(), created_default: false, malformed: false }; + let ok = + config::UserMenuLoadResult { entries: entries.clone(), warnings: Vec::new(), created_default: false, malformed: false, problem: None }; apply_user_menu(&mut state, ok); assert_eq!(state.user_menu_entries, entries); - assert_eq!(state.startup_warning, None); + assert_eq!(state.startup_warning(), None); + } + + /// configuration-files "Non-UTF-8 usermenu.toml is kept, not + /// overwritten": the not-UTF-8 outcome gets its own message that says + /// how to recover, instead of the generic malformed one (design D3). + #[test] + fn apply_user_menu_names_the_not_utf8_recovery_for_a_non_utf8_file() { + let mut state = State::empty(Theme::classic()); + let result = config::UserMenuLoadResult { + entries: config::default_user_menu_entries(), + warnings: Vec::new(), + created_default: false, + malformed: true, + problem: Some(config::UserMenuProblem::NotUtf8), + }; + apply_user_menu(&mut state, result); + let warning = state.startup_warning().expect("a not-UTF-8 user menu raises the dialog"); + assert!(warning.starts_with("usermenu.toml is not UTF-8 text"), "{warning}"); + assert!(warning.contains("re-save it as UTF-8"), "{warning}"); + } + + /// history-persistence-hardening design D1: the renderer ranks the + /// Ctrl+J list with `state.epoch_ms`, exactly as the reducer's Enter + /// does, so the first drawn row is the row Enter navigates to even when + /// the monotonic and wall clocks differ by decades. + #[test] + fn fuzzy_jump_renderer_and_reducer_rank_with_the_same_epoch_clock() { + use filecommand_core::quicksearch::FrecencyEntry; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + let e = 1_787_000_000_000u64; + let day = 24 * 60 * 60 * 1000; + let mut state = State::empty(Theme::classic()); + state.clock_ms = 500; + state.epoch_ms = e + 3 * day + 60_000; + state.dir_history = vec![ + FrecencyEntry { path: PathBuf::from(r"C:\old"), visit_count: 3, last_visited_ms: e }, + FrecencyEntry { path: PathBuf::from(r"C:\new"), visit_count: 3, last_visited_ms: e + 3 * day }, + ]; + let (state, _) = update(state, Command::FuzzyJumpOpen); + + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let mut buf = Buffer::empty(area); + views::fuzzy_jump::render_fuzzy_jump( + &mut buf, + area, + &state.theme, + ColorDepth::Ansi16, + state.fuzzy_jump().unwrap(), + &state.dir_history, + state.epoch_ms, + ); + let first_row = (0..area.height) + .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::()) + .find(|row| row.contains("C:\\")) + .expect("the list draws at least one directory row"); + assert!(first_row.contains(r"C:\new"), "the renderer draws the more recently visited directory first: {first_row:?}"); + + let (state, effects) = update(state, Command::FuzzyJumpConfirm); + assert_eq!(state.left.cwd, PathBuf::from(r"C:\new"), "Enter lands on the row that was drawn first"); + assert!(effects.iter().any(|e| matches!(e, Effect::StartListing { path, .. } if path == Path::new(r"C:\new")))); + } + + // -- configuration-files: the warning summary and its precedence ------ + + /// The config.toml warnings come first, then usermenu.toml's, and the + /// dialog shows the first plus a `(+N more)` count across both files + /// (configuration-files "Several warnings are summarized"; design D4). + #[test] + fn configuration_warnings_summarize_across_both_files() { + let mut state = State::empty(Theme::classic()); + let parsed = config::parse("colour = \"red\"\nsplash false\n"); + let menu = config::parse_usermenu("[[entry]]\nlabel = \"A\"\ncommand = \"a\"\nicon = \"x\"\n"); + let mut warnings: Vec = parsed.warnings.iter().map(ToString::to_string).collect(); + warnings.extend(menu.warnings.iter().map(ToString::to_string)); + assert_eq!(warnings.len(), 3, "{warnings:?}"); + apply_configuration_warnings(&mut state, &warnings); + let shown = state.startup_warning().expect("the summary is shown"); + assert!(shown.starts_with("config.toml line 1: unknown setting \"colour\""), "{shown}"); + assert!(shown.ends_with("(+2 more)"), "{shown}"); + } + + /// configuration-files "Profile warning keeps precedence": an existing + /// startup warning (here the unwritable-profile one) is never replaced + /// by the configuration summary. + #[test] + fn configuration_warnings_never_displace_an_existing_startup_warning() { + let mut state = State::empty(Theme::classic()); + let profile_warning = "Could not create the profile directory C:\\nope: access denied; settings will not be saved".to_string(); + set_startup_warning(&mut state, profile_warning.clone()); + let warnings = vec!["config.toml line 2: unknown setting \"colour\"; ignored".to_string()]; + apply_configuration_warnings(&mut state, &warnings); + assert_eq!(state.startup_warning(), Some(profile_warning.as_str())); + } + + /// configuration-files "Theme override warning keeps precedence": the + /// `--theme` warning wins over an unknown-setting summary; so does a + /// malformed `usermenu.toml`. + #[test] + fn theme_override_and_malformed_user_menu_warnings_outrank_the_summary() { + let config = config::parse("colour = \"red\"\n"); + let summary: Vec = config.warnings.iter().map(ToString::to_string).collect(); + + let mut state = State::empty(Theme::classic()); + let (_, theme_warning) = resolve_startup_theme(&config.config, Some("no-such-theme")); + set_startup_warning(&mut state, theme_warning.expect("unknown --theme warns")); + apply_configuration_warnings(&mut state, &summary); + let shown = state.startup_warning().unwrap().to_string(); + assert!(shown.contains("--theme no-such-theme"), "{shown}"); + + let mut state = State::empty(Theme::classic()); + let malformed = config::UserMenuLoadResult { + entries: config::default_user_menu_entries(), + warnings: Vec::new(), + created_default: false, + malformed: true, + problem: Some(config::UserMenuProblem::InvalidToml), + }; + apply_user_menu(&mut state, malformed); + apply_configuration_warnings(&mut state, &summary); + let shown = state.startup_warning().unwrap(); + assert!(shown.contains("usermenu.toml is malformed"), "{shown}"); + } + + /// configuration-files "Clean configuration produces no warning". + #[test] + fn no_configuration_warnings_leave_the_startup_warning_alone() { + let mut state = State::empty(Theme::classic()); + apply_configuration_warnings(&mut state, &[]); + assert_eq!(state.startup_warning(), None); + let parsed = config::parse("splash = false # c\ntheme = \"nc-mono\" # c\n[mouse] # c\nenabled = true\n"); + assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings); } #[test] fn spawn_editor_and_wait_succeeds_for_a_real_program() { - assert_eq!(spawn_editor_and_wait(&stub_invocation("0")), Ok(())); + let _serial = console::FLAG_LOCK.lock().unwrap(); + let mut child_run = console::ChildRunGuard::new(); + assert_eq!(spawn_editor_and_wait(&stub_invocation("0"), &mut child_run), Ok(())); } #[test] @@ -1186,11 +2895,200 @@ mod tests { file_arg: OsString::from("report.txt"), cwd: std::env::temp_dir(), }; - let result = spawn_editor_and_wait(&inv); + let _serial = console::FLAG_LOCK.lock().unwrap(); + let mut child_run = console::ChildRunGuard::new(); + let result = spawn_editor_and_wait(&inv, &mut child_run); assert!(result.is_err()); assert!(result.unwrap_err().contains("filecommand-definitely-not-a-real-editor-binary")); } + // -- process-lifecycle-hardening: quit waits for job cleanup (task 2.3, + // 2.4; application-shell "Quit waits for job cancellation cleanup"; + // design D3) --------------------------------------------------------- + + /// A `Runtime` with no terminal behind it, enough for `shutdown` and the + /// effect bodies the tests here drive. + fn test_runtime(dir: &Path) -> (Runtime, Receiver) { + let (tx, rx) = mpsc::channel(); + let rt = Runtime { + tx, + active_job: None, + cancels: CancelRegistry::default(), + history_writer: HistoryWriter::disabled(), + config_writer: ConfigWriter::disabled(), + usermenu_path: dir.join("usermenu.toml"), + viewer_source: None, + viewer_handoff: mpsc::channel(), + clipboard: Box::new(clipboard::RecordingClipboard::default()), + mouse_tracker: MouseTracker::new(), + git_slots: [worker::GitQuerySlot::new(), worker::GitQuerySlot::new()], + workers: worker::WorkerGauge::new(), + held: None, + }; + (rt, rx) + } + + /// A stand-in job thread: `body` runs with the handle's cancel flag. + fn fake_job(body: impl FnOnce(filecommand_core::fs_ops::CancelFlag) + Send + 'static) -> worker::JobHandle { + let cancel = filecommand_core::fs_ops::CancelFlag::new(); + let (reply_tx, _reply_rx) = mpsc::channel(); + let thread_cancel = cancel.clone(); + let thread = std::thread::spawn(move || body(thread_cancel)); + worker::JobHandle { cancel, reply_tx, thread } + } + + #[test] + fn shutdown_waits_for_a_job_that_finishes_its_cleanup_after_the_cancel() { + // The thread notices the flag, then takes 300 ms to clean up (the + // partial-file removal) before ending: quit returns only after that. + let job = fake_job(|cancel| { + while !cancel.is_cancelled() { + std::thread::sleep(Duration::from_millis(5)); + } + std::thread::sleep(Duration::from_millis(300)); + }); + let started = Instant::now(); + assert!(shutdown_job(job, QUIT_JOB_WAIT), "the thread ended within the bound"); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(300), "returned only after the cleanup finished, took {elapsed:?}"); + assert!(elapsed < QUIT_JOB_WAIT, "and well before the bound, took {elapsed:?}"); + } + + #[test] + fn shutdown_gives_up_on_a_job_that_never_finishes_after_about_two_seconds() { + // Scenario "The wait is bounded": a write blocked on an unresponsive + // share never returns; quit exits anyway after `QUIT_JOB_WAIT`. + let job = fake_job(|_cancel| std::thread::sleep(Duration::from_secs(20))); + let started = Instant::now(); + assert!(!shutdown_job(job, QUIT_JOB_WAIT), "abandoned at the bound"); + let elapsed = started.elapsed(); + assert!(elapsed >= QUIT_JOB_WAIT, "took {elapsed:?}"); + assert!(elapsed < QUIT_JOB_WAIT + Duration::from_secs(1), "took {elapsed:?}"); + } + + #[test] + fn shutdown_with_no_job_returns_immediately() { + // Scenario "No job means no wait". + let (mut rt, _rx) = test_runtime(&std::env::temp_dir()); + let started = Instant::now(); + shutdown(&mut rt).unwrap(); + assert!(started.elapsed() < Duration::from_millis(100), "took {:?}", started.elapsed()); + } + + #[test] + fn shutdown_joins_a_job_that_has_already_finished_without_waiting() { + let job = fake_job(|_cancel| {}); + // Let the thread end first. + while !job.thread.is_finished() { + std::thread::yield_now(); + } + let started = Instant::now(); + assert!(shutdown_job(job, QUIT_JOB_WAIT)); + assert!(started.elapsed() < Duration::from_millis(100)); + } + + /// Scenario "Quit during a large copy leaves no partial file" (task + /// 2.4), end to end through the real `spawn_job` and `RealFs`: quit + /// confirmed mid-copy sets the flag, the engine's cancel path removes + /// the partially written destination, and `shutdown` returns only once + /// it has. The copy source is a sparse 256 MB file so the test is quick + /// to set up while the copy still takes long enough to be interrupted. + #[cfg(windows)] + #[test] + fn shutdown_mid_copy_leaves_no_partial_destination_file() { + use filecommand_core::fs_ops::{Job, JobKind, JobOutcome, SourceItem}; + + let dir = std::env::temp_dir().join(format!("filecommand-tui-app-quit-copy-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let src_dir = dir.join("src"); + let dst_dir = dir.join("dst"); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::create_dir_all(&dst_dir).unwrap(); + let src = src_dir.join("big.bin"); + std::fs::File::create(&src).unwrap().set_len(256 * 1024 * 1024).unwrap(); + let dst = dst_dir.join("big.bin"); + + let (mut rt, rx) = test_runtime(&dir); + let job = Job { + kind: JobKind::Copy, + sources: vec![SourceItem { original_name: OsString::from("big.bin"), path: src.clone(), is_dir: false }], + source_dir: src_dir.clone(), + dest_dir: dst_dir.clone(), + new_dir_name: None, + }; + rt.active_job = Some(worker::spawn_job(job, rt.tx.clone(), rt.workers.token())); + + // Mid-copy: since durable-file-copy the engine writes a sibling + // `.~fc-partial` file and renames it over the target only once + // the copy is complete, so the partial file is what shows the copy + // is in progress. If the final target appears first the copy already + // finished and this run cannot exercise the scenario. + let partial = dst_dir.join(format!("big.bin{}", filecommand_core::fs_ops::PARTIAL_SUFFIX)); + let deadline = Instant::now() + Duration::from_secs(30); + while !partial.exists() { + assert!(!dst.exists(), "the copy finished before it could be interrupted; enlarge the source"); + assert!(Instant::now() < deadline, "the copy never started"); + std::thread::sleep(Duration::from_millis(1)); + } + + let started = Instant::now(); + shutdown(&mut rt).unwrap(); + let elapsed = started.elapsed(); + + assert!(!partial.exists(), "the partially written destination was removed before quit returned"); + assert!(!dst.exists(), "an interrupted copy never lands on the final name"); + assert!(elapsed < QUIT_JOB_WAIT, "the job ended within the bound rather than being abandoned, took {elapsed:?}"); + let outcomes: Vec = rx.try_iter().filter(|c| matches!(c, Command::JobDone { .. })).collect(); + assert!( + matches!(outcomes.as_slice(), [Command::JobDone { outcome: JobOutcome::Cancelled { .. }, .. }]), + "the job reported itself cancelled: {outcomes:?}" + ); + assert!(std::fs::metadata(&src).is_ok(), "the source is untouched"); + let _ = std::fs::remove_dir_all(&dir); + } + + // -- error-diagnostics: status-line errors name the item (task 4.3) --- + + /// filesystem-error-handling "Error messages name the affected path": + /// the viewer and editor open failures read `Cannot open : ` (scenario "Viewer open failure names the file"). + #[test] + fn cannot_message_formats_the_viewer_and_editor_open_failure() { + let err = io::Error::new(io::ErrorKind::PermissionDenied, "Access is denied. (os error 5)"); + let path = Path::new("dir").join("locked.db"); + assert_eq!(worker::cannot_message("open", &path, &err), "Cannot open locked.db: Access is denied. (os error 5)"); + } + + /// Scenario "Editor save failure names the file": `Cannot save : + /// `, where the reason may be the editor's own refusal text as + /// well as an OS message. + #[test] + fn cannot_message_formats_the_editor_save_failure() { + let err = io::Error::new(io::ErrorKind::StorageFull, "There is not enough space on the disk. (os error 112)"); + let path = Path::new("dir").join("notes.txt"); + assert_eq!(worker::cannot_message("save", &path, &err), "Cannot save notes.txt: There is not enough space on the disk. (os error 112)"); + // A non-`io::Error` reason (the editor's own refusal) formats the + // same way around it. + assert_eq!( + worker::cannot_message("save", Path::new("notes.txt"), "notes.txt is read-only — not saved"), + "Cannot save notes.txt: notes.txt is read-only — not saved" + ); + } + + /// open-with-associated-app design D1 / file-action-menu "Open with the + /// associated application": the `ShellExecuteW` error-code mapping — + /// 31 (`SE_ERR_NOASSOC`) names the missing association, 2 + /// (`ERROR_FILE_NOT_FOUND`) the missing target, anything else reports + /// the raw code. Pure-value test; the constants' values are pinned by + /// the Win32 API. + #[cfg(windows)] + #[test] + fn open_with_association_error_maps_the_known_shell_execute_codes() { + assert_eq!(open_with_association_error("report.xyz", 31), "No application is associated with report.xyz"); + assert_eq!(open_with_association_error("report.xyz", 2), "report.xyz not found"); + assert_eq!(open_with_association_error("report.xyz", 5), "Could not open report.xyz (error 5)"); + } + #[test] fn scroll_lines_in_hex_mode_moves_by_whole_rows_and_never_underflows() { let viewer = { @@ -1233,4 +3131,340 @@ mod tests { f.flush().unwrap(); ByteSource::open(&path).unwrap() } + + // ----------------------------------------------------------------- + // idle-aware-event-loop: the poll-timeout choice (design D2/D3; task + // 2.3) and the key-burst drain (design D5; task 2.7) + // ----------------------------------------------------------------- + + #[test] + fn next_timeout_is_zero_with_a_drain_backlog() { + assert_eq!(next_timeout(1_000, Some(1_033), Duration::from_millis(60_000), true, true), Duration::ZERO); + assert_eq!(next_timeout(1_000, None, Duration::from_millis(60_000), false, true), Duration::ZERO); + } + + #[test] + fn next_timeout_is_capped_at_the_poll_interval_while_workers_are_alive() { + // No deadlines at all: the worker cadence governs (design D3). + assert_eq!(next_timeout(1_000, None, Duration::from_millis(60_000), true, false), POLL_INTERVAL); + // A nearer deadline wins over the cap; a farther one does not raise it. + assert_eq!(next_timeout(1_000, Some(1_010), Duration::from_millis(60_000), true, false), Duration::from_millis(10)); + assert_eq!(next_timeout(1_000, Some(5_000), Duration::from_millis(60_000), true, false), POLL_INTERVAL); + } + + #[test] + fn next_timeout_is_the_earliest_deadline_with_no_workers() { + // The minute boundary alone bounds the sleep (application-shell + // "Header clock still advances while idle"). + assert_eq!(next_timeout(1_000, None, Duration::from_millis(42_000), false, false), Duration::from_millis(42_000)); + // A core deadline ahead of it wins. + assert_eq!(next_timeout(1_000, Some(1_033), Duration::from_millis(42_000), false, false), Duration::from_millis(33)); + // A deadline already in the past is due now. + assert_eq!(next_timeout(1_000, Some(900), Duration::from_millis(42_000), false, false), Duration::ZERO); + } + + // ----------------------------------------------------------------- + // streaming-listing-performance: the budgeted drain (design D7; task 5.3) + // ----------------------------------------------------------------- + + fn tick(n: u64) -> Command { + Command::Tick { now_ms: n, epoch_ms: 0 } + } + + fn tick_value(cmd: &Command) -> u64 { + match cmd { + Command::Tick { now_ms, .. } => *now_ms, + other => panic!("expected a Tick, got {other:?}"), + } + } + + /// A clock that never advances: only the message budget governs. + fn frozen_clock() -> impl Fn() -> Instant { + let t = Instant::now(); + move || t + } + + #[test] + fn a_prefilled_channel_drains_in_budgeted_slices_in_order_without_loss() { + let (tx, rx) = std::sync::mpsc::channel(); + for i in 0..1_000u64 { + tx.send(tick(i)).unwrap(); + } + let clock = frozen_clock(); + let mut held = None; + let mut applied: Vec = Vec::new(); + let mut slices = Vec::new(); + loop { + let before = applied.len(); + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, cmd| { + applied.push(tick_value(&cmd)); + Ok((s, false)) + }) + .unwrap(); + slices.push(applied.len() - before); + assert!(outcome.applied); + if !outcome.backlog { + break; + } + assert!(held.is_some(), "a backlog always comes with the message the budget check took"); + } + assert_eq!(applied, (0..1_000).collect::>(), "order preserved, nothing lost or duplicated"); + assert_eq!(slices.len(), 16, "15 full slices of 64 and the remainder"); + assert!(slices[..15].iter().all(|&n| n == DRAIN_BUDGET_MESSAGES), "{slices:?}"); + assert_eq!(slices[15], 1_000 - 15 * DRAIN_BUDGET_MESSAGES); + assert!(held.is_none(), "nothing is held once the channel is drained"); + } + + #[test] + fn the_held_message_is_applied_first_by_the_next_drain() { + let (tx, rx) = std::sync::mpsc::channel(); + for i in 0..70u64 { + tx.send(tick(i)).unwrap(); + } + let clock = frozen_clock(); + let mut held = None; + let mut applied: Vec = Vec::new(); + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, cmd| { + applied.push(tick_value(&cmd)); + Ok((s, false)) + }) + .unwrap(); + assert_eq!(outcome, DrainOutcome { applied: true, backlog: true }); + assert_eq!(applied.len(), 64); + assert_eq!(held.as_ref().map(tick_value), Some(64), "the 65th message was taken by the budget check and parked"); + + // More arrive behind it; the parked one still goes first. + tx.send(tick(70)).unwrap(); + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, cmd| { + applied.push(tick_value(&cmd)); + Ok((s, false)) + }) + .unwrap(); + assert_eq!(outcome, DrainOutcome { applied: true, backlog: false }); + assert_eq!(applied[64..], [64, 65, 66, 67, 68, 69, 70]); + assert!(held.is_none()); + } + + #[test] + fn the_time_budget_stops_a_drain_and_holds_the_message_it_took() { + // The clock advances 5 ms per reading: the start reading, then one + // per message taken — the first message is checked at +5 ms (within + // 8 ms) and applied, the second at +10 ms is parked. + let (tx, rx) = std::sync::mpsc::channel(); + for i in 0..10u64 { + tx.send(tick(i)).unwrap(); + } + let t0 = Instant::now(); + let reads = std::cell::Cell::new(0u32); + let clock = move || { + reads.set(reads.get() + 1); + t0 + Duration::from_millis(5) * reads.get() + }; + let mut held = None; + let mut applied: Vec = Vec::new(); + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, cmd| { + applied.push(tick_value(&cmd)); + Ok((s, false)) + }) + .unwrap(); + assert_eq!(outcome, DrainOutcome { applied: true, backlog: true }); + assert_eq!(applied, vec![0]); + assert_eq!(held.as_ref().map(tick_value), Some(1)); + } + + #[test] + fn an_empty_channel_reports_nothing_applied_and_no_backlog() { + let (_tx, rx) = std::sync::mpsc::channel::(); + let clock = frozen_clock(); + let mut held = None; + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, _| Ok((s, false))).unwrap(); + assert_eq!(outcome, DrainOutcome::default()); + assert!(held.is_none()); + } + + #[test] + fn a_quit_ends_the_drain_without_a_backlog_claim() { + let (tx, rx) = std::sync::mpsc::channel(); + for i in 0..5u64 { + tx.send(tick(i)).unwrap(); + } + let clock = frozen_clock(); + let mut held = None; + let mut applied: Vec = Vec::new(); + let ((), outcome) = drain_budgeted((), &rx, &mut held, &clock, |s, cmd| { + let v = tick_value(&cmd); + applied.push(v); + Ok((s, v == 2)) + }) + .unwrap(); + assert_eq!(applied, vec![0, 1, 2]); + assert_eq!(outcome, DrainOutcome { applied: true, backlog: false }); + assert!(held.is_none()); + } + + /// A scripted `EventSource`: `poll` reports "ready" while the queue is + /// non-empty, `read` pops it. + struct ScriptedEvents { + queue: std::collections::VecDeque, + } + + impl ScriptedEvents { + fn of(events: impl IntoIterator) -> ScriptedEvents { + ScriptedEvents { queue: events.into_iter().collect() } + } + + fn remaining(&self) -> usize { + self.queue.len() + } + } + + impl EventSource for ScriptedEvents { + fn poll(&mut self, _timeout: Duration) -> io::Result { + Ok(!self.queue.is_empty()) + } + fn read(&mut self) -> io::Result { + Ok(self.queue.pop_front().expect("poll reported an event ready")) + } + } + + /// A `BurstHandler` that records the mapped commands and applies them + /// through the real reducer — no terminal, and effects reduced to the + /// quit flag (a headless test cannot run a `TerminalGuard`). + struct RecordingHandler { + keys: config::Keys, + applied: Vec, + } + + impl BurstHandler for RecordingHandler { + fn map_key(&mut self, key: KeyEvent, state: &State) -> Option { + map_pressed_key(key, state, &self.keys, None) + } + fn apply(&mut self, state: State, cmd: Command) -> io::Result<(State, bool)> { + self.applied.push(cmd.clone()); + let (state, effects) = update(state, cmd); + Ok((state, effects.contains(&Effect::Quit))) + } + } + + fn press(code: KeyCode) -> Event { + Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + /// The pre-burst-loop behavior, as the reference the burst must match: + /// map each key against the state the previous one produced, apply, + /// repeat (application-shell "Bursts of key input render once": + /// "Applying a burst SHALL produce the same final state as applying the + /// same keys one at a time"). + fn apply_one_at_a_time(mut state: State, codes: &[KeyCode]) -> (State, Vec) { + let mut applied = Vec::new(); + for &code in codes { + if let Some(cmd) = map_pressed_key(KeyEvent::new(code, KeyModifiers::NONE), &state, &config::Keys::default(), None) { + applied.push(cmd.clone()); + let (s, _) = update(state, cmd); + state = s; + } + } + (state, applied) + } + + fn burst(initial: State, first: KeyCode, rest: Vec) -> (State, bool, bool, Vec, ScriptedEvents, Option) { + let mut events = ScriptedEvents::of(rest); + let mut pending_event = None; + let mut handler = RecordingHandler { keys: config::Keys::default(), applied: Vec::new() }; + let (state, quit, applied) = + drain_key_burst(KeyEvent::new(first, KeyModifiers::NONE), initial, &mut events, &mut pending_event, &mut handler).unwrap(); + (state, quit, applied, handler.applied, events, pending_event) + } + + #[test] + fn a_held_page_down_burst_applies_every_key_and_draws_once() { + // application-shell "Holding PgDn renders one frame per burst": five + // queued PgDn presses apply five page movements, the queue empties + // in one drain (one loop iteration, hence one draw), and the final + // state matches one-at-a-time application. + let mut state = State::empty(Theme::classic()); + state.term_size = (80, 24); + let entries: Vec<_> = (0..500) + .map(|i| filecommand_core::listing::Entry::new(format!("f{i:03}.txt"), filecommand_core::listing::EntryKind::File, 1, None, false, false)) + .collect(); + state.left.listing_request = Some(1); + let (s, _) = update(state, Command::ListingChunk { panel: PanelSide::Left, entries, request: 1 }); + state = s; + + let keys = [KeyCode::PageDown; 5]; + let (burst_state, quit, applied, commands, events, pending) = burst(state.clone(), keys[0], keys[1..].iter().map(|&c| press(c)).collect()); + assert!(!quit); + assert!(applied, "the burst applied commands, so the loop draws once after it"); + assert_eq!(events.remaining(), 0, "the whole burst drained in one call"); + assert!(pending.is_none()); + assert_eq!(commands.len(), 5, "every queued key was applied"); + + let (reference_state, reference_commands) = apply_one_at_a_time(state, &keys); + assert_eq!(commands, reference_commands); + assert_eq!(burst_state, reference_state, "burst and one-at-a-time produce the same state"); + assert!(burst_state.left.cursor > 0, "the cursor actually paged down"); + } + + #[test] + fn burst_routing_follows_the_state_each_key_produces() { + // application-shell "Routing follows the state each key produces": + // F7 opens the Make-directory dialog, `new` types into its field, + // Enter confirms it — exactly as if the keys had been pressed + // slowly. Mapping the burst against the *original* state would + // route the characters to the command line and Enter to + // `Command::Enter` instead of `FileOpConfirm`. + let state = State::empty(Theme::classic()); + let rest = vec![press(KeyCode::Char('n')), press(KeyCode::Char('e')), press(KeyCode::Char('w')), press(KeyCode::Enter)]; + let (burst_state, quit, applied, commands, events, pending) = burst(state.clone(), KeyCode::F(7), rest); + assert!(!quit && applied && pending.is_none() && events.remaining() == 0); + assert_eq!( + commands, + vec![ + Command::RequestMkdir, + Command::TextEdit(filecommand_core::line_edit::TextEditOp::Insert('n')), + Command::TextEdit(filecommand_core::line_edit::TextEditOp::Insert('e')), + Command::TextEdit(filecommand_core::line_edit::TextEditOp::Insert('w')), + Command::FileOpConfirm, + ], + "each key mapped against the state the previous one produced" + ); + let (reference_state, _) = + apply_one_at_a_time(state, &[KeyCode::F(7), KeyCode::Char('n'), KeyCode::Char('e'), KeyCode::Char('w'), KeyCode::Enter]); + assert_eq!(burst_state, reference_state); + assert!( + matches!( + burst_state.phase, + UiPhase::FileOpSetup(filecommand_core::fs_ops::dialog::FileOpSetup::DestinationInput { .. }) | UiPhase::FileOpRunning { .. } + ), + "the dialog opened and was confirmed: {:?}", + burst_state.phase + ); + } + + #[test] + fn a_burst_past_the_event_cap_stops_and_draws_before_continuing() { + // application-shell "A paste delivered as key events renders once + // per burst": 70 queued characters apply 64, then the drain returns + // so the loop draws before the remaining 6 are read next iteration. + let state = State::empty(Theme::classic()); + let rest: Vec = (0..69).map(|_| press(KeyCode::Char('x'))).collect(); + let (state, quit, applied, commands, events, _) = burst(state, KeyCode::Char('x'), rest); + assert!(!quit && applied); + assert_eq!(commands.len(), BURST_MAX_EVENTS, "the burst stops at the cap"); + assert_eq!(events.remaining(), 70 - BURST_MAX_EVENTS); + assert_eq!(state.command_line.text().len(), BURST_MAX_EVENTS, "all 64 characters landed in the command line"); + } + + #[test] + fn a_mouse_event_ends_the_burst_and_is_stashed_for_the_next_iteration() { + // design D5: the mouse-batch path takes the stashed event next + // iteration. + let state = State::empty(Theme::classic()); + let mouse = Event::Mouse(crossterm::event::MouseEvent { kind: MouseEventKind::ScrollDown, column: 5, row: 5, modifiers: KeyModifiers::NONE }); + let (_state, quit, applied, commands, events, pending) = burst(state, KeyCode::PageDown, vec![mouse]); + assert!(!quit && applied); + assert_eq!(commands.len(), 1, "only the first key applied before the mouse event"); + assert_eq!(events.remaining(), 0); + assert!(matches!(pending, Some(Event::Mouse(_))), "the mouse event is stashed, not lost or applied mid-burst"); + } } diff --git a/crates/filecommand-tui/src/clipboard.rs b/crates/filecommand-tui/src/clipboard.rs index 11596a8..b3035c7 100644 --- a/crates/filecommand-tui/src/clipboard.rs +++ b/crates/filecommand-tui/src/clipboard.rs @@ -1,5 +1,6 @@ -//! The OS clipboard write behind `Effect::SetClipboard` -//! (clipboard-export). `core::update` only ever produces a +//! The OS clipboard access behind `Effect::SetClipboard` +//! (clipboard-export) and `Effect::ReadClipboardText` (text-field-editing +//! "Paste into a text field"). `core::update` only ever produces a //! `ClipboardPayload` — which of the three payload kinds, and the resolved, //! `..`-excluded absolute paths to act on; it never touches the OS. This //! module is where that touches real Win32 (`clipboard-win`) or, off @@ -37,6 +38,13 @@ pub trait Clipboard { /// Write `text` as plain Unicode text (the Paths/Names payloads, and /// Files' non-Windows fallback). fn set_text(&self, text: &str) -> Result<(), String>; + + /// Read the clipboard's current text, if it holds any — the read half + /// of text-field-editing "Paste into a text field" (design D4), run for + /// `Effect::ReadClipboardText`. `None` means "no text available" (an + /// empty clipboard, a non-text payload, or a clipboard that could not + /// be opened): pasting is then a no-op, never an error. + fn get_text(&self) -> Option; } /// Normalise one clipboard item's path (clipboard-export "Windows @@ -70,11 +78,7 @@ pub fn paths_text(items: &[PathBuf]) -> String { /// The Names payload's text: one file name per line, no trailing separator /// after the last (clipboard-export "Clipboard payloads and scope"). pub fn names_text(items: &[PathBuf]) -> String { - items - .iter() - .map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()) - .collect::>() - .join("\n") + items.iter().map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()).collect::>().join("\n") } #[cfg(windows)] @@ -155,6 +159,14 @@ mod windows_impl { let _clip = open_with_retry()?; formats::Unicode.write_clipboard(&text).map_err(|e| e.to_string()) } + + fn get_text(&self) -> Option { + // `get_clipboard_string` opens the clipboard, reads the Unicode + // text if any, and closes it (design D4); any failure — busy, + // no text, no window station in CI — is "no text available", + // making the paste a no-op rather than an error. + clipboard_win::get_clipboard_string().ok() + } } } @@ -192,6 +204,13 @@ mod text_impl { let mut clip = ArboardHandle::new().map_err(|e| e.to_string())?; clip.set_text(text.to_string()).map_err(|e| e.to_string()) } + + fn get_text(&self) -> Option { + // Best-effort like the rest of the non-Windows fallback: any + // failure (no clipboard provider, no text) is "no text + // available" (design D4). + ArboardHandle::new().ok()?.get_text().ok() + } } } @@ -207,6 +226,9 @@ pub use text_impl::TextClipboard as PlatformClipboard; pub struct RecordingClipboard { pub files_calls: RefCell>>, pub text_calls: RefCell>, + /// What `get_text` returns — the fake clipboard's current text content + /// for paste tests (text-field-editing "Paste into a text field"). + pub text_to_read: RefCell>, /// When `Some`, both methods return this `Err` instead of recording a /// call — exercises the busy/failure path (clipboard-export "Clipboard /// busy retry": "Persistent lock reports failure"). @@ -234,6 +256,10 @@ impl Clipboard for RecordingClipboard { self.text_calls.borrow_mut().push(text.to_string()); Ok(()) } + + fn get_text(&self) -> Option { + self.text_to_read.borrow().clone() + } } #[cfg(test)] @@ -301,4 +327,12 @@ mod tests { assert_eq!(clip.set_text("x"), Err("Clipboard busy — try again".to_string())); assert!(clip.text_calls.borrow().is_empty()); } + + #[test] + fn recording_clipboard_reads_back_its_staged_text() { + let clip = RecordingClipboard::default(); + assert_eq!(clip.get_text(), None, "nothing staged: the paste is a no-op"); + *clip.text_to_read.borrow_mut() = Some("E:\\archive".to_string()); + assert_eq!(clip.get_text().as_deref(), Some("E:\\archive")); + } } diff --git a/crates/filecommand-tui/src/clock.rs b/crates/filecommand-tui/src/clock.rs index fd7de41..90fdb55 100644 --- a/crates/filecommand-tui/src/clock.rs +++ b/crates/filecommand-tui/src/clock.rs @@ -40,34 +40,25 @@ impl WallClock for RealWallClock { fn now_local(&self) -> (u8, u8) { platform::now_local() } + + /// `SystemTime` since the Unix epoch, saturating to 0 should the clock + /// read before it (history-persistence-hardening design D1). + fn epoch_ms(&self) -> u64 { + now_unix_ms() + } } #[cfg(windows)] mod platform { - /// Mirrors the Win32 `SYSTEMTIME` layout; only the fields - /// `GetLocalTime` requires a home for are read. - #[repr(C)] - #[derive(Default)] - struct SystemTime { - year: u16, - month: u16, - day_of_week: u16, - day: u16, - hour: u16, - minute: u16, - second: u16, - milliseconds: u16, - } - - #[link(name = "kernel32")] - extern "system" { - fn GetLocalTime(system_time: *mut SystemTime); - } + use windows_sys::Win32::Foundation::SYSTEMTIME; + use windows_sys::Win32::System::SystemInformation::GetLocalTime; pub fn now_local() -> (u8, u8) { - let mut st = SystemTime::default(); + let mut st = SYSTEMTIME { wYear: 0, wMonth: 0, wDayOfWeek: 0, wDay: 0, wHour: 0, wMinute: 0, wSecond: 0, wMilliseconds: 0 }; + // SAFETY: `st` is a live, fully initialized `SYSTEMTIME` that the + // call overwrites in place; nothing else is touched. unsafe { GetLocalTime(&mut st) }; - (st.hour as u8, st.minute as u8) + (st.wHour as u8, st.wMinute as u8) } } @@ -81,3 +72,36 @@ mod platform { ((minutes_total / 60) as u8, (minutes_total % 60) as u8) } } + +/// The current wall-clock time as unix milliseconds. The header clock's +/// minute boundary is computed from this (see [`ms_until_next_minute`]); +/// std's `SystemTime` suffices — no platform FFI needed for a wall reading. +pub fn now_unix_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64 +} + +/// Milliseconds from `unix_ms` until the next wall-clock minute boundary — +/// the idle loop's deadline for repainting the header clock +/// (idle-aware-event-loop design D2; application-shell "Header clock still +/// advances while idle"). Local-timezone offsets are whole minutes, so unix +/// arithmetic lands exactly on the boundary the rendered `h:mm a` flips at. +/// Always in `1..=60_000`, so the loop parks at most one minute. +pub fn ms_until_next_minute(unix_ms: u64) -> u64 { + 60_000 - unix_ms % 60_000 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ms_until_next_minute_at_known_instants() { + assert_eq!(ms_until_next_minute(0), 60_000, "exactly on a boundary waits a full minute"); + assert_eq!(ms_until_next_minute(1), 59_999); + assert_eq!(ms_until_next_minute(59_999), 1, "one ms before the flip"); + assert_eq!(ms_until_next_minute(60_000), 60_000, "the boundary itself starts a new minute"); + assert_eq!(ms_until_next_minute(61_500), 58_500); + // A real wall-clock reading: 2026-10-12T15:04:23.250Z. + assert_eq!(ms_until_next_minute(1_787_441_063_250), 36_750); + } +} diff --git a/crates/filecommand-tui/src/config_writer.rs b/crates/filecommand-tui/src/config_writer.rs new file mode 100644 index 0000000..5ec640c --- /dev/null +++ b/crates/filecommand-tui/src/config_writer.rs @@ -0,0 +1,350 @@ +//! The background `config.toml` writer (application-shell "Slow storage +//! never blocks the event loop"; offload-blocking-io design D9). +//! +//! `Effect::PersistTheme` and `Effect::PersistPanelSplit` used to rewrite +//! `config.toml` synchronously on the event-loop thread — one whole-file +//! read-modify-write per theme apply and per Ctrl+Left/Ctrl+Right step +//! (audit P-09). Now the value is posted to [`ConfigWriter::submit`], which +//! hands it to one long-lived thread with a per-setting mailbox: +//! +//! - writes are applied one at a time, in the order their settings were +//! first requested, so two rewrites of `config.toml` never interleave +//! (both saves are read-modify-writes of the same file — running them +//! concurrently could let one replace overwrite the other's fresh key); +//! - a newer value for a setting replaces an older one the thread has not +//! taken yet (last write wins), so a held Ctrl+Right produces a couple +//! of writes rather than one per step, and the final value is what lands; +//! - each write is the same `config::save_theme_atomic` / +//! `save_panel_split_atomic` call as before — the durable temp-file, +//! flush and replace sequence (configuration-files "Persisted +//! configuration writes are durable") is untouched by moving threads — +//! and a failure is logged, never fatal; +//! - dropping the writer flushes every pending setting and waits for the +//! thread for a bounded [`FLUSH_WAIT`], so an unreachable profile +//! directory can never keep the process from exiting. +//! +//! `Runtime` owns the writer, so every exit path from `app::run` (the quit +//! sites, early `?` returns, and panic unwinding) runs the final flush. +//! `history.json` has its own writer (`crate::history_writer`); the two +//! write different files and are independent. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError}; +use std::thread::JoinHandle; +use std::time::Duration; + +use filecommand_core::config; + +use crate::diag; + +/// The most `Drop` waits for the final flush before detaching the thread. +pub const FLUSH_WAIT: Duration = Duration::from_secs(2); + +/// One configuration setting to persist. Each variant is one key of +/// `config.toml`; the mailbox keeps the newest value per variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigWrite { + /// `theme = ""` (theme-selection "Applied theme persists to + /// configuration"). + Theme(String), + /// `panel_split = ` (panel-split "Split persistence to + /// configuration"). + PanelSplit(u16), +} + +impl ConfigWrite { + /// Whether `other` is a value for the same setting as `self`. + fn same_key(&self, other: &ConfigWrite) -> bool { + matches!((self, other), (ConfigWrite::Theme(_), ConfigWrite::Theme(_)) | (ConfigWrite::PanelSplit(_), ConfigWrite::PanelSplit(_))) + } +} + +/// The save a write is performed with — `config::save_theme_atomic` / +/// `save_panel_split_atomic` in production, injectable for the tests. +type SaveFn = Box io::Result<()> + Send>; + +enum Msg { + Write(ConfigWrite), + Flush, +} + +/// Handle to the writer thread. [`ConfigWriter::disabled`] is the no-op +/// used when persistence is off for the session (unwritable profile +/// directory) and in tests that must not touch the disk. +pub struct ConfigWriter { + tx: Option>, + done: Option>, + handle: Option>, +} + +impl ConfigWriter { + /// Start the writer for the `config.toml` at `path`. If the thread + /// cannot be started, the failure is logged and a disabled writer is + /// returned: the theme and split are then not persisted this session, + /// but the session itself continues. + pub fn spawn(path: PathBuf) -> ConfigWriter { + ConfigWriter::spawn_with(path, Box::new(save_setting)) + } + + /// [`Self::spawn`] with the per-write save injected (tests). + pub(crate) fn spawn_with(path: PathBuf, save: SaveFn) -> ConfigWriter { + let (tx, rx) = mpsc::channel::(); + let (done_tx, done_rx) = mpsc::channel::<()>(); + let spawned = std::thread::Builder::new().name("config-writer".to_string()).spawn(move || { + writer_loop(&path, &rx, &*save); + let _ = done_tx.send(()); + }); + match spawned { + Ok(handle) => ConfigWriter { tx: Some(tx), done: Some(done_rx), handle: Some(handle) }, + Err(e) => { + diag::log("persist", format_args!("config writer thread could not start: {e}")); + ConfigWriter::disabled() + } + } + } + + /// A writer that drops every write: persistence is off. + pub fn disabled() -> ConfigWriter { + ConfigWriter { tx: None, done: None, handle: None } + } + + /// Hand a setting's new value to the writer thread and return at once. + /// A no-op when disabled — exactly as the old `persist_enabled` guard + /// skipped the write. + pub fn submit(&self, write: ConfigWrite) { + if let Some(tx) = &self.tx { + // A send fails only if the thread is gone; nothing to do then. + let _ = tx.send(Msg::Write(write)); + } + } + + /// Whether writes go anywhere. + pub fn is_enabled(&self) -> bool { + self.tx.is_some() + } +} + +impl Drop for ConfigWriter { + /// Flush every pending setting and wait for the thread, for at most + /// [`FLUSH_WAIT`] (application-shell "A setting changed just before + /// quit is saved", "An unresponsive profile directory cannot hold quit + /// open"). + fn drop(&mut self) { + let Some(tx) = self.tx.take() else { return }; + let _ = tx.send(Msg::Flush); + drop(tx); + if let Some(done) = self.done.take() { + match done.recv_timeout(FLUSH_WAIT) { + // Finished (or panicked, which also drops its `done_tx`): + // joining is immediate either way. + Ok(()) | Err(RecvTimeoutError::Disconnected) => { + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + Err(RecvTimeoutError::Timeout) => { + diag::log("persist", format_args!("config writer still busy after {FLUSH_WAIT:?}; exiting without waiting")); + } + } + } + } +} + +/// The production save: the same durable write-back the effect bodies used +/// to call inline (config-parsing-robustness owns how it is performed). +fn save_setting(path: &Path, write: &ConfigWrite) -> io::Result<()> { + match write { + ConfigWrite::Theme(name) => config::save_theme_atomic(path, name), + ConfigWrite::PanelSplit(percent) => config::save_panel_split_atomic(path, *percent), + } +} + +/// Store `write` in the mailbox: a value for a setting already queued +/// replaces it in place (last write wins, position kept); a new setting is +/// appended (FIFO by first request). +fn enqueue(pending: &mut Vec, write: ConfigWrite) { + match pending.iter_mut().find(|p| p.same_key(&write)) { + Some(slot) => *slot = write, + None => pending.push(write), + } +} + +/// One write, logged on failure and otherwise swallowed: a configuration +/// write must never take the session down (the setting is already in +/// effect in memory). +fn perform(path: &Path, save: &(dyn Fn(&Path, &ConfigWrite) -> io::Result<()> + Send), write: &ConfigWrite) { + diag::log_err("persist", format_args!("config write {}", path.display()), save(path, write)); +} + +/// The writer thread body: drain whatever has arrived into the per-setting +/// mailbox, then write one setting at a time with its newest value; on +/// `Flush` (or the handle being dropped) write every pending setting and +/// exit. Every message queued before the `Flush` is taken into the mailbox +/// first, so nothing submitted before the drop is lost. +fn writer_loop(path: &Path, rx: &Receiver, save: &(dyn Fn(&Path, &ConfigWrite) -> io::Result<()> + Send)) { + let mut pending: Vec = Vec::new(); + loop { + let msg = if pending.is_empty() { rx.recv().map_err(|_| TryRecvError::Disconnected) } else { rx.try_recv() }; + match msg { + Ok(Msg::Write(write)) => enqueue(&mut pending, write), + Err(TryRecvError::Empty) => { + let next = pending.remove(0); + perform(path, save, &next); + } + Ok(Msg::Flush) | Err(TryRecvError::Disconnected) => { + for write in pending.drain(..) { + perform(path, save, &write); + } + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Instant; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-config-writer-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// The `key = value` line for `key` in the file at `path`, if any. + fn top_level_value(path: &Path, key: &str) -> Option { + let text = std::fs::read_to_string(path).ok()?; + text.lines().find_map(|line| { + let (k, v) = line.split_once('=')?; + (k.trim() == key).then(|| v.trim().to_string()) + }) + } + + /// A save hook that performs the real durable write and counts calls. + fn counting_save(writes: Arc) -> SaveFn { + Box::new(move |path, write| { + writes.fetch_add(1, Ordering::SeqCst); + save_setting(path, write) + }) + } + + /// application-shell "Rapid split adjustments persist the final value": + /// eight submissions in a row end with the last value on disk, with no + /// more writes than submissions. + #[test] + fn a_burst_of_split_submissions_ends_with_the_last_value_on_disk() { + let dir = temp_dir("burst"); + let path = dir.join("config.toml"); + let writes = Arc::new(AtomicUsize::new(0)); + let writer = ConfigWriter::spawn_with(path.clone(), counting_save(writes.clone())); + for percent in 52..=66u16 { + if (percent - 50) % 2 == 0 { + writer.submit(ConfigWrite::PanelSplit(percent)); + } + } + drop(writer); + assert_eq!(top_level_value(&path, "panel_split").as_deref(), Some("66"), "the final value is the last one requested"); + let n = writes.load(Ordering::SeqCst); + assert!((1..=8).contains(&n), "no more writes than submissions, at least one: {n}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// application-shell "A theme and a split change made together are both + /// saved": both keys land, and the two saves never overlap. + #[test] + fn a_theme_and_a_split_submission_both_land_and_never_run_concurrently() { + let dir = temp_dir("both"); + let path = dir.join("config.toml"); + let in_save = Arc::new(AtomicBool::new(false)); + let overlap = Arc::new(AtomicBool::new(false)); + let (in_save2, overlap2) = (in_save.clone(), overlap.clone()); + let save: SaveFn = Box::new(move |path, write| { + if in_save2.swap(true, Ordering::SeqCst) { + overlap2.store(true, Ordering::SeqCst); + } + std::thread::sleep(Duration::from_millis(30)); + let result = save_setting(path, write); + in_save2.store(false, Ordering::SeqCst); + result + }); + let writer = ConfigWriter::spawn_with(path.clone(), save); + writer.submit(ConfigWrite::Theme("purple-lights".to_string())); + writer.submit(ConfigWrite::PanelSplit(60)); + drop(writer); + assert_eq!(top_level_value(&path, "theme").as_deref(), Some("\"purple-lights\"")); + assert_eq!(top_level_value(&path, "panel_split").as_deref(), Some("60")); + assert!(!overlap.load(Ordering::SeqCst), "the saves ran one at a time"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Settings are written in the order first requested, and a newer value + /// for a queued setting replaces the older one in place. + #[test] + fn the_mailbox_keeps_first_request_order_and_the_newest_value_per_setting() { + let mut pending = Vec::new(); + enqueue(&mut pending, ConfigWrite::PanelSplit(52)); + enqueue(&mut pending, ConfigWrite::Theme("a".to_string())); + enqueue(&mut pending, ConfigWrite::PanelSplit(54)); + enqueue(&mut pending, ConfigWrite::Theme("b".to_string())); + enqueue(&mut pending, ConfigWrite::PanelSplit(56)); + assert_eq!(pending, vec![ConfigWrite::PanelSplit(56), ConfigWrite::Theme("b".to_string())]); + } + + /// application-shell "A setting changed just before quit is saved": + /// dropping the writer with a pending write completes it before `drop` + /// returns. + #[test] + fn dropping_the_writer_completes_a_pending_write_first() { + let dir = temp_dir("flush"); + let path = dir.join("config.toml"); + let save: SaveFn = Box::new(|path, write| { + std::thread::sleep(Duration::from_millis(200)); + save_setting(path, write) + }); + let writer = ConfigWriter::spawn_with(path.clone(), save); + writer.submit(ConfigWrite::PanelSplit(64)); + drop(writer); + assert_eq!(top_level_value(&path, "panel_split").as_deref(), Some("64"), "the write landed before drop returned"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// application-shell "An unresponsive profile directory cannot hold quit + /// open": a save that never returns makes `drop` give up after about + /// [`FLUSH_WAIT`]. + #[test] + fn a_save_that_blocks_forever_makes_drop_return_after_the_bound() { + let dir = temp_dir("stuck"); + let path = dir.join("config.toml"); + let save: SaveFn = Box::new(|_, _| loop { + std::thread::park(); + }); + let writer = ConfigWriter::spawn_with(path, save); + writer.submit(ConfigWrite::Theme("x".to_string())); + let started = Instant::now(); + drop(writer); + let elapsed = started.elapsed(); + assert!(elapsed >= FLUSH_WAIT.mul_f32(0.8), "drop waited for the bound: {elapsed:?}"); + assert!(elapsed < FLUSH_WAIT * 3, "drop did not wait much past the bound: {elapsed:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// With persistence off for the session, `submit` writes nothing. + #[test] + fn a_disabled_writer_writes_nothing() { + let dir = temp_dir("disabled"); + let path = dir.join("config.toml"); + let writer = ConfigWriter::disabled(); + assert!(!writer.is_enabled()); + writer.submit(ConfigWrite::PanelSplit(60)); + writer.submit(ConfigWrite::Theme("x".to_string())); + drop(writer); + assert!(!path.exists()); + assert!(std::fs::read_dir(&dir).unwrap().next().is_none(), "no file of any kind was created"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/filecommand-tui/src/console.rs b/crates/filecommand-tui/src/console.rs new file mode 100644 index 0000000..ef1457f --- /dev/null +++ b/crates/filecommand-tui/src/console.rs @@ -0,0 +1,302 @@ +//! Console control events during a suspended child run (application-shell +//! "Ctrl+C during a suspended child run stops only the child"; +//! process-lifecycle-hardening design D1/D2). +//! +//! While the TUI is suspended for a Run-entry command, an F2 user-menu +//! command, or the F4 external editor, raw mode is off, so Ctrl+C is a +//! console control event again: Windows delivers it to every process on the +//! console and Unix sends SIGINT to the foreground process group. Without a +//! handler the default disposition ends FileCommand along with the child. +//! +//! The fix is gated on one process-wide flag, [`CHILD_RUNNING`], held by an +//! RAII [`ChildRunGuard`] for the whole suspended stretch: +//! +//! - **Windows**: a handler installed once at startup +//! ([`install_ctrl_handler`]) answers `CTRL_C_EVENT`/`CTRL_BREAK_EVENT` +//! with "handled" while the flag is set, and declines everything else — +//! close, logoff, and shutdown still end the process, and Ctrl+C outside a +//! child run takes the default path exactly as before. Handler routines +//! are per-process and not inherited, so the child still receives the +//! event and stops as it would from a shell. +//! - **Unix**: only *after* `spawn` has returned (the child has already +//! `exec`ed with default dispositions) does the guard set `SIGINT` and +//! `SIGQUIT` to `SIG_IGN`, saving the previous actions and restoring them +//! when it drops. Ignoring them before the spawn would be inherited by +//! the child, which is exactly what must not happen. + +use std::io; +use std::process::{Child, Command, ExitStatus}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Set while a suspended child run is in progress — from before +/// `guard.suspend()` until after `guard.resume()` has re-entered raw mode, +/// so the press-any-key prompt and the moments between steps are covered +/// too (design D1). +pub static CHILD_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Whether the control handler claims `event` given the current flag — the +/// pure decision [`ctrl_handler`] applies, kept separate so it can be +/// stated in one place: only Ctrl+C and Ctrl+Break, and only while a child +/// run is in progress. `CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT`, and +/// `CTRL_SHUTDOWN_EVENT` are never claimed. +#[cfg(windows)] +fn claims_event(event: u32, child_running: bool) -> bool { + use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_C_EVENT}; + child_running && (event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT) +} + +/// The `HandlerRoutine` registered by [`install_ctrl_handler`]. Runs on a +/// system-created thread, so it touches nothing but the atomic flag. +/// Returns `TRUE` (handled — FileCommand keeps running) for Ctrl+C and +/// Ctrl+Break while [`CHILD_RUNNING`] is set, and `FALSE` otherwise so the +/// next handler in the chain (ultimately the default, which ends the +/// process) runs. +#[cfg(windows)] +pub extern "system" fn ctrl_handler(event: u32) -> windows_sys::Win32::Foundation::BOOL { + use windows_sys::Win32::Foundation::{FALSE, TRUE}; + if claims_event(event, CHILD_RUNNING.load(Ordering::SeqCst)) { + TRUE + } else { + FALSE + } +} + +/// Register [`ctrl_handler`] with the console. Called once by `app::run` at +/// startup, before the terminal is acquired. A registration failure is not +/// fatal — it only means the pre-existing behavior (Ctrl+C ends both +/// processes) remains — so the result is reported rather than unwrapped. +#[cfg(windows)] +pub fn install_ctrl_handler() -> std::io::Result<()> { + use windows_sys::Win32::Foundation::TRUE; + use windows_sys::Win32::System::Console::SetConsoleCtrlHandler; + // SAFETY: `ctrl_handler` is a plain `extern "system"` function with the + // `PHANDLER_ROUTINE` signature and `'static` lifetime; adding it is a + // process-local registration with no pointer arguments beyond it. + let ok = unsafe { SetConsoleCtrlHandler(Some(ctrl_handler), TRUE) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// RAII marker for one suspended child run: sets [`CHILD_RUNNING`] on +/// construction and clears it on drop — including during an unwind, so a +/// panic between suspend and resume can never leave Ctrl+C permanently +/// swallowed. On Unix it also owns the saved `SIGINT`/`SIGQUIT` actions +/// once [`child_started`](Self::child_started) has been called, restoring +/// them on drop. +/// +/// Not `Clone`: exactly one guard exists per run, and nested runs do not +/// happen (the TUI is suspended for the whole stretch). +pub struct ChildRunGuard { + #[cfg(unix)] + saved: Option, + /// Keeps the struct non-constructible outside [`ChildRunGuard::new`]. + _private: (), +} + +impl ChildRunGuard { + /// Begin a child run: the flag goes up now, before the TUI suspends. + pub fn new() -> ChildRunGuard { + CHILD_RUNNING.store(true, Ordering::SeqCst); + ChildRunGuard { + #[cfg(unix)] + saved: None, + _private: (), + } + } + + /// Call once `std::process::Command::spawn` has returned `Ok`: the child + /// is running with its own default signal dispositions, so the parent + /// may now ignore `SIGINT`/`SIGQUIT` without the child inheriting that + /// (design D2). A no-op on Windows, where the control handler is already + /// gated by the flag. + pub fn child_started(&mut self) { + #[cfg(unix)] + { + if self.saved.is_none() { + self.saved = Some(unix::ignore_interrupts()); + } + } + } +} + +impl Default for ChildRunGuard { + fn default() -> Self { + ChildRunGuard::new() + } +} + +/// Spawn `command` and wait for it to exit inside `guard`: the Unix signal +/// ignore is armed only once `spawn` has returned, so the child never +/// inherits it (design D2). The spawn error is returned unchanged so the +/// callers' existing "Could not run `…`" messages stay as they are. On +/// Windows the arming step is a no-op; the flag the guard already holds is +/// what gates the control handler. +pub fn spawn_and_wait(command: &mut Command, guard: &mut ChildRunGuard) -> io::Result { + let mut child: Child = command.spawn()?; + guard.child_started(); + child.wait() +} + +/// Serializes every test — in this module or elsewhere in the crate — that +/// reads or writes the process-wide [`CHILD_RUNNING`] flag. +#[cfg(test)] +pub(crate) static FLAG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +impl Drop for ChildRunGuard { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(saved) = self.saved.take() { + unix::restore_interrupts(saved); + } + CHILD_RUNNING.store(false, Ordering::SeqCst); + } +} + +#[cfg(unix)] +mod unix { + //! The Unix half of design D2: `sigaction`-based save/ignore/restore of + //! `SIGINT` and `SIGQUIT`. + + /// The dispositions in force before the guard ignored them. + pub struct SavedSignals { + int: libc::sigaction, + quit: libc::sigaction, + } + + fn ignore(sig: libc::c_int) -> libc::sigaction { + // SAFETY: `sigaction` is plain data that is valid when zeroed; both + // pointers are to live locals for the duration of the call. + unsafe { + let mut new: libc::sigaction = std::mem::zeroed(); + new.sa_sigaction = libc::SIG_IGN; + libc::sigemptyset(&mut new.sa_mask); + let mut old: libc::sigaction = std::mem::zeroed(); + libc::sigaction(sig, &new, &mut old); + old + } + } + + fn restore(sig: libc::c_int, old: &libc::sigaction) { + // SAFETY: `old` was filled in by a successful `sigaction` call on the + // same signal; the output pointer may be null. + unsafe { + libc::sigaction(sig, old, std::ptr::null_mut()); + } + } + + pub fn ignore_interrupts() -> SavedSignals { + SavedSignals { int: ignore(libc::SIGINT), quit: ignore(libc::SIGQUIT) } + } + + pub fn restore_interrupts(saved: SavedSignals) { + restore(libc::SIGINT, &saved.int); + restore(libc::SIGQUIT, &saved.quit); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_guard_sets_the_flag_while_alive_and_clears_it_on_drop() { + let _serial = FLAG_LOCK.lock().unwrap(); + assert!(!CHILD_RUNNING.load(Ordering::SeqCst), "idle to begin with"); + { + let _guard = ChildRunGuard::new(); + assert!(CHILD_RUNNING.load(Ordering::SeqCst)); + } + assert!(!CHILD_RUNNING.load(Ordering::SeqCst), "cleared when the guard drops"); + } + + #[test] + fn the_guard_clears_the_flag_during_an_unwind() { + // A panic between suspend and resume must not leave Ctrl+C swallowed + // for the rest of the session. + let _serial = FLAG_LOCK.lock().unwrap(); + let result = std::panic::catch_unwind(|| { + let _guard = ChildRunGuard::new(); + assert!(CHILD_RUNNING.load(Ordering::SeqCst)); + panic!("scripted panic inside a child run"); + }); + assert!(result.is_err()); + assert!(!CHILD_RUNNING.load(Ordering::SeqCst), "the unwind ran the guard's Drop"); + } + + // Calling the handler directly exercises its decision without sending + // a real console control event, which would reach every process on + // this console (task 1.5). + #[cfg(windows)] + mod windows_handler { + use super::*; + use windows_sys::Win32::Foundation::{FALSE, TRUE}; + use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT}; + + #[test] + fn ctrl_c_and_ctrl_break_are_claimed_only_while_a_child_run_is_in_progress() { + let _serial = FLAG_LOCK.lock().unwrap(); + assert_eq!(ctrl_handler(CTRL_C_EVENT), FALSE, "outside a child run Ctrl+C takes the default path"); + assert_eq!(ctrl_handler(CTRL_BREAK_EVENT), FALSE); + { + let _guard = ChildRunGuard::new(); + assert_eq!(ctrl_handler(CTRL_C_EVENT), TRUE, "handled: FileCommand keeps running"); + assert_eq!(ctrl_handler(CTRL_BREAK_EVENT), TRUE); + } + assert_eq!(ctrl_handler(CTRL_C_EVENT), FALSE, "the guard is gone, so the default path is back"); + } + + #[test] + fn close_logoff_and_shutdown_are_never_claimed() { + // "any console close, logoff, or shutdown event ... still ends + // FileCommand": declined with and without a child run. + let _serial = FLAG_LOCK.lock().unwrap(); + for event in [CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT] { + assert_eq!(ctrl_handler(event), FALSE, "event {event} outside a child run"); + } + let _guard = ChildRunGuard::new(); + for event in [CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT] { + assert_eq!(ctrl_handler(event), FALSE, "event {event} during a child run"); + } + } + + #[test] + fn claims_event_is_the_pure_form_of_the_handler() { + assert!(claims_event(CTRL_C_EVENT, true)); + assert!(claims_event(CTRL_BREAK_EVENT, true)); + assert!(!claims_event(CTRL_C_EVENT, false)); + assert!(!claims_event(CTRL_CLOSE_EVENT, true)); + } + } + + /// Task 1.6: with the guard armed *after* `sleep 5` has been spawned, + /// SIGINT ends the child (it inherited the default disposition, not + /// `SIG_IGN`) while this process — which also receives it — survives. + /// The signal is sent to the child's pid and to this process explicitly + /// rather than to the whole process group, so the test runner that + /// spawned this binary (which shares the group) is not interrupted; + /// the two deliveries together are what a group-wide Ctrl+C would do. + #[cfg(unix)] + #[test] + fn sigint_after_spawn_ends_the_child_and_spares_this_process() { + let _serial = FLAG_LOCK.lock().unwrap(); + let mut guard = ChildRunGuard::new(); + let mut child = Command::new("sleep").arg("5").stdin(std::process::Stdio::null()).spawn().expect("`sleep` exists on every Unix"); + guard.child_started(); + + // SAFETY: plain signal sends to a child we own and to ourselves; the + // guard has just made SIGINT ignored in this process. + unsafe { + libc::kill(child.id() as libc::pid_t, libc::SIGINT); + libc::kill(libc::getpid(), libc::SIGINT); + } + + let started = std::time::Instant::now(); + let status = child.wait().expect("wait on the signalled child"); + assert!(started.elapsed() < std::time::Duration::from_secs(4), "the child stopped on SIGINT rather than sleeping out its 5 s"); + assert!(!status.success(), "ended by a signal, not a normal exit: {status:?}"); + // Reaching this line is the "test process survives" half. + drop(guard); + } +} diff --git a/crates/filecommand-tui/src/diag.rs b/crates/filecommand-tui/src/diag.rs new file mode 100644 index 0000000..0e8355c --- /dev/null +++ b/crates/filecommand-tui/src/diag.rs @@ -0,0 +1,243 @@ +//! The optional diagnostic log (error-diagnostics; application-shell +//! "Optional diagnostic log"): a minimal append-only file appender behind a +//! process-wide `OnceLock`, enabled at launch by `--log ` or the +//! `FILECOMMAND_LOG` environment variable. When neither is set the global +//! stays unset and every `log` call is a no-op that creates nothing. +//! +//! Deliberately dependency-free: the audit (B-06) asked for a diagnostic +//! trail without pulling in `log`/`tracing` (design D1), and +//! `filecommand-core` stays I/O-free — only the TUI writes here. Only +//! failures are recorded, so the single `Mutex` is never meaningfully +//! contended; the mutex is what makes the log safe to call from worker +//! threads. + +use std::fmt::Display; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::time::SystemTime; + +use filecommand_core::listing::DateTime; + +/// The process-wide log, set once by [`init`]. A disabled log costs one +/// failed `get()` load per call; append mode keeps concurrent sessions that +/// share one file from clobbering each other. +static LOG: OnceLock = OnceLock::new(); + +/// An open diagnostic log: the file plus its write lock, factored out of +/// the global so unit tests can drive one with an injected path (task 1.2). +struct Logger { + file: Mutex, +} + +impl Logger { + /// Open `path` in append mode — creating it when missing, never + /// truncating it ("Existing log content is kept") — and write the + /// session header line. + fn open(path: &Path) -> io::Result { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + write_line(&mut file, "session", format_args!("FileCommand {} session start", env!("CARGO_PKG_VERSION")))?; + Ok(Logger { file: Mutex::new(file) }) + } + + fn log(&self, category: &str, message: impl Display) { + // A poisoned lock still yields the file: one panicking writer must + // not disable diagnostics for the rest of the session. Write errors + // are ignored — "a failure to write a log line SHALL never + // interrupt the session". + let mut file = self.file.lock().unwrap_or_else(|e| e.into_inner()); + let _ = write_line(&mut file, category, message); + let _ = file.flush(); + } +} + +/// One log line: UTC timestamp, category, message (design D1). +fn write_line(file: &mut File, category: &str, message: impl Display) -> io::Result<()> { + writeln!(file, "{} [{}] {}", timestamp(), category, message) +} + +/// The UTC timestamp prefix, `YYYY-MM-DDTHH:MM:SS.mmmZ`: the civil date and +/// time from `listing::DateTime` (the conversion the panels already use — +/// it carries no seconds field), plus the seconds and millisecond remainder +/// from the raw duration (design D1). +fn timestamp() -> String { + let now = SystemTime::now(); + let dt = DateTime::from_system_time(now); + let dur = now.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default(); + format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", dt.year, dt.month, dt.day, dt.hour, dt.minute, dur.as_secs() % 60, dur.subsec_millis()) +} + +/// Open the diagnostic log at `path`, enabling [`log`] for the rest of the +/// session. On error nothing is enabled and the caller surfaces the warning +/// (design D2: "the session continues without logging"). +pub fn init(path: &Path) -> io::Result<()> { + let logger = Logger::open(path)?; + // The launch flag is resolved once at startup, so this is set at most + // once in production; a stray second `init` keeps the first logger + // rather than orphaning its file. + let _ = LOG.set(logger); + Ok(()) +} + +/// Append one line to the diagnostic log. A no-op when logging was never +/// enabled — no file is created or written ("No log unless asked for"). +pub fn log(category: &str, message: impl Display) { + if let Some(logger) = LOG.get() { + logger.log(category, message); + } +} + +/// Log `result`'s error with `context` and unwrap it to an `Option`, so a +/// former `let _ = fallible()` site records the failure without gaining new +/// control flow (designs D1/D3). +pub fn log_err(category: &str, context: impl Display, result: Result) -> Option { + match result { + Ok(value) => Some(value), + Err(err) => { + log(category, format_args!("{context}: {err}")); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A unique temp file per test; the parent directory is created and any + /// leftover from a previous run is removed first. + fn temp_log(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-tui-diag-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("fc.log") + } + + /// `YYYY-MM-DDTHH:MM:SS.mmmZ` — 24 characters of digits with fixed + /// punctuation, followed by `" ["` (the category bracket). + fn assert_timestamp_prefix(line: &str) { + assert!(line.len() > 26, "a log line is longer than its timestamp: {line:?}"); + let (ts, rest) = line.split_at(24); + assert!(rest.starts_with(" ["), "a space and the category follow the timestamp: {line:?}"); + for (i, b) in ts.bytes().enumerate() { + match i { + 4 | 7 => assert_eq!(b, b'-', "date dashes in {ts:?}"), + 10 => assert_eq!(b, b'T', "date/time separator in {ts:?}"), + 13 | 16 => assert_eq!(b, b':', "time colons in {ts:?}"), + 19 => assert_eq!(b, b'.', "millisecond dot in {ts:?}"), + 23 => assert_eq!(b, b'Z', "UTC marker in {ts:?}"), + _ => assert!(b.is_ascii_digit(), "digit at offset {i} in {ts:?}"), + } + } + } + + #[test] + fn a_disabled_logger_creates_no_file() { + // "No log unless asked for": no test in this binary calls the global + // `init`, so `log` must be a pure no-op that creates nothing. + let path = temp_log("disabled"); + let dir = path.parent().unwrap().to_path_buf(); + let _ = std::fs::remove_file(&path); + log("persist", "history write C:\\nowhere\\history.json: denied"); + assert!(std::fs::read_dir(&dir).unwrap().next().is_none(), "nothing may be created while the log is disabled"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_enabled_logger_appends_timestamped_lines() { + let path = temp_log("lines"); + { + let logger = Logger::open(&path).unwrap(); + logger.log("persist", "history write C:\\p\\history.json: Access is denied. (os error 5)"); + logger.log("channel", "listing chunk not delivered: event loop gone"); + } + + let content = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 3, "the session header plus the two logged lines: {content:?}"); + for line in &lines { + assert_timestamp_prefix(line); + } + assert!(lines[0].contains("[session]") && lines[0].contains("session start"), "the header opens the session: {}", lines[0]); + assert!( + lines[1].contains("[persist] history write C:\\p\\history.json: Access is denied. (os error 5)"), + "category and message are kept verbatim: {}", + lines[1] + ); + assert!(lines[2].contains("[channel] listing chunk not delivered: event loop gone"), "{}", lines[2]); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn reopening_an_existing_log_keeps_its_earlier_content() { + let path = temp_log("append"); + { + let logger = Logger::open(&path).unwrap(); + logger.log("startup", "first session warning"); + } + let first_len = std::fs::read_to_string(&path).unwrap().len(); + + // "Existing log content is kept": a later session appends after the + // earlier content rather than truncating it. + { + let logger = Logger::open(&path).unwrap(); + logger.log("startup", "second session warning"); + } + + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.len() > first_len, "the second session appends"); + assert!(content.contains("first session warning"), "the earlier lines survive: {content:?}"); + assert!(content.contains("second session warning")); + assert_eq!(content.matches("session start").count(), 2, "each session writes its own header"); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn concurrent_logging_from_four_threads_produces_whole_lines() { + let path = temp_log("threads"); + let logger = std::sync::Arc::new(Logger::open(&path).unwrap()); + let mut handles = Vec::new(); + for thread in 0..4 { + let logger = std::sync::Arc::clone(&logger); + handles.push(std::thread::spawn(move || { + for i in 0..50 { + logger.log("copy", format_args!("thread {thread} line {i}")); + } + })); + } + for handle in handles { + handle.join().unwrap(); + } + drop(logger); + + let content = std::fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 1 + 4 * 50, "the header plus every line, none lost or torn"); + for line in &lines[1..] { + assert_timestamp_prefix(line); + assert!(line.contains("[copy] thread "), "a whole, untorn line from one thread: {line:?}"); + } + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn init_on_an_unopenable_path_fails_without_enabling_the_log() { + // "Unwritable log path warns and continues": the open error reaches + // the caller, and nothing is enabled. + let missing_dir = std::env::temp_dir().join(format!("filecommand-tui-diag-init-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&missing_dir); + let err = init(&missing_dir.join("fc.log")).expect_err("a log inside a nonexistent directory cannot be opened"); + assert_eq!(err.kind(), io::ErrorKind::NotFound); + } + + #[test] + fn log_err_maps_ok_to_some_and_err_to_none() { + // The mapping is all a disabled log can show; the file-writing half + // is covered by the `Logger` tests above. + let ok: io::Result = Ok(5); + assert_eq!(log_err("persist", "history write /x", ok), Some(5)); + let err: io::Result = Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied")); + assert_eq!(log_err("persist", "history write /x", err), None); + } +} diff --git a/crates/filecommand-tui/src/history_writer.rs b/crates/filecommand-tui/src/history_writer.rs new file mode 100644 index 0000000..468250c --- /dev/null +++ b/crates/filecommand-tui/src/history_writer.rs @@ -0,0 +1,319 @@ +//! The background `history.json` writer (fuzzy-jump "Directory history +//! persistence"; history-persistence-hardening design D4, D5). +//! +//! `Effect::PersistHistory` used to write the whole file synchronously on +//! the event-loop thread, on every navigation (audit P-08). Now the reducer's +//! snapshot is posted to [`HistoryWriter::submit`], which hands it to one +//! long-lived thread with a single-slot "latest snapshot" mailbox: +//! +//! - older snapshots are dropped unread; only the newest matters; +//! - the thread writes once a burst of changes has settled +//! ([`DEBOUNCE`] with no new snapshot), and no later than [`MAX_WAIT`] +//! after the oldest unwritten snapshot arrived; +//! - every write first re-reads the file on disk, migrates it if it is a +//! legacy file, merges it with the snapshot +//! ([`config::merge_history_files`]), and replaces the file durably, so +//! two instances sharing a profile directory keep each other's history; +//! - dropping the writer flushes any pending snapshot and waits for the +//! thread for a bounded [`FLUSH_WAIT`], so an unreachable profile +//! directory can never keep the process from exiting. +//! +//! `Runtime` owns the writer, so every exit path from `app::run` (the quit +//! sites, early `?` returns, and panic unwinding) runs the final flush. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use filecommand_core::config::{self, HistoryFile}; + +use crate::diag; + +/// A write happens once this long has passed with no newer snapshot. +pub const DEBOUNCE: Duration = Duration::from_secs(2); +/// ... and no later than this long after the oldest unwritten snapshot. +pub const MAX_WAIT: Duration = Duration::from_secs(10); +/// The most `Drop` waits for the final flush before detaching the thread. +pub const FLUSH_WAIT: Duration = Duration::from_secs(2); + +enum Msg { + Snapshot(HistoryFile), + Flush, +} + +/// Handle to the writer thread. [`HistoryWriter::disabled`] is the no-op +/// used when persistence is off for the session (unwritable profile +/// directory) and in tests that must not touch the disk. +pub struct HistoryWriter { + tx: Option>, + done: Option>, + handle: Option>, +} + +impl HistoryWriter { + /// Start the writer for `path` with the given debounce and maximum + /// wait. If the thread cannot be started, the failure is logged and a + /// disabled writer is returned: history is then not persisted this + /// session, but the session itself continues. + pub fn spawn(path: PathBuf, debounce: Duration, max_wait: Duration) -> HistoryWriter { + let (tx, rx) = mpsc::channel::(); + let (done_tx, done_rx) = mpsc::channel::<()>(); + let spawned = std::thread::Builder::new().name("history-writer".to_string()).spawn(move || { + writer_loop(&path, &rx, debounce, max_wait); + let _ = done_tx.send(()); + }); + match spawned { + Ok(handle) => HistoryWriter { tx: Some(tx), done: Some(done_rx), handle: Some(handle) }, + Err(e) => { + diag::log("persist", format_args!("history writer thread could not start: {e}")); + HistoryWriter::disabled() + } + } + } + + /// A writer that drops every snapshot: persistence is off. + pub fn disabled() -> HistoryWriter { + HistoryWriter { tx: None, done: None, handle: None } + } + + /// Hand the reducer's latest snapshot to the writer thread and return + /// at once (fuzzy-jump "Navigation does not wait for the history + /// write"). A no-op when disabled. + pub fn submit(&self, file: HistoryFile) { + if let Some(tx) = &self.tx { + // A send fails only if the thread is gone; nothing to do then. + let _ = tx.send(Msg::Snapshot(file)); + } + } + + /// Whether snapshots go anywhere. + pub fn is_enabled(&self) -> bool { + self.tx.is_some() + } +} + +impl Drop for HistoryWriter { + /// Flush the pending snapshot and wait for the thread, for at most + /// [`FLUSH_WAIT`] (fuzzy-jump "Quitting flushes unwritten history"). + fn drop(&mut self) { + let Some(tx) = self.tx.take() else { return }; + let _ = tx.send(Msg::Flush); + drop(tx); + if let Some(done) = self.done.take() { + match done.recv_timeout(FLUSH_WAIT) { + // Finished (or panicked, which also drops its `done_tx`): + // joining is immediate either way. + Ok(()) | Err(RecvTimeoutError::Disconnected) => { + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + Err(RecvTimeoutError::Timeout) => { + diag::log("persist", format_args!("history writer still busy after {FLUSH_WAIT:?}; exiting without waiting")); + } + } + } + } +} + +/// The writer thread body: keep only the newest snapshot, write it when the +/// burst settles or the maximum wait expires, and on `Flush` (or the handle +/// being dropped) write whatever is pending and exit. +fn writer_loop(path: &Path, rx: &Receiver, debounce: Duration, max_wait: Duration) { + let mut pending: Option = None; + let mut first_pending_at = Instant::now(); + let mut last_submit_at = Instant::now(); + loop { + let msg = if pending.is_none() { + rx.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + let deadline = (last_submit_at + debounce).min(first_pending_at + max_wait); + rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) + }; + match msg { + Ok(Msg::Snapshot(file)) => { + let now = Instant::now(); + if pending.is_none() { + first_pending_at = now; + } + last_submit_at = now; + pending = Some(file); + } + Err(RecvTimeoutError::Timeout) => { + if let Some(file) = pending.take() { + write_merged(path, &file); + } + } + Ok(Msg::Flush) | Err(RecvTimeoutError::Disconnected) => { + if let Some(file) = pending.take() { + write_merged(path, &file); + } + return; + } + } + } +} + +/// One write: load the disk copy, migrate it if it is a legacy file, merge +/// it with `mine`, and replace `history.json` durably (design D5, D6). A +/// failure is logged and otherwise swallowed: a history write must never +/// take the session down. +pub fn write_merged(path: &Path, mine: &HistoryFile) { + let now_epoch = crate::clock::now_unix_ms(); + let mut disk = config::load_history_document(path); + config::migrate_loaded_history(&mut disk.file, disk.legacy, now_epoch); + let merged = config::merge_history_files(&disk.file, mine, now_epoch); + diag::log_err("persist", format_args!("history write {}", path.display()), config::save_history_file_atomic(path, &merged)); +} + +#[cfg(test)] +mod tests { + use super::*; + use filecommand_core::quicksearch::FrecencyEntry; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-history-writer-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn snapshot(n: usize) -> HistoryFile { + HistoryFile { + commands: (0..=n).map(|i| format!("cmd{i}")).collect(), + directories: (0..=n) + .map(|i| FrecencyEntry { path: PathBuf::from(format!(r"C:\d{i}")), visit_count: 1, last_visited_ms: 1_000 + i as u64 }) + .collect(), + } + } + + fn dir_paths(file: &HistoryFile) -> Vec { + file.directories.iter().map(|d| d.path.to_string_lossy().into_owned()).collect() + } + + /// Poll for `history.json` to appear, up to `max`. + fn wait_for_file(path: &Path, max: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < max { + if path.exists() { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + path.exists() + } + + /// fuzzy-jump "A burst of navigation is written once": 20 rapid + /// submits settle into one write holding the last snapshot. + #[test] + fn a_burst_of_submits_produces_one_write_with_the_last_snapshot() { + let dir = temp_dir("burst"); + let path = dir.join("history.json"); + let writer = HistoryWriter::spawn(path.clone(), Duration::from_millis(150), Duration::from_secs(5)); + for i in 0..20 { + writer.submit(snapshot(i)); + } + assert!(!path.exists(), "nothing is written while the burst is still arriving"); + assert!(wait_for_file(&path, Duration::from_secs(3)), "the write lands once the burst settles"); + let first_write = std::fs::metadata(&path).unwrap().modified().unwrap(); + let on_disk = config::load_history_file(&path); + assert_eq!(on_disk.commands.len(), 20, "the last snapshot (cmd0..cmd19) was written"); + assert_eq!(dir_paths(&on_disk).len(), 20); + // With nothing new submitted there is no second write. + std::thread::sleep(Duration::from_millis(400)); + assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), first_write, "no further write without a new snapshot"); + drop(writer); + let _ = std::fs::remove_dir_all(&dir); + } + + /// fuzzy-jump "Quitting flushes unwritten history": a submit followed + /// by drop is written before `drop` returns, debounce notwithstanding. + #[test] + fn dropping_the_writer_flushes_the_pending_snapshot_first() { + let dir = temp_dir("flush"); + let path = dir.join("history.json"); + let writer = HistoryWriter::spawn(path.clone(), Duration::from_secs(60), Duration::from_secs(600)); + writer.submit(HistoryFile { + commands: vec!["cd C:\\work".to_string()], + directories: vec![FrecencyEntry { path: PathBuf::from(r"C:\work"), visit_count: 1, last_visited_ms: 5 }], + }); + drop(writer); + let on_disk = config::load_history_file(&path); + assert_eq!(on_disk.commands, vec!["cd C:\\work".to_string()]); + assert_eq!(dir_paths(&on_disk), vec![r"C:\work"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_disabled_writer_writes_nothing() { + let dir = temp_dir("disabled"); + let path = dir.join("history.json"); + let writer = HistoryWriter::disabled(); + assert!(!writer.is_enabled()); + writer.submit(snapshot(3)); + drop(writer); + assert!(!path.exists()); + assert!(std::fs::read_dir(&dir).unwrap().next().is_none(), "no file of any kind was created"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// fuzzy-jump "Two instances keep each other's history" and "Another + /// instance's commands are kept". + #[test] + fn two_writers_on_one_path_keep_both_directories_and_foreign_commands() { + let dir = temp_dir("two-instances"); + let path = dir.join("history.json"); + // A pre-existing file holding a command neither instance ran. + config::save_history_file_atomic(&path, &HistoryFile { commands: vec!["cd D:\\build".to_string()], directories: vec![] }).unwrap(); + + let a = HistoryWriter::spawn(path.clone(), Duration::from_secs(60), Duration::from_secs(600)); + let b = HistoryWriter::spawn(path.clone(), Duration::from_secs(60), Duration::from_secs(600)); + a.submit(HistoryFile { + commands: vec!["cd C:\\a".to_string()], + directories: vec![FrecencyEntry { path: PathBuf::from(r"C:\a"), visit_count: 2, last_visited_ms: 10 }], + }); + b.submit(HistoryFile { + commands: vec!["cd C:\\b".to_string()], + directories: vec![FrecencyEntry { path: PathBuf::from(r"C:\b"), visit_count: 1, last_visited_ms: 20 }], + }); + drop(a); + drop(b); + + let on_disk = config::load_history_file(&path); + let mut paths = dir_paths(&on_disk); + paths.sort(); + assert_eq!(paths, vec![r"C:\a", r"C:\b"]); + assert_eq!( + on_disk.commands, + vec!["cd D:\\build".to_string(), "cd C:\\a".to_string(), "cd C:\\b".to_string()], + "the foreign command is kept, ordered before both instances' own" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Writing through the writer adds the format marker and migrates a + /// legacy disk copy rather than letting its session-relative times win + /// the merge. + #[test] + fn write_merged_migrates_a_legacy_disk_copy_and_stamps_the_version() { + let dir = temp_dir("legacy-merge"); + let path = dir.join("history.json"); + // A legacy file: no "version", session-relative times. + std::fs::write( + &path, + "{\n \"commands\": [],\n \"directories\": [\n {\"path\": \"C:\\\\old\", \"visit_count\": 7, \"last_visited_ms\": 42}\n ]\n}\n", + ) + .unwrap(); + let before = crate::clock::now_unix_ms(); + write_merged(&path, &HistoryFile::default()); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("\"version\": 2"), "{text}"); + let loaded = config::load_history_document(&path); + assert!(!loaded.legacy); + assert_eq!(loaded.file.directories.len(), 1); + assert_eq!(loaded.file.directories[0].visit_count, 7, "the count carries over"); + assert!(loaded.file.directories[0].last_visited_ms >= before, "the legacy time was re-stamped to load time"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/filecommand-tui/src/hitmap.rs b/crates/filecommand-tui/src/hitmap.rs index a77c424..04e58b1 100644 --- a/crates/filecommand-tui/src/hitmap.rs +++ b/crates/filecommand-tui/src/hitmap.rs @@ -2,11 +2,13 @@ //! translate a raw mouse event into what the user clicked without any //! coordinate or `crossterm::event::KeyModifiers` ever reaching //! `filecommand-core` (design D2; mouse-input "Hit-testing stays in the -//! TUI"). Every field is built by the matching view module's own hit-test -//! helper (`views::panel::hit_test`, `views::keybar::hit_slots`, ...), -//! mirroring the exact geometry that module's renderer just drew — so the -//! hit map can never describe a rect that isn't actually on screen this -//! frame. +//! TUI"). `views::render` fills it in the same dispatch that draws the +//! frame: the panel, tab-strip, key-bar and command-line regions come from +//! the `layout::compute` the panels were drawn with, and every dialog's and +//! menu's buttons or rows from the `DialogLayout` its renderer just painted +//! from (reducer-overlay-structure design D8; mouse-input "Click targets +//! match the rendered geometry") — so the hit map can never describe a rect +//! that isn't actually on screen this frame. use std::ffi::OsString; use std::path::PathBuf; @@ -158,7 +160,7 @@ mod tests { fn panel_with(cwd: &str, n_entries: usize, scroll_offset: usize) -> PanelState { let mut panel = PanelState::new(PathBuf::from(cwd)); - panel.entries = (0..n_entries).map(|i| Entry { name: OsString::from(format!("f{i}.txt")), kind: EntryKind::File, size: 0, modified: None }).collect(); + panel.entries = (0..n_entries).map(|i| Entry::new(OsString::from(format!("f{i}.txt")), EntryKind::File, 0, None, false, false)).collect(); panel.scroll_offset = scroll_offset; panel } @@ -166,7 +168,10 @@ mod tests { /// Whether `inner` lies entirely within `outer` — not just its /// top-left corner, the whole rect. fn rect_nests_inside(outer: Rect, inner: Rect) -> bool { - inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height + inner.x >= outer.x + && inner.y >= outer.y + && inner.x + inner.width <= outer.x + outer.width + && inner.y + inner.height <= outer.y + outer.height } fn rects_overlap(a: Rect, b: Rect) -> bool { diff --git a/crates/filecommand-tui/src/input/mod.rs b/crates/filecommand-tui/src/input/mod.rs index 527fe07..027784e 100644 --- a/crates/filecommand-tui/src/input/mod.rs +++ b/crates/filecommand-tui/src/input/mod.rs @@ -15,84 +15,67 @@ use filecommand_core::editor::{EditorMove, EditorState}; use filecommand_core::find_file::FindFileState; use filecommand_core::fs_ops::dialog::{FileOpSetup, RunningDialog}; use filecommand_core::fs_ops::{ConflictChoice, ErrorChoice}; +use filecommand_core::line_edit::{flatten_line_breaks, TextEditOp}; use filecommand_core::listing::SortMode; use filecommand_core::panel::CursorMove; use filecommand_core::update::ClipboardPayloadKind; use filecommand_core::viewer::ViewerState; -use filecommand_core::{Command, PanelSide, State, UiPhase}; +use filecommand_core::{input_layer, Command, InputLayer, Overlay, PanelSide, State, UiPhase}; mod mouse; pub use mouse::{map_mouse, MouseTracker}; +/// Map a pressed key to the command it means in `state`, routing it to the +/// layer that owns input right now — the single precedence order +/// `filecommand_core::input_layer` defines (reducer-overlay-structure design +/// D2; application-shell "Modal overlays are mutually exclusive by +/// construction") — and then, for the overlay layer, to the open overlay's +/// own mapper. The mapping itself still performs no state mutation and no +/// I/O. pub fn map_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> Option { - // The quit-confirmation dialog is the topmost modal overlay of all: it - // can open above panels, the viewer, an open menu, or any other modal - // dialog/overlay, so it is checked before every other overlay below - // (application-shell "Quit request keys and confirmation"; design D5). - // The event loop routes here for it even while `state.phase` is - // `Viewer`/`Editor` — see `app.rs` — except in the editor, whose own - // Ctrl+C=Copy binding means this flag can never become true there. - if state.quit_confirm { - return map_quit_confirm_key(key); - } - // Modal overlays come first: while one is up it owns every key it - // understands, regardless of the phase underneath. The startup-warning - // modal is checked first of all since it can only ever be up at the - // very start of a session, before anything else has had a chance to - // open (user-menu "Malformed file warns and falls back without - // overwriting"). - if state.startup_warning.is_some() { - return map_startup_warning_key(key); - } - if state.drive_select.is_some() { - return map_drive_select_key(key); - } - if state.menu.is_some() { - return map_menu_key(key); - } - // The M5 dialogs are likewise modal overlays beside the phase. - if state.fuzzy_jump.is_some() { - return map_fuzzy_jump_key(key); - } - if let Some(dialog) = &state.find_file { - return map_find_file_key(key, dialog); - } - if state.user_menu.is_some() { - return map_user_menu_key(key); - } - if state.theme_picker.is_some() { - return map_theme_picker_key(key); - } - if let Some(dialog) = &state.help { - return map_help_key(key, dialog); - } - // The Enter-on-file action menu is likewise a modal overlay beside the - // phase (it opens without changing `state.phase` away from `Panels`), - // so it must be checked before the phase match below claims panel/ - // command-line keys (file-action-menu "Enter on a file opens the action - // menu"). - if let Some(dialog) = &state.file_action_menu { - return map_file_action_menu_key(key, dialog); - } - - match &state.phase { - UiPhase::FileOpSetup(setup) => map_file_op_setup_key(key, setup), - UiPhase::FileOpRunning { dialog, .. } => map_file_op_running_key(key, dialog), - UiPhase::FileOpSummary(_) => Some(Command::FileOpConfirm), + match input_layer(state) { + // The quit-confirmation dialog is the topmost layer of all: it can + // open above panels, the viewer, an open menu, or any other modal + // dialog/overlay (application-shell "Quit request keys and + // confirmation"; design D5). The event loop routes here for it even + // while `state.phase` is `Viewer`/`Editor` — see `app.rs` — except + // in the editor, whose own Ctrl+C=Copy binding means this flag can + // never become true there. + InputLayer::QuitConfirm => map_quit_confirm_key(key), + // Below the 60x16 floor the placeholder hides whatever screen is + // active — panels, an open overlay, a dialog, the viewer or the + // editor — and input must never reach a screen the user cannot see. + // Only the quit dialog above and the startup warning (an overlay the + // precedence function keeps above this layer), both drawn over the + // placeholder, keep their keys (application-shell "Resize handling + // with 60x16 hard floor and placeholder"; resize-preserves-session- + // state design D3). + InputLayer::Undersized => map_undersized_key(key, state), + InputLayer::FileOp => match &state.phase { + UiPhase::FileOpSetup(setup) => map_file_op_setup_key(key, setup), + UiPhase::FileOpRunning { dialog, .. } => map_file_op_running_key(key, dialog), + UiPhase::FileOpSummary(_) => Some(Command::FileOpConfirm), + _ => None, + }, // The viewer owns its own key routing (`map_viewer_key`), which // needs I/O (a backward/forward line-start scan) that this pure // mapper cannot perform — the event loop calls it directly instead // of `map_key` while the viewer is open (viewer: Frame-less - // full-screen chrome — "Viewer owns focus while open"). - UiPhase::Viewer(_) => None, - // The editor likewise owns its own key routing (`map_editor_key`). - // It needs no I/O, but its page-size parameter is the editor body's - // row count (term_size.1 - 2, full width, no panel layout) rather - // than `map_key`'s panel-layout-derived `page_size`, so the event - // loop calls it directly the same way it does for the viewer + // full-screen chrome — "Viewer owns focus while open"). The editor + // likewise owns its own key routing (`map_editor_key`). It needs no + // I/O, but its page-size parameter is the editor body's row count + // (term_size.1 - 2, full width, no panel layout) rather than + // `map_key`'s panel-layout-derived `page_size`, so the event loop + // calls it directly the same way it does for the viewer // (builtin-editor "Full-screen editor chrome"). - UiPhase::Editor(_) => None, - _ => { + InputLayer::Viewer | InputLayer::Editor => None, + // While a modal overlay is up it owns every key it understands, + // regardless of the phase underneath; a key its mapper does not bind + // is ignored and never reaches the panels or the command line + // (application-shell "Unbound keys never reach the panels or the + // command line"). + InputLayer::Overlay => state.overlay().and_then(|overlay| map_overlay_key(key, overlay)), + InputLayer::Splash | InputLayer::Panels => { if state.quick_search.is_some() { return map_quick_search_key(key, page_size); } @@ -101,6 +84,39 @@ pub fn map_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> O } } +/// The open overlay's own key mapping. +fn map_overlay_key(key: KeyEvent, overlay: &Overlay) -> Option { + match overlay { + Overlay::Menu(_) => map_menu_key(key), + Overlay::DriveSelect(_) => map_drive_select_key(key), + Overlay::FuzzyJump(_) => map_fuzzy_jump_key(key), + Overlay::FindFile(dialog) => map_find_file_key(key, dialog), + Overlay::UserMenu(_) => map_user_menu_key(key), + Overlay::HistoryDialog(_) => map_history_dialog_key(key), + Overlay::ThemePicker(_) => map_theme_picker_key(key), + Overlay::FileActionMenu(dialog) => map_file_action_menu_key(key, dialog), + Overlay::Help(dialog) => map_help_key(key, dialog), + Overlay::StartupWarning(_) => map_startup_warning_key(key), + } +} + +/// What a key means while the "terminal too small" placeholder is shown: +/// F10 and Esc ask to quit (the quit-confirmation dialog is drawn over the +/// placeholder), everything else is ignored. The one exception is a hidden +/// built-in editor: there F10 and Esc are ignored too, because a confirmed +/// quit would discard unsaved edits without the editor's own save-on-exit +/// prompt — the same way the global quit dialog cannot be opened from inside +/// the editor at full size (design D3). +fn map_undersized_key(key: KeyEvent, state: &State) -> Option { + if matches!(state.phase, UiPhase::Editor(_)) { + return None; + } + match key.code { + KeyCode::F(10) | KeyCode::Esc => Some(Command::RequestQuit), + _ => None, + } +} + /// What a key means while the F3 viewer is open. Simple toggles/prompt edits /// map straight to a `Command`; navigation needs a byte-source-backed scan /// (design D1's "the caller computes via `crate::viewer::backward`" — down @@ -165,6 +181,86 @@ fn is_plain(key: &KeyEvent) -> bool { !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) } +/// Route a terminal bracketed-paste (`Event::Paste`, reported only off +/// Windows — crossterm's Windows reader delivers a paste as ordinary key +/// events) to the text that owns the keyboard, as one batch of commands the +/// event loop applies before a single draw (idle-aware-event-loop design +/// D6; text-field-editing "Terminal paste arrives as one paste"): +/// +/// - wherever Ctrl+V would paste — a single-line text field owns the +/// keyboard (a dialog field, or the command line over the panels) — one +/// `TextEdit(Paste(..))` with line breaks flattened, exactly what Ctrl+V's +/// clipboard read produces. The target is *derived from the Ctrl+V +/// mapping* (probed with the compiled-in keymap, since the paste chords +/// are not rebindable), so there is no second routing table to drift; +/// - in the built-in editor's text, the commands the characters would +/// produce if typed — `EditorChar` per character, `EditorNewline` per +/// line break; +/// - in the editor's search/replace prompts and the viewer's search prompt, +/// the per-character prompt commands with line breaks flattened to +/// spaces, so a pasted line break never confirms the prompt; +/// - anywhere else (menus, confirmation and conflict dialogs, help, the +/// quit dialog): nothing — a pasted `y` must not answer a confirmation. +pub fn map_paste(text: &str, state: &State) -> Vec { + if text.is_empty() { + return Vec::new(); + } + // The same layer order as `map_key` (`input_layer`): the quit- + // confirmation dialog outranks even the viewer/editor's own routing — a + // paste never answers it — and nothing reaches a screen the placeholder + // hides. + if matches!(input_layer(state), InputLayer::QuitConfirm | InputLayer::Undersized) { + return Vec::new(); + } + match &state.phase { + UiPhase::Viewer(viewer) => { + if viewer.search_input.is_some() { + return flatten_line_breaks(text).chars().map(Command::ViewerSearchChar).collect(); + } + Vec::new() + } + UiPhase::Editor(editor) => { + if editor.quit_confirm { + return Vec::new(); + } + if editor.replace_prompt.is_some() { + return flatten_line_breaks(text).chars().map(Command::EditorReplaceChar).collect(); + } + if editor.search_prompt.is_some() { + return flatten_line_breaks(text).chars().map(Command::EditorSearchChar).collect(); + } + let mut commands = Vec::new(); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\r' => { + // CRLF is one line break, not two. + if chars.peek() == Some(&'\n') { + chars.next(); + } + commands.push(Command::EditorNewline); + } + '\n' => commands.push(Command::EditorNewline), + _ => commands.push(Command::EditorChar(c)), + } + } + commands + } + _ => { + // Where would Ctrl+V go right now? `RequestPaste` means a + // single-line text field owns the keyboard — this paste is that + // field's text. The paste keys are compiled in (not config- + // rebindable), so the default keymap probes the same answer. + let ctrl_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); + if matches!(map_key(ctrl_v, state, 0, &Keys::default()), Some(Command::RequestPaste)) { + vec![Command::TextEdit(TextEditOp::Paste(flatten_line_breaks(text)))] + } else { + Vec::new() + } + } + } +} + /// Map a key while the F4 built-in editor is open. Unlike the viewer, the /// editor's own commands need no file I/O to resolve (design D1's /// in-memory buffer), so this returns a plain `Command` rather than an @@ -282,12 +378,10 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> // The activation key is a toggle: pressed again while a filter is // already active on this panel, it exits and clears the filter // instead of restarting one (quick-filter "Clearing the quick - // filter": "the activation key toggles the filter"). - return Some(if state.active_panel().quick_filter.is_some() { - Command::QuickFilterEnd - } else { - Command::QuickFilterStart - }); + // filter": "the activation key toggles the filter"). The keymap + // passes the active side; the Left/Right menus pass their own + // (pulldown-menu-completion design D1). + return Some(if state.active_panel().quick_filter.is_some() { Command::QuickFilterEnd } else { Command::QuickFilterStart(active) }); } if matches_binding(&key, &keys.fuzzy_jump) { return Some(Command::FuzzyJumpOpen); @@ -302,6 +396,16 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> return Some(Command::SplitReset); } + // The clear-line chord only claims the key while the buffer is + // non-empty; on an empty line it falls through to nothing, so a + // mistaken press never does anything surprising (command-line "Clear + // the command line with a dedicated key": "Ctrl+Y on an empty line + // does nothing"; design D2). `CommandLineClear` also resets the + // history cursor, ending any recall in progress (design D3). + if typing && matches_binding(&key, &keys.clear_line) { + return Some(Command::CommandLineClear); + } + // Clipboard export (clipboard-export "Clipboard key bindings"). Checked // ahead of the ctrl/alt group and the general match below — in // particular ahead of the unguarded `Insert -> ToggleSelectAtCursor` @@ -328,13 +432,24 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> let alt = key.modifiers.contains(KeyModifiers::ALT); // Ctrl+T/Ctrl+W/Alt+1..9 panel tabs (panel-tabs "New tab", "Close tab", - // "Switch tab"). Checked ahead of the general match below only because + // "Switch tab"), plus Ctrl+U swap panels (panel-navigation "Swap panels + // (Ctrl+U)"). Checked ahead of the general match below only because // Alt+ would otherwise fall through to nothing (digits aren't - // claimed by any other alt-combination here). + // claimed by any other alt-combination here). The tab commands pass the + // active side; the Left/Right menus pass their own (design D1 of + // pulldown-menu-completion). if ctrl && !alt { match key.code { - KeyCode::Char('t') | KeyCode::Char('T') => return Some(Command::OpenTab), - KeyCode::Char('w') | KeyCode::Char('W') => return Some(Command::CloseTab), + KeyCode::Char('t') | KeyCode::Char('T') => return Some(Command::OpenTab(active)), + KeyCode::Char('w') | KeyCode::Char('W') => return Some(Command::CloseTab(active)), + KeyCode::Char('u') | KeyCode::Char('U') => return Some(Command::SwapPanels), + // Ctrl+V pastes the system clipboard's text into the command + // line (text-field-editing "Paste into a text field": "Over the + // panels, Ctrl+V SHALL paste into the command line"; design D4 — + // Shift+Ins stays the selection toggle here, so it is *not* a + // paste key over the panels, and Ctrl+Shift+Ins stays the + // clipboard-paths action, claimed above). + KeyCode::Char('v') | KeyCode::Char('V') => return Some(Command::RequestPaste), _ => {} } } @@ -360,6 +475,10 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> KeyCode::F(1) if alt => Some(Command::OpenDriveSelect(PanelSide::Left)), KeyCode::F(2) if alt => Some(Command::OpenDriveSelect(PanelSide::Right)), KeyCode::F(7) if alt => Some(Command::FindFileOpen), + // Alt+F8 opens the command-history dialog; guarded so it takes + // precedence over the unguarded F(8) delete arm below (command-line + // "Command history dialog (Alt+F8)"). + KeyCode::F(8) if alt => Some(Command::HistoryDialogOpen), KeyCode::F(1) => Some(Command::HelpOpen), KeyCode::F(2) => Some(Command::UserMenuOpen), @@ -383,20 +502,37 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> KeyCode::Char('o') | KeyCode::Char('O') if ctrl => Some(Command::ShowScrollback), // Up/Down walk command history while something is typed, and move - // the panel cursor when the buffer is empty. Backspacing the buffer - // to empty is the documented way to hand them back to the panel. + // the panel cursor when the buffer is empty. Backspacing the + // buffer to empty, or the clear-line chord above, is the + // documented way to hand them back to the panel. KeyCode::Up if typing => Some(Command::CommandLineHistoryPrev), KeyCode::Down if typing => Some(Command::CommandLineHistoryNext), KeyCode::Up => Some(Command::MoveCursor(CursorMove::Up(1))), KeyCode::Down => Some(Command::MoveCursor(CursorMove::Down(1))), + // Left/Right move the command-line caret while the line is + // non-empty — mirroring the Up/Down history rule (command-line + // "Command-line prompt and printable-key routing": "while the + // buffer is empty, Left and Right SHALL have no command-line + // meaning"); on an empty line they stay unbound, exactly as before. + // Home/End/PgUp/PgDn/Delete/Ctrl+Left/Ctrl+Right keep their panel + // meanings even mid-composition (design D3), so this helper is + // *not* `map_text_edit_key`: the command line's editing set is + // deliberately narrower than a dialog field's. + KeyCode::Left if typing && is_plain(&key) => Some(Command::TextEdit(TextEditOp::Left)), + KeyCode::Right if typing && is_plain(&key) => Some(Command::TextEdit(TextEditOp::Right)), + // Ctrl+Backspace deletes the word before the caret while typing + // (design D3). Some terminals deliver it as Ctrl+H instead — both + // are mapped (design Risks). + KeyCode::Backspace if typing && ctrl => Some(Command::TextEdit(TextEditOp::DeleteWordBack)), + KeyCode::Char('h') | KeyCode::Char('H') if typing && ctrl && !alt => Some(Command::TextEdit(TextEditOp::DeleteWordBack)), // Esc is unconditional at panel level: it asks to quit regardless of // command-line content, replacing the old clear-the-buffer meaning - // (backspacing to empty is now what hands Up/Down back to the panel - // — see the Backspace arms just below) (application-shell "Quit - // request keys and confirmation"; command-line "Command history - // navigation"). + // (backspacing to empty, or the clear-line chord, is now what hands + // Up/Down back to the panel — see the Backspace arms just below) + // (application-shell "Quit request keys and confirmation"; + // command-line "Command history navigation"). KeyCode::Esc => Some(Command::RequestQuit), - KeyCode::Backspace if typing => Some(Command::CommandLineBackspace), + KeyCode::Backspace if typing => Some(Command::TextEdit(TextEditOp::Backspace)), KeyCode::Backspace => Some(Command::ParentDir), KeyCode::PageUp if ctrl => Some(Command::ParentDir), @@ -425,7 +561,7 @@ fn map_panel_key(key: KeyEvent, state: &State, page_size: usize, keys: &Keys) -> KeyCode::Char('-') if is_plain(&key) && !typing => Some(Command::GroupDeselectAll), KeyCode::Char('*') if is_plain(&key) && !typing => Some(Command::InvertSelection), - KeyCode::Char(c) if is_plain(&key) => Some(Command::CommandLineChar(c)), + KeyCode::Char(c) if is_plain(&key) => Some(Command::TextEdit(TextEditOp::Insert(c))), _ => None, } } @@ -465,21 +601,21 @@ fn map_menu_key(key: KeyEvent) -> Option { } /// Ctrl+J fuzzy-jump dialog (fuzzy-jump "Fuzzy jump dialog invocation"). +/// The pattern is a shared text field per text-field-editing; Up/Down stay +/// with the result list. fn map_fuzzy_jump_key(key: KeyEvent) -> Option { match key.code { KeyCode::Esc => Some(Command::FuzzyJumpCancel), KeyCode::Enter => Some(Command::FuzzyJumpConfirm), - KeyCode::Backspace => Some(Command::FuzzyJumpBackspace), KeyCode::Up => Some(Command::FuzzyJumpMove(-1)), KeyCode::Down => Some(Command::FuzzyJumpMove(1)), - KeyCode::Char(c) if is_plain(&key) => Some(Command::FuzzyJumpChar(c)), - _ => None, + _ => map_text_edit_key(key), } } /// Alt+F7 find-file dialog. Its own precedence, highest first: the pattern -/// input stage (no search submitted yet) claims printables/Backspace/Enter; -/// once a search is in flight (or done), Up/Down/Enter move over and +/// input stage (no search submitted yet) claims the text-field keys and +/// Enter; once a search is in flight (or done), Up/Down/Enter move over and /// confirm a result instead (find-file "Find-file invocation", "Navigate to /// a chosen result"). fn map_find_file_key(key: KeyEvent, dialog: &FindFileState) -> Option { @@ -487,9 +623,7 @@ fn map_find_file_key(key: KeyEvent, dialog: &FindFileState) -> Option { return match key.code { KeyCode::Esc => Some(Command::FindFileCancel), KeyCode::Enter => Some(Command::FindFileSubmit), - KeyCode::Backspace => Some(Command::FindFileBackspace), - KeyCode::Char(c) if is_plain(&key) => Some(Command::FindFileChar(c)), - _ => None, + _ => map_text_edit_key(key), }; } match key.code { @@ -512,6 +646,22 @@ fn map_user_menu_key(key: KeyEvent) -> Option { } } +/// Alt+F8 command-history dialog (command-line "Command history dialog +/// (Alt+F8)"): a list overlay, not a text field — Home/End/PgUp/PgDn map to +/// huge deltas exactly as drive-select's own Home/End do, and printables do +/// nothing (the dialog never types). +fn map_history_dialog_key(key: KeyEvent) -> Option { + match key.code { + KeyCode::Esc => Some(Command::HistoryDialogCancel), + KeyCode::Enter => Some(Command::HistoryDialogConfirm), + KeyCode::Up => Some(Command::HistoryDialogMove(-1)), + KeyCode::Down => Some(Command::HistoryDialogMove(1)), + KeyCode::Home | KeyCode::PageUp => Some(Command::HistoryDialogMove(isize::MIN / 2)), + KeyCode::End | KeyCode::PageDown => Some(Command::HistoryDialogMove(isize::MAX / 2)), + _ => None, + } +} + /// Options → Themes picker (theme-selection "Picker navigation, apply, and /// cancel"). fn map_theme_picker_key(key: KeyEvent) -> Option { @@ -661,29 +811,76 @@ pub fn matches_binding(key: &KeyEvent, binding: &KeyBinding) -> bool { && (!binding.shift || key.modifiers.contains(KeyModifiers::SHIFT)) } +/// The shared single-line text-field key set (text-field-editing "Caret +/// model and editing keys"; design D2/D4): caret moves, word keys, +/// Backspace/Delete, paste, and printables, used by every dialog text field +/// — the destination/name input, the in-place Rename, the conflict dialog's +/// rename input, the find-file pattern, and the fuzzy-jump pattern. (The +/// command line deliberately does *not* use this helper: over the panels +/// several of these keys keep their panel meanings — see `map_panel_key` +/// and design D3.) +/// +/// Returns `Option` rather than `Option` so the paste +/// keys can emit `Command::RequestPaste` (design D4: the actual clipboard +/// text is read TUI-side and comes back as `TextEdit(Paste(..))`). +/// +/// Two terminals report Ctrl+Backspace differently: as `Backspace` with the +/// Ctrl modifier, or as Ctrl+H — both are mapped to `DeleteWordBack` +/// (design Risks). Plain printables are the only characters this helper +/// inserts; the destination/Rename/conflict fields historically typed the +/// base character of *any* `Char` event (their arm never filtered +/// modifiers), so those arms keep an unguarded `Char` fallback of their own +/// below — leaving Ctrl+V as the one Ctrl-combination whose behavior +/// changes there, exactly as design D4 calls out. +fn map_text_edit_key(key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let edit = |op| Some(Command::TextEdit(op)); + match key.code { + KeyCode::Left if ctrl => edit(TextEditOp::WordLeft), + KeyCode::Right if ctrl => edit(TextEditOp::WordRight), + KeyCode::Backspace if ctrl => edit(TextEditOp::DeleteWordBack), + KeyCode::Char('h') | KeyCode::Char('H') if ctrl => edit(TextEditOp::DeleteWordBack), + KeyCode::Delete if ctrl => edit(TextEditOp::DeleteWordForward), + KeyCode::Left => edit(TextEditOp::Left), + KeyCode::Right => edit(TextEditOp::Right), + KeyCode::Home => edit(TextEditOp::Home), + KeyCode::End => edit(TextEditOp::End), + KeyCode::Backspace => edit(TextEditOp::Backspace), + KeyCode::Delete => edit(TextEditOp::Delete), + KeyCode::Insert if key.modifiers.contains(KeyModifiers::SHIFT) => Some(Command::RequestPaste), + KeyCode::Char('v') | KeyCode::Char('V') if ctrl => Some(Command::RequestPaste), + KeyCode::Char(c) if is_plain(&key) => edit(TextEditOp::Insert(c)), + _ => None, + } +} + fn map_file_op_setup_key(key: KeyEvent, setup: &FileOpSetup) -> Option { // Ctrl+C no longer requests quit from any file-op setup dialog - // (application-shell "Quit request keys and confirmation"). Unlike the - // Progress/Help cancel arms, `DestinationInput`/`RenameInput`'s - // `Char(c) => FileOpInputChar(c)` below needs no extra guard: it has - // never filtered by modifier (every other Ctrl-letter already types its - // base character there today), so Ctrl+C typing `c` is consistent with - // that existing behavior rather than a new special case. + // (application-shell "Quit request keys and confirmation"). The + // destination/Rename fields' text keys now run through + // `map_text_edit_key`, whose unguarded `Char(c)` arm keeps the + // pre-existing "Ctrl+C types a literal c" behavior of these fields + // intact (see that helper's doc comment). match setup { FileOpSetup::DeleteConfirm { .. } => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => Some(Command::FileOpConfirm), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => Some(Command::FileOpCancel), _ => None, }, - // `RenameInput` reuses the same `FileOpInput*`/`FileOpConfirm`/ + // `RenameInput` reuses the same `TextEdit`/`FileOpConfirm`/ // `FileOpCancel` commands `DestinationInput` uses (fs_ops::dialog - // "Reuses the same ... commands `DestinationInput` uses"). + // "Reuses the same ... commands `DestinationInput` uses"). The + // `or_else` fallback preserves these fields' historical unguarded + // `Char` arm — "Ctrl+letter types its base character" (so Ctrl+C + // still types `c`) — for every character the helper didn't claim + // (word keys, Ctrl+V, Ctrl+H, and Shift+Ins win first). FileOpSetup::DestinationInput { .. } | FileOpSetup::RenameInput { .. } => match key.code { KeyCode::Enter => Some(Command::FileOpConfirm), KeyCode::Esc => Some(Command::FileOpCancel), - KeyCode::Backspace => Some(Command::FileOpInputBackspace), - KeyCode::Char(c) => Some(Command::FileOpInputChar(c)), - _ => None, + _ => map_text_edit_key(key).or(match key.code { + KeyCode::Char(c) => Some(Command::TextEdit(TextEditOp::Insert(c))), + _ => None, + }), }, } } @@ -704,9 +901,13 @@ fn map_file_op_running_key(key: KeyEvent, dialog: &RunningDialog) -> Option match key.code { KeyCode::Enter => Some(Command::FileOpConfirm), KeyCode::Esc => Some(Command::FileOpCancel), - KeyCode::Backspace => Some(Command::FileOpInputBackspace), - KeyCode::Char(c) => Some(Command::FileOpInputChar(c)), - _ => None, + // Same historical unguarded-`Char` fallback as the destination/ + // rename fields (Ctrl+letter types its base character) behind + // the shared helper's claims. + _ => map_text_edit_key(key).or(match key.code { + KeyCode::Char(c) => Some(Command::TextEdit(TextEditOp::Insert(c))), + _ => None, + }), }, RunningDialog::Conflict { rename_input: None, .. } => match key.code { KeyCode::Char('o') | KeyCode::Char('O') => Some(Command::FileOpConflictChoice(ConflictChoice::Overwrite)), @@ -714,6 +915,13 @@ fn map_file_op_running_key(key: KeyEvent, dialog: &RunningDialog) -> Option Some(Command::FileOpConflictChoice(ConflictChoice::Skip)), KeyCode::Char('a') | KeyCode::Char('A') => Some(Command::FileOpConflictChoice(ConflictChoice::SkipAll)), KeyCode::Char('r') | KeyCode::Char('R') => Some(Command::FileOpBeginRename), + // Cancel the job from the prompt — Esc or a plain `c`/`C`, + // mirroring the Progress dialog's own cancel above + // (operation-dialogs "Overwrite-conflict dialog": "Esc cancels + // the job from the conflict dialog"; process-lifecycle-hardening + // design D7). The plain guard keeps Ctrl+C out, as for Progress. + KeyCode::Esc => Some(Command::FileOpConflictChoice(ConflictChoice::Cancel)), + KeyCode::Char('c') | KeyCode::Char('C') if is_plain(&key) => Some(Command::FileOpConflictChoice(ConflictChoice::Cancel)), _ => None, }, RunningDialog::Error { .. } => match key.code { diff --git a/crates/filecommand-tui/src/input/mouse.rs b/crates/filecommand-tui/src/input/mouse.rs index 91dfaab..cd57559 100644 --- a/crates/filecommand-tui/src/input/mouse.rs +++ b/crates/filecommand-tui/src/input/mouse.rs @@ -18,7 +18,7 @@ use std::ffi::OsString; use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use filecommand_core::fs_ops::JobKind; use filecommand_core::update::{ClickMods, DropTarget}; -use filecommand_core::{Command, PanelSide, State, UiPhase}; +use filecommand_core::{input_layer, Command, InputLayer, Overlay, PanelSide, State}; use crate::hitmap::{self, HitMap}; @@ -139,50 +139,51 @@ enum Context { PulldownOpen, /// A modal dialog that accepts button clicks only — file-op setup, /// running-job (conflict/error/progress), summary, or the quit-confirm - /// overlay (which can appear over any of the others, hence checked - /// first in `context`). + /// dialog (which can appear over any of the others, and is the topmost + /// layer of the precedence order). DialogButtons, /// The file-action menu is open: only its own row rects and click- /// outside-to-close are live — no hover-highlight-on-move (mouse-input /// "File-action menu entries are clickable"). FileActionMenuOpen, /// An overlay mouse-basics does not support yet (drive select, fuzzy - /// jump, find file, user menu, theme picker, help, the startup warning) - /// or a phase with nothing clickable (splash, placeholder) — every - /// event is ignored. + /// jump, find file, user menu, the Alt+F8 history dialog, theme picker, + /// help, the startup warning), a phase with nothing clickable (splash), + /// or the "terminal too small" placeholder hiding the active screen — + /// every event is ignored. Ignored, } +/// The mode-gating table, keyed by the layer that owns input right now — +/// the single precedence order `filecommand_core::input_layer` defines, the +/// same one `map_key` and the reducer route by (reducer-overlay-structure +/// design D2; mouse-input "Mouse is honoured only where the key would be"). fn context(state: &State) -> Context { - // The quit-confirmation dialog can open above panels, the viewer, an - // open menu, or any other modal dialog/overlay (application-shell - // "Quit request keys and confirmation"), so it is checked before - // everything else here too, mirroring `core::update`'s own precedence. - if state.quit_confirm { - return Context::DialogButtons; - } - if state.startup_warning.is_some() - || state.drive_select.is_some() - || state.fuzzy_jump.is_some() - || state.find_file.is_some() - || state.user_menu.is_some() - || state.theme_picker.is_some() - || state.help.is_some() - { - return Context::Ignored; - } - if state.file_action_menu.is_some() { - return Context::FileActionMenuOpen; - } - match &state.phase { - UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_) => Context::DialogButtons, - UiPhase::Panels if state.menu.is_some() => Context::PulldownOpen, - UiPhase::Panels => Context::Panels, - // Splash/Placeholder have nothing clickable; Viewer/Editor are - // never routed here at all (see the module doc comment) — kept as - // an explicit arm rather than a catch-all so a future `UiPhase` - // variant doesn't silently fall through to "ignored" unnoticed. - UiPhase::Splash { .. } | UiPhase::Placeholder | UiPhase::Viewer(_) | UiPhase::Editor(_) => Context::Ignored, + match input_layer(state) { + // The quit-confirmation dialog can open above panels, the viewer, an + // open menu, or any other modal dialog/overlay (application-shell + // "Quit request keys and confirmation"): its buttons are the only + // live target then. + InputLayer::QuitConfirm => Context::DialogButtons, + // Below the 60x16 floor the placeholder hides the active screen, so + // no click or wheel notch may reach it — the quit dialog's buttons, + // drawn over the placeholder, stay clickable through the arm above + // (application-shell "Resize handling with 60x16 hard floor and + // placeholder"; resize-preserves-session-state design D3). + InputLayer::Undersized => Context::Ignored, + InputLayer::FileOp => Context::DialogButtons, + // Splash has nothing clickable; Viewer/Editor are never routed here + // at full size (see the module doc comment). + InputLayer::Viewer | InputLayer::Editor | InputLayer::Splash => Context::Ignored, + // Each overlay keeps its own mouse policy: the F9 pull-down and the + // file-action menu take clicks on their own rows (and a click + // outside closes them); every other overlay ignores the mouse. + InputLayer::Overlay => match state.overlay() { + Some(Overlay::Menu(_)) => Context::PulldownOpen, + Some(Overlay::FileActionMenu(_)) => Context::FileActionMenuOpen, + _ => Context::Ignored, + }, + InputLayer::Panels => Context::Panels, } } diff --git a/crates/filecommand-tui/src/input/mouse/tests.rs b/crates/filecommand-tui/src/input/mouse/tests.rs index b20417b..5634ee4 100644 --- a/crates/filecommand-tui/src/input/mouse/tests.rs +++ b/crates/filecommand-tui/src/input/mouse/tests.rs @@ -49,7 +49,8 @@ fn sample_hitmap() -> HitMap { }; hm.keybar = vec![(Rect { x: 10, y: 20, width: 5, height: 1 }, 5)]; hm.menu_titles = vec![(Rect { x: 5, y: 0, width: 5, height: 1 }, MenuId::Files)]; - hm.dialog_buttons = vec![(Rect { x: 1, y: 1, width: 4, height: 1 }, ButtonId::QuitYes), (Rect { x: 6, y: 1, width: 3, height: 1 }, ButtonId::QuitNo)]; + hm.dialog_buttons = + vec![(Rect { x: 1, y: 1, width: 4, height: 1 }, ButtonId::QuitYes), (Rect { x: 6, y: 1, width: 3, height: 1 }, ButtonId::QuitNo)]; hm } @@ -329,7 +330,7 @@ fn right_click_clears_the_double_click_chain() { fn right_click_while_a_pulldown_is_open_does_nothing() { let hitmap = sample_hitmap(); let mut state = test_state(); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); let mut tracker = MouseTracker::new(); let cmd = map_mouse(ev(MouseEventKind::Down(MouseButton::Right), 3, 3), &hitmap, &mut tracker, &state); assert_eq!(cmd, None); @@ -369,7 +370,7 @@ fn wheel_outside_both_panels_does_nothing() { fn an_ignored_overlay_returns_none_even_over_a_hit_row() { let hitmap = sample_hitmap(); let mut state = test_state(); - state.help = Some(filecommand_core::dialogs::HelpState::new()); + state.open_overlay(Overlay::Help(filecommand_core::dialogs::HelpState::new())); let mut tracker = MouseTracker::new(); map_mouse(ev(MouseEventKind::Down(MouseButton::Left), 3, 3), &hitmap, &mut tracker, &state); let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 3, 3), &hitmap, &mut tracker, &state); @@ -408,7 +409,7 @@ fn pulldown_item_click_dispatches_menu_item_click() { let mut hitmap = sample_hitmap(); hitmap.menu_items = vec![(Rect { x: 6, y: 2, width: 8, height: 1 }, 2)]; let mut state = test_state(); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); let mut tracker = MouseTracker::new(); map_mouse(ev(MouseEventKind::Down(MouseButton::Left), 8, 2), &hitmap, &mut tracker, &state); let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 8, 2), &hitmap, &mut tracker, &state); @@ -420,7 +421,7 @@ fn pulldown_click_elsewhere_closes_the_bar() { let mut hitmap = sample_hitmap(); hitmap.menu_items = vec![(Rect { x: 6, y: 2, width: 8, height: 1 }, 2)]; let mut state = test_state(); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); let mut tracker = MouseTracker::new(); // Lands on the left panel's entry row — while a pull-down is open, // panel rows are not honoured at all; the click just closes the bar. @@ -437,7 +438,7 @@ fn file_action_menu_item_click_dispatches_file_action_menu_item_click() { let mut hitmap = sample_hitmap(); hitmap.file_action_menu_items = vec![(Rect { x: 6, y: 2, width: 8, height: 1 }, 2)]; let mut state = test_state(); - state.file_action_menu = Some(FileActionMenuState::new(OsString::from("a.txt"), false)); + state.open_overlay(Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))); let mut tracker = MouseTracker::new(); map_mouse(ev(MouseEventKind::Down(MouseButton::Left), 8, 2), &hitmap, &mut tracker, &state); let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 8, 2), &hitmap, &mut tracker, &state); @@ -452,7 +453,7 @@ fn file_action_menu_click_elsewhere_closes_it() { let mut hitmap = sample_hitmap(); hitmap.file_action_menu_items = vec![(Rect { x: 6, y: 2, width: 8, height: 1 }, 2)]; let mut state = test_state(); - state.file_action_menu = Some(FileActionMenuState::new(OsString::from("a.txt"), false)); + state.open_overlay(Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))); let mut tracker = MouseTracker::new(); // Lands on the left panel's entry row — while the file-action menu is // open, panel rows are not honoured at all; the click just closes it. @@ -482,7 +483,7 @@ fn file_op_setup_phase_honours_dialog_buttons_only() { source_dir: PathBuf::new(), original_name: OsString::from("a.txt"), is_dir: false, - input: String::new(), + input: filecommand_core::line_edit::LineEdit::default(), }); let mut tracker = MouseTracker::new(); @@ -526,28 +527,37 @@ fn assert_ignores_mouse_over_a_hit_row(state: State) { #[test] fn drive_select_overlay_ignores_mouse() { let mut state = test_state(); - state.drive_select = Some(DriveSelect::new(PanelSide::Left, vec!['C'], Some('C'))); + state.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C'], Some('C')))); assert_ignores_mouse_over_a_hit_row(state); } #[test] fn fuzzy_jump_overlay_ignores_mouse() { let mut state = test_state(); - state.fuzzy_jump = Some(FuzzyJumpState::new()); + state.open_overlay(Overlay::FuzzyJump(FuzzyJumpState::new())); assert_ignores_mouse_over_a_hit_row(state); } #[test] fn find_file_overlay_ignores_mouse() { let mut state = test_state(); - state.find_file = Some(FindFileState::new(PathBuf::from(r"C:\"))); + state.open_overlay(Overlay::FindFile(FindFileState::new(PathBuf::from(r"C:\")))); assert_ignores_mouse_over_a_hit_row(state); } #[test] fn user_menu_overlay_ignores_mouse() { let mut state = test_state(); - state.user_menu = Some(UserMenuState::new()); + state.open_overlay(Overlay::UserMenu(UserMenuState::new())); + assert_ignores_mouse_over_a_hit_row(state); +} + +/// command-line "Command history dialog (Alt+F8)": "The dialog SHALL NOT +/// respond to the mouse (it joins the unsupported-overlay set)." +#[test] +fn history_dialog_overlay_ignores_mouse() { + let mut state = test_state(); + state.open_overlay(Overlay::HistoryDialog(filecommand_core::dialogs::HistoryDialogState::new())); assert_ignores_mouse_over_a_hit_row(state); } @@ -555,14 +565,14 @@ fn user_menu_overlay_ignores_mouse() { fn theme_picker_overlay_ignores_mouse() { let mut state = test_state(); let name = state.theme.name.clone(); - state.theme_picker = Some(ThemePickerState::open(&name)); + state.open_overlay(Overlay::ThemePicker(ThemePickerState::open(&name))); assert_ignores_mouse_over_a_hit_row(state); } #[test] fn startup_warning_overlay_ignores_mouse() { let mut state = test_state(); - state.startup_warning = Some("malformed usermenu.toml".to_string()); + state.set_startup_warning("malformed usermenu.toml".to_string()); assert_ignores_mouse_over_a_hit_row(state); } @@ -578,13 +588,63 @@ fn splash_phase_ignores_mouse() { assert_ignores_mouse_over_a_hit_row(state); } -#[test] -fn placeholder_phase_ignores_mouse() { +/// application-shell "Mouse input is ignored while undersized": the +/// placeholder is `State::undersized` beside the phase now, not a phase, so +/// it must ignore the mouse over *every* hidden screen (resize-preserves- +/// session-state design D3). +#[test] +fn undersized_ignores_mouse_over_every_phase() { + let phases = [ + UiPhase::Panels, + UiPhase::Splash { started_at_ms: 0 }, + UiPhase::Viewer(filecommand_core::viewer::ViewerState::new(PathBuf::from("f.txt"), 0)), + UiPhase::Editor(filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), b"").expect("valid UTF-8 fixture")), + UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + }), + UiPhase::FileOpSummary(vec![]), + ]; + for phase in phases { + let mut state = test_state(); + state.undersized = true; + state.phase = phase; + assert_ignores_mouse_over_a_hit_row(state); + } + // Overlays beside the phase are hidden too — a menu's title must not be + // clickable under the placeholder. + let mut state = test_state(); + state.undersized = true; + state.open_overlay(Overlay::Menu(MenuState::opened())); + assert_ignores_mouse_over_a_hit_row(state); let mut state = test_state(); - state.phase = UiPhase::Placeholder; + state.undersized = true; + state.open_overlay(Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))); assert_ignores_mouse_over_a_hit_row(state); } +/// application-shell "Quit dialog buttons stay clickable over the +/// placeholder": `quit_confirm` is checked before `undersized` in +/// `context`, so a click on the dialog's No button still maps. +#[test] +fn undersized_quit_dialog_buttons_stay_clickable() { + let hitmap = sample_hitmap(); + let mut state = test_state(); + state.undersized = true; + state.quit_confirm = true; + let mut tracker = MouseTracker::new(); + map_mouse(ev(MouseEventKind::Down(MouseButton::Left), 7, 1), &hitmap, &mut tracker, &state); + let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 7, 1), &hitmap, &mut tracker, &state); + assert_eq!(cmd, Some(Command::DialogButtonClick(ButtonId::QuitNo))); + // Off the buttons, nothing underneath is reachable. + map_mouse(ev(MouseEventKind::Down(MouseButton::Left), 3, 3), &hitmap, &mut tracker, &state); + let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 3, 3), &hitmap, &mut tracker, &state); + assert_eq!(cmd, None); +} + /// The viewer/editor phases are never actually routed through `map_mouse` /// in production (the event loop dispatches their wheel-only handling /// directly from `state.phase` — see this module's doc comment); this just @@ -601,7 +661,7 @@ fn viewer_phase_ignores_map_mouse_if_ever_called_directly() { #[test] fn editor_phase_ignores_map_mouse_if_ever_called_directly() { let mut state = test_state(); - state.phase = UiPhase::Editor(filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), b"")); + state.phase = UiPhase::Editor(filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), b"").expect("valid UTF-8 fixture")); assert_ignores_mouse_over_a_hit_row(state); } @@ -620,3 +680,289 @@ fn reset_clears_an_in_progress_press_and_the_double_click_chain() { let cmd = map_mouse(ev(MouseEventKind::Up(MouseButton::Left), 3, 3), &hitmap, &mut tracker, &state); assert!(matches!(cmd, Some(Command::ClickEntry { .. })), "reset() must break the double-click chain"); } + +// --------------------------------------------------------------------- +// reducer-overlay-structure: click targets come from the rendered geometry +// (mouse-input "Click targets match the rendered geometry"; tasks 5.5, 6.8) +// --------------------------------------------------------------------- + +/// Render `state` at `size` and return the frame's painted text and the hit +/// map `map_mouse` would use for the next frame's events. +fn rendered(state: &State, size: (u16, u16)) -> (Vec, HitMap) { + let area = Rect { x: 0, y: 0, width: size.0, height: size.1 }; + let mut buf = ratatui::buffer::Buffer::empty(area); + let identity = ["FileCommand".to_string(), "v0".to_string(), "test".to_string(), "fixture".to_string()]; + let out = crate::views::render(&mut buf, area, state, filecommand_core::theme::ColorDepth::Ansi16, &identity, "3:04 PM", None); + let rows = (0..size.1).map(|y| (0..size.0).map(|x| buf[(x, y)].symbol()).collect::()).collect(); + (rows, out.hitmap) +} + +/// A left Down+Up on one cell. +fn click_at(hitmap: &HitMap, state: &State, x: u16, y: u16) -> Option { + let mut tracker = MouseTracker::new(); + map_mouse(ev(MouseEventKind::Down(MouseButton::Left), x, y), hitmap, &mut tracker, state); + map_mouse(ev(MouseEventKind::Up(MouseButton::Left), x, y), hitmap, &mut tracker, state) +} + +/// The screen column range `needle` occupies on `row`, as display columns. +fn span_of(row: &str, needle: &str) -> std::ops::Range { + let byte = row.find(needle).unwrap_or_else(|| panic!("{needle:?} is painted on {row:?}")); + let start = filecommand_core::listing::display_width(&row[..byte]) as u16; + start..start + filecommand_core::listing::display_width(needle) as u16 +} + +fn sized_state(size: (u16, u16), phase: UiPhase) -> State { + let mut state = test_state(); + state.term_size = size; + state.phase = phase; + state +} + +/// mouse-input "Delete confirmation buttons at the size floor": a click on +/// any cell of the drawn `(Y)es` confirms and on any cell of `(N)o` +/// cancels; the frame, the truncated question, and the spaces between the +/// labels do nothing. +#[test] +fn delete_confirm_buttons_at_the_size_floor_match_the_drawn_labels() { + let name = "n".repeat(200); + let setup = FileOpSetup::DeleteConfirm { + sources: vec![filecommand_core::fs_ops::SourceItem { + original_name: OsString::from(&name), + path: PathBuf::from("/left").join(&name), + is_dir: false, + }], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + }; + let state = sized_state((60, 16), UiPhase::FileOpSetup(setup)); + let (rows, hm) = rendered(&state, (60, 16)); + let question_y = rows.iter().position(|r| r.contains("Delete n") && r.contains('\u{2026}')).expect("the question is truncated with …") as u16; + let buttons_y = rows.iter().position(|r| r.contains("(Y)es")).expect("the buttons row") as u16; + let yes = span_of(&rows[buttons_y as usize], "(Y)es"); + let no = span_of(&rows[buttons_y as usize], "(N)o"); + for x in yes.clone() { + assert_eq!(click_at(&hm, &state, x, buttons_y), Some(Command::DialogButtonClick(ButtonId::DeleteConfirmYes)), "cell {x}"); + } + for x in no.clone() { + assert_eq!(click_at(&hm, &state, x, buttons_y), Some(Command::DialogButtonClick(ButtonId::DeleteConfirmNo)), "cell {x}"); + } + for x in yes.end..no.start { + assert_eq!(click_at(&hm, &state, x, buttons_y), None, "the gap between the labels at {x}"); + } + assert_eq!(click_at(&hm, &state, yes.start, question_y), None, "the question row"); + assert_eq!(click_at(&hm, &state, yes.start - 1, buttons_y), None, "left of the first label"); + let frame_y = buttons_y + 1; + assert_eq!(click_at(&hm, &state, yes.start, frame_y), None, "the bottom frame"); + assert_eq!(click_at(&hm, &state, 0, 0), None, "the panels underneath are not live"); +} + +/// mouse-input "Quit dialog hotkey cells": the drawn `Y` confirms, the +/// drawn `N` cancels, every other cell of the dialog does nothing — at 80x24 +/// and at the 60x16 floor. +#[test] +fn quit_dialog_hotkey_cells_match_the_drawn_message() { + for size in [(80, 24), (60, 16)] { + let mut state = sized_state(size, UiPhase::Panels); + state.quit_confirm = true; + let (rows, hm) = rendered(&state, size); + let y = rows.iter().position(|r| r.contains("Quit FileCommand? (Y/N)")).expect("the message row") as u16; + let row = &rows[y as usize]; + let y_col = span_of(row, "(Y/N)").start + 1; + let n_col = y_col + 2; + assert_eq!(click_at(&hm, &state, y_col, y), Some(Command::DialogButtonClick(ButtonId::QuitYes)), "{size:?}"); + assert_eq!(click_at(&hm, &state, n_col, y), Some(Command::DialogButtonClick(ButtonId::QuitNo)), "{size:?}"); + let left = span_of(row, "Quit"); + for x in left.start.saturating_sub(1)..left.end { + assert_eq!(click_at(&hm, &state, x, y), None, "{size:?}: a message cell at {x}"); + } + assert_eq!(click_at(&hm, &state, y_col + 1, y), None, "{size:?}: the slash between Y and N"); + assert_eq!(click_at(&hm, &state, y_col, y - 1), None, "{size:?}: the top frame"); + assert_eq!(click_at(&hm, &state, y_col, y + 1), None, "{size:?}: the bottom frame"); + } +} + +/// mouse-input "Progress dialog Cancel follows a resize": after the +/// terminal shrinks from 120x40 to 80x24 the button is where the new frame +/// draws it, and a cell it occupied before — but no longer does — is dead. +#[test] +fn progress_cancel_follows_a_resize() { + let phase = UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + dialog: filecommand_core::fs_ops::dialog::RunningDialog::Progress { + kind: JobKind::Copy, + progress: filecommand_core::fs_ops::ProgressInfo::starting(3, 30), + }, + }; + let large = sized_state((120, 40), phase.clone()); + let (large_rows, _) = rendered(&large, (120, 40)); + let large_y = large_rows.iter().position(|r| r.contains("[ Cancel ]")).expect("the button at 120x40") as u16; + let large_x = span_of(&large_rows[large_y as usize], "[ Cancel ]"); + + let (small, _) = filecommand_core::update(large, Command::Resize(80, 24)); + let (small_rows, hm) = rendered(&small, (80, 24)); + let small_y = small_rows.iter().position(|r| r.contains("[ Cancel ]")).expect("the button at 80x24") as u16; + let small_x = span_of(&small_rows[small_y as usize], "[ Cancel ]"); + assert_ne!((large_x.start, large_y), (small_x.start, small_y), "the dialog moved with the resize"); + for x in small_x.clone() { + assert_eq!(click_at(&hm, &small, x, small_y), Some(Command::DialogButtonClick(ButtonId::ProgressCancel)), "cell {x} of the button now"); + } + // The old position is off the 80x24 screen or on a dead cell either way. + let old = (large_x.start, large_y); + if old.0 < 80 && old.1 < 24 { + assert_eq!(click_at(&hm, &small, old.0, old.1), None, "where the button was drawn before the resize"); + } + assert_eq!(click_at(&hm, &small, small_x.start, small_y + 1), None, "the frame below the button"); + assert_eq!(click_at(&hm, &small, small_x.end, small_y), None, "just past the label"); +} + +/// 6.8 "One precedence order everywhere": for states covering every +/// `InputLayer` and every overlay variant, the key router, the mouse +/// context, and the frame's hit map all treat the layer `input_layer` names +/// as the one that owns input. +#[test] +fn key_routing_mouse_context_and_the_hit_map_agree_with_input_layer() { + use filecommand_core::dialogs::{HelpState, HistoryDialogState}; + use filecommand_core::input_layer; + use filecommand_core::InputLayer; + + use crate::input::map_key; + + let esc = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Esc, KeyModifiers::NONE); + let letter = crossterm::event::KeyEvent::new(crossterm::event::KeyCode::Char('a'), KeyModifiers::NONE); + let keys = filecommand_core::config::Keys::default(); + let size = (80, 24); + let delete_confirm = UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + }); + let overlays: Vec<(&str, Overlay)> = vec![ + ("menu", Overlay::Menu(MenuState::for_menu(MenuId::Files))), + ("drive select", Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C'], Some('C')))), + ("fuzzy jump", Overlay::FuzzyJump(FuzzyJumpState::new())), + ("find file", Overlay::FindFile(FindFileState::new(PathBuf::from("/left")))), + ("user menu", Overlay::UserMenu(UserMenuState::new())), + ("history dialog", Overlay::HistoryDialog(HistoryDialogState::new())), + ("theme picker", Overlay::ThemePicker(ThemePickerState::open("nc-classic"))), + ("file-action menu", Overlay::FileActionMenu(FileActionMenuState::new(OsString::from("a.txt"), false))), + ("help", Overlay::Help(HelpState::new())), + ("startup warning", Overlay::StartupWarning("usermenu.toml is malformed".to_string())), + ]; + + let mut cases: Vec<(String, State)> = vec![ + ("panels".to_string(), sized_state(size, UiPhase::Panels)), + ("splash".to_string(), sized_state(size, UiPhase::Splash { started_at_ms: 0 })), + ("file-op dialog".to_string(), sized_state(size, delete_confirm.clone())), + ("viewer".to_string(), sized_state(size, UiPhase::Viewer(filecommand_core::viewer::ViewerState::new(PathBuf::from("f.txt"), 0)))), + ( + "editor".to_string(), + sized_state( + size, + UiPhase::Editor(filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), b"x").expect("valid UTF-8 fixture")), + ), + ), + ]; + for (name, overlay) in &overlays { + let mut state = sized_state(size, UiPhase::Panels); + state.open_overlay(overlay.clone()); + cases.push((format!("{name} over the panels"), state)); + } + let mut quit_over_menu = sized_state(size, UiPhase::Panels); + quit_over_menu.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + quit_over_menu.quit_confirm = true; + cases.push(("quit over the menu".to_string(), quit_over_menu)); + let mut quit_over_dialog = sized_state(size, delete_confirm); + quit_over_dialog.quit_confirm = true; + cases.push(("quit over a file-op dialog".to_string(), quit_over_dialog)); + for undersized_over in ["panels", "menu over the panels", "startup warning over the panels", "file-op dialog", "viewer", "editor"] { + let mut state = cases.iter().find(|(n, _)| n == undersized_over).map(|(_, s)| s.clone()).expect("a base case"); + state.term_size = (40, 10); + state.undersized = true; + cases.push((format!("undersized {undersized_over}"), state)); + } + + for (name, state) in cases { + let layer = input_layer(&state); + let size = state.term_size; + let (_, hm) = rendered(&state, size); + let has_quit_buttons = hm.dialog_buttons.iter().any(|(_, id)| matches!(id, ButtonId::QuitYes | ButtonId::QuitNo)); + let has_dialog_buttons = hm.dialog_buttons.iter().any(|(_, id)| !matches!(id, ButtonId::QuitYes | ButtonId::QuitNo)); + let page = 5; + let on_esc = map_key(esc, &state, page, &keys); + let on_letter = map_key(letter, &state, page, &keys); + match layer { + InputLayer::QuitConfirm => { + assert!(matches!(context(&state), Context::DialogButtons), "{name}"); + assert!(has_quit_buttons, "{name}: the quit dialog's buttons are recorded"); + assert_eq!(on_esc, Some(Command::CancelQuit), "{name}"); + assert_eq!(on_letter, None, "{name}: a letter never reaches the layers below"); + } + InputLayer::Undersized => { + assert!(matches!(context(&state), Context::Ignored), "{name}"); + assert!( + !has_quit_buttons && !has_dialog_buttons && hm.menu_titles.is_empty() && hm.keybar.is_empty(), + "{name}: nothing hidden is clickable" + ); + assert_eq!(on_letter, None, "{name}"); + let expected = if matches!(state.phase, UiPhase::Editor(_)) { None } else { Some(Command::RequestQuit) }; + assert_eq!(on_esc, expected, "{name}"); + } + InputLayer::FileOp => { + assert!(matches!(context(&state), Context::DialogButtons), "{name}"); + assert!(has_dialog_buttons && !has_quit_buttons, "{name}: the dialog's own buttons are recorded"); + assert_eq!(on_esc, Some(Command::FileOpCancel), "{name}"); + } + InputLayer::Viewer | InputLayer::Editor => { + assert!(matches!(context(&state), Context::Ignored), "{name}"); + assert!( + hm.dialog_buttons.is_empty() && hm.keybar.is_empty() && hm.panel(PanelSide::Left).rows.is_empty(), + "{name}: nothing is clickable" + ); + assert_eq!(on_esc, None, "{name}: the event loop maps these keys directly"); + } + InputLayer::Overlay => { + let overlay = state.overlay().expect("the overlay layer has a slot"); + match overlay { + Overlay::Menu(_) => { + assert!(matches!(context(&state), Context::PulldownOpen), "{name}"); + assert!(!hm.menu_titles.is_empty() && !hm.menu_items.is_empty(), "{name}: the bar and its rows are recorded"); + assert_eq!(on_esc, Some(Command::MenuCollapse), "{name}"); + } + Overlay::FileActionMenu(_) => { + assert!(matches!(context(&state), Context::FileActionMenuOpen), "{name}"); + assert!(!hm.file_action_menu_items.is_empty(), "{name}: its rows are recorded"); + assert_eq!(on_esc, Some(Command::FileActionMenuCancel), "{name}"); + } + _ => { + assert!(matches!(context(&state), Context::Ignored), "{name}"); + assert!(hm.menu_titles.is_empty() && hm.file_action_menu_items.is_empty(), "{name}: no menu rows are recorded"); + let esc_cmd = on_esc.clone().unwrap_or_else(|| panic!("{name}: Esc is the overlay's own cancel")); + assert!(overlay.claims(&esc_cmd), "{name}: {esc_cmd:?} belongs to the open overlay"); + assert_ne!(esc_cmd, Command::RequestQuit, "{name}"); + } + } + assert!(!has_dialog_buttons && !has_quit_buttons, "{name}: no dialog buttons"); + if let Some(cmd) = &on_letter { + assert!(overlay.claims(cmd), "{name}: {cmd:?} is the overlay's own, never a panel command"); + } + } + InputLayer::Panels => { + assert!(matches!(context(&state), Context::Panels), "{name}"); + assert!( + !hm.keybar.is_empty() && hm.dialog_buttons.is_empty() && hm.menu_titles.is_empty(), + "{name}: the panels and key bar are live, nothing modal is" + ); + assert_eq!(on_esc, Some(Command::RequestQuit), "{name}"); + } + InputLayer::Splash => { + assert!(matches!(context(&state), Context::Ignored), "{name}"); + assert!(hm.keybar.is_empty() && hm.dialog_buttons.is_empty(), "{name}: the splash has nothing clickable"); + } + } + } +} diff --git a/crates/filecommand-tui/src/input/tests.rs b/crates/filecommand-tui/src/input/tests.rs index bd429c9..74c787c 100644 --- a/crates/filecommand-tui/src/input/tests.rs +++ b/crates/filecommand-tui/src/input/tests.rs @@ -16,12 +16,7 @@ fn plain(code: KeyCode) -> KeyEvent { } fn state_with(phase: UiPhase) -> State { - State { - left: PanelState::new(PathBuf::from("/left")), - right: PanelState::new(PathBuf::from("/right")), - phase, - ..State::empty(Theme::classic()) - } + State { left: PanelState::new(PathBuf::from("/left")), right: PanelState::new(PathBuf::from("/right")), phase, ..State::empty(Theme::classic()) } } fn panels() -> State { @@ -29,7 +24,7 @@ fn panels() -> State { } fn typing(text: &str) -> State { - State { command_line: text.to_string(), ..panels() } + State { command_line: filecommand_core::line_edit::LineEdit::from(text), ..panels() } } fn map(key: KeyEvent, state: &State) -> Option { @@ -84,7 +79,7 @@ fn quit_dialog_outranks_every_other_overlay_and_phase() { assert_eq!(map(plain(KeyCode::Char('y')), &over_viewer), Some(Command::ConfirmQuit)); let mut over_menu = panels(); - over_menu.menu = Some(MenuState::opened()); + over_menu.open_overlay(Overlay::Menu(MenuState::opened())); over_menu.quit_confirm = true; assert_eq!(map(plain(KeyCode::Esc), &over_menu), Some(Command::CancelQuit), "not MenuCollapse"); } @@ -142,20 +137,160 @@ fn destination_input_state() -> State { kind: filecommand_core::fs_ops::JobKind::Copy, sources: vec![], source_dir: PathBuf::from("/left"), - input: String::new(), + input: filecommand_core::line_edit::LineEdit::default(), buttons: None, + hidden_by_filter: 0, })) } #[test] fn destination_input_routes_typing_and_confirm_cancel() { let state = destination_input_state(); - assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::FileOpInputChar('x'))); - assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::FileOpInputBackspace)); + assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::TextEdit(TextEditOp::Insert('x')))); + assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::TextEdit(TextEditOp::Backspace))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::FileOpConfirm)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FileOpCancel)); } +// --------------------------------------------------------------------- +// line-editing-in-text-fields: the shared text-field key set +// (text-field-editing "Caret model and editing keys"; tasks.md 3.1/3.3) +// --------------------------------------------------------------------- + +#[test] +fn dialog_fields_map_the_full_editing_key_set() { + // The destination dialog stands in for every field here; the arms all + // share `map_text_edit_key` (proven by the next test). + let state = destination_input_state(); + assert_eq!(map(plain(KeyCode::Left), &state), Some(Command::TextEdit(TextEditOp::Left))); + assert_eq!(map(plain(KeyCode::Right), &state), Some(Command::TextEdit(TextEditOp::Right))); + assert_eq!(map(plain(KeyCode::Home), &state), Some(Command::TextEdit(TextEditOp::Home))); + assert_eq!(map(plain(KeyCode::End), &state), Some(Command::TextEdit(TextEditOp::End))); + assert_eq!(map(plain(KeyCode::Delete), &state), Some(Command::TextEdit(TextEditOp::Delete))); + assert_eq!(map(key(KeyCode::Left, KeyModifiers::CONTROL), &state), Some(Command::TextEdit(TextEditOp::WordLeft))); + assert_eq!(map(key(KeyCode::Right, KeyModifiers::CONTROL), &state), Some(Command::TextEdit(TextEditOp::WordRight))); + assert_eq!(map(key(KeyCode::Backspace, KeyModifiers::CONTROL), &state), Some(Command::TextEdit(TextEditOp::DeleteWordBack))); + // Some terminals deliver Ctrl+Backspace as Ctrl+H — both are mapped + // (design Risks). + assert_eq!(map(key(KeyCode::Char('h'), KeyModifiers::CONTROL), &state), Some(Command::TextEdit(TextEditOp::DeleteWordBack))); + assert_eq!(map(key(KeyCode::Delete, KeyModifiers::CONTROL), &state), Some(Command::TextEdit(TextEditOp::DeleteWordForward))); + assert_eq!(map(key(KeyCode::Insert, KeyModifiers::SHIFT), &state), Some(Command::RequestPaste)); + assert_eq!(map(key(KeyCode::Char('v'), KeyModifiers::CONTROL), &state), Some(Command::RequestPaste)); + assert_eq!(map(plain(KeyCode::Char('q')), &state), Some(Command::TextEdit(TextEditOp::Insert('q')))); +} + +#[test] +fn every_dialog_field_shares_the_editing_key_set() { + // The rename, conflict-rename, find-file, and fuzzy-jump arms route + // through the same `map_text_edit_key` as the destination field: one + // caret key, one word key, and both paste chords each. + let rename = state_with(UiPhase::FileOpSetup(FileOpSetup::RenameInput { + source_dir: PathBuf::from("/left"), + original_name: "a.txt".into(), + is_dir: false, + input: filecommand_core::line_edit::LineEdit::from("a.txt"), + })); + let conflict = conflict_state(Some(filecommand_core::line_edit::LineEdit::default())); + let mut find = panels(); + find.open_overlay(Overlay::FindFile(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")))); + let mut fuzzy = panels(); + fuzzy.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); + + for state in [&rename, &conflict, &find, &fuzzy] { + assert_eq!(map(plain(KeyCode::Left), state), Some(Command::TextEdit(TextEditOp::Left)), "Left"); + assert_eq!(map(key(KeyCode::Right, KeyModifiers::CONTROL), state), Some(Command::TextEdit(TextEditOp::WordRight)), "Ctrl+Right"); + assert_eq!(map(key(KeyCode::Backspace, KeyModifiers::CONTROL), state), Some(Command::TextEdit(TextEditOp::DeleteWordBack)), "Ctrl+Backspace"); + assert_eq!(map(key(KeyCode::Insert, KeyModifiers::SHIFT), state), Some(Command::RequestPaste), "Shift+Ins"); + assert_eq!(map(key(KeyCode::Char('v'), KeyModifiers::CONTROL), state), Some(Command::RequestPaste), "Ctrl+V"); + assert_eq!(map(plain(KeyCode::Char('z')), state), Some(Command::TextEdit(TextEditOp::Insert('z'))), "printable"); + } +} + +#[test] +fn conflict_rename_uses_the_shared_keys_only_while_the_field_is_up() { + // Before Rename is activated the conflict dialog's keys are its + // mnemonics; the editing keys must not leak into that stage. + let state = conflict_state(None); + assert_eq!(map(plain(KeyCode::Left), &state), None); + assert_eq!(map(plain(KeyCode::Char('r')), &state), Some(Command::FileOpBeginRename)); + let state = conflict_state(Some(filecommand_core::line_edit::LineEdit::default())); + assert_eq!(map(plain(KeyCode::Home), &state), Some(Command::TextEdit(TextEditOp::Home))); + assert_eq!(map(plain(KeyCode::Char('r')), &state), Some(Command::TextEdit(TextEditOp::Insert('r')))); +} + +#[test] +fn find_file_editing_keys_stop_once_the_search_is_submitted() { + // The pattern is fixed once a search is in flight (find-file "Find-file + // invocation"), so the field's keys go quiet and Up/Down move over the + // results instead. + let mut submitted = filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")); + submitted.submit(1); + let mut state = panels(); + state.open_overlay(Overlay::FindFile(submitted)); + assert_eq!(map(plain(KeyCode::Left), &state), None, "no field owns Left once the walk is running"); + assert_eq!(map(plain(KeyCode::Char('x')), &state), None, "the pattern no longer accepts text"); + assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::FindFileMove(-1))); +} + +#[test] +fn left_and_right_move_the_command_line_caret_only_while_typing() { + // command-line "Caret editing mid-line" / "Left on an empty line does + // nothing to the panel" (design D3). + assert_eq!(map(plain(KeyCode::Left), &typing("cd srv")), Some(Command::TextEdit(TextEditOp::Left))); + assert_eq!(map(plain(KeyCode::Right), &typing("cd srv")), Some(Command::TextEdit(TextEditOp::Right))); + assert_eq!(map(plain(KeyCode::Left), &panels()), None, "Left on an empty line maps to nothing"); + assert_eq!(map(plain(KeyCode::Right), &panels()), None, "Right on an empty line maps to nothing"); +} + +#[test] +fn ctrl_backspace_deletes_the_word_before_the_caret_while_typing() { + // command-line "Command-line prompt and printable-key routing"; design + // D3 — including the Ctrl+H delivery some terminals produce. + assert_eq!(map(key(KeyCode::Backspace, KeyModifiers::CONTROL), &typing("cd srv")), Some(Command::TextEdit(TextEditOp::DeleteWordBack))); + assert_eq!(map(key(KeyCode::Char('h'), KeyModifiers::CONTROL), &typing("cd srv")), Some(Command::TextEdit(TextEditOp::DeleteWordBack))); + // On an empty line Ctrl+Backspace keeps its long-standing ParentDir + // meaning (the Backspace arm has never filtered modifiers); Ctrl+H stays + // unbound there, exactly as before. + assert_eq!(map(key(KeyCode::Backspace, KeyModifiers::CONTROL), &panels()), Some(Command::ParentDir)); + assert_eq!(map(key(KeyCode::Char('h'), KeyModifiers::CONTROL), &panels()), None); +} + +#[test] +fn ctrl_v_pastes_into_the_command_line_over_the_panels() { + // text-field-editing "Paste into a text field": "Over the panels, Ctrl+V + // SHALL paste into the command line" — empty line or mid-composition. + assert_eq!(map(key(KeyCode::Char('v'), KeyModifiers::CONTROL), &panels()), Some(Command::RequestPaste)); + assert_eq!(map(key(KeyCode::Char('v'), KeyModifiers::CONTROL), &typing("cd ")), Some(Command::RequestPaste)); +} + +#[test] +fn home_end_delete_shift_ins_and_ctrl_arrows_keep_their_panel_meanings_while_typing() { + // design D3: none of these may become command-line editing keys, even + // mid-composition. + let state = typing("cd docs"); + assert_eq!(map(plain(KeyCode::Home), &state), Some(Command::MoveCursor(CursorMove::Home))); + assert_eq!(map(plain(KeyCode::End), &state), Some(Command::MoveCursor(CursorMove::End))); + assert_eq!(map(plain(KeyCode::PageUp), &state), Some(Command::MoveCursor(CursorMove::Up(5)))); + assert_eq!(map(plain(KeyCode::PageDown), &state), Some(Command::MoveCursor(CursorMove::Down(5)))); + assert_eq!(map(plain(KeyCode::Delete), &state), Some(Command::RequestDelete), "Delete still opens the delete confirmation"); + assert_eq!(map(key(KeyCode::Insert, KeyModifiers::SHIFT), &state), Some(Command::ToggleSelectAtCursor), "Shift+Ins still toggles selection"); + assert_eq!(map(key(KeyCode::Left, KeyModifiers::CONTROL), &state), Some(Command::SplitShrink)); + assert_eq!(map(key(KeyCode::Right, KeyModifiers::CONTROL), &state), Some(Command::SplitGrow)); +} + +#[test] +fn shift_ins_toggles_selection_and_ctrl_shift_ins_is_still_clipboard_paths() { + // text-field-editing "Shift+Ins over the panels still toggles selection" + // and "Ctrl+Shift+Ins SHALL remain the clipboard-paths action". + assert_eq!(map(key(KeyCode::Insert, KeyModifiers::SHIFT), &panels()), Some(Command::ToggleSelectAtCursor)); + assert_eq!( + map(key(KeyCode::Insert, KeyModifiers::CONTROL | KeyModifiers::SHIFT), &panels()), + Some(Command::CopyToClipboard(ClipboardPayloadKind::Paths)) + ); + // And plain Insert is untouched, of course. + assert_eq!(map(plain(KeyCode::Insert), &panels()), Some(Command::ToggleSelectAtCursor)); +} + #[test] fn delete_confirm_routes_y_n() { let state = state_with(UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { @@ -163,6 +298,7 @@ fn delete_confirm_routes_y_n() { source_dir: PathBuf::from("/left"), needs_second_confirm: false, confirmed_once: false, + hidden_by_filter: 0, })); assert_eq!(map(plain(KeyCode::Char('y')), &state), Some(Command::FileOpConfirm)); assert_eq!(map(plain(KeyCode::Char('n')), &state), Some(Command::FileOpCancel)); @@ -196,7 +332,7 @@ fn progress_dialog_plain_c_still_cancels_but_ctrl_c_is_ignored() { assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &state), None); } -fn conflict_state(rename_input: Option) -> State { +fn conflict_state(rename_input: Option) -> State { state_with(UiPhase::FileOpRunning { source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/right"), @@ -228,12 +364,54 @@ fn conflict_dialog_routes_mnemonic_keys() { #[test] fn conflict_dialog_rename_mode_routes_typing_instead_of_mnemonics() { - let state = conflict_state(Some(String::new())); - assert_eq!(map(plain(KeyCode::Char('o')), &state), Some(Command::FileOpInputChar('o'))); + let state = conflict_state(Some(filecommand_core::line_edit::LineEdit::default())); + assert_eq!(map(plain(KeyCode::Char('o')), &state), Some(Command::TextEdit(TextEditOp::Insert('o')))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::FileOpConfirm)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FileOpCancel)); } +/// operation-dialogs "Overwrite-conflict dialog" scenarios "Esc cancels the +/// job from the conflict dialog" and "The Cancel control and hotkey match +/// Esc" (process-lifecycle-hardening task 4.4): with no rename field open, +/// Esc and a plain `c`/`C` all issue the Cancel answer; Ctrl+C stays +/// unmapped here as it is on the Progress dialog. +#[test] +fn conflict_dialog_esc_and_plain_c_cancel_the_job() { + let state = conflict_state(None); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FileOpConflictChoice(ConflictChoice::Cancel))); + assert_eq!(map(plain(KeyCode::Char('c')), &state), Some(Command::FileOpConflictChoice(ConflictChoice::Cancel))); + assert_eq!(map(plain(KeyCode::Char('C')), &state), Some(Command::FileOpConflictChoice(ConflictChoice::Cancel))); + assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &state), None); +} + +/// Scenario "Esc in the Rename field returns to the choices": with the +/// field open, Esc still closes the field (`FileOpCancel`) and never +/// cancels the job; a typed `c` is text, not the hotkey. +#[test] +fn conflict_dialog_esc_in_the_rename_field_closes_the_field_not_the_job() { + let state = conflict_state(Some(filecommand_core::line_edit::LineEdit::default())); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FileOpCancel)); + assert_eq!(map(plain(KeyCode::Char('c')), &state), Some(Command::TextEdit(TextEditOp::Insert('c')))); +} + +/// operation-dialogs "Cancellation is observed while a job waits for an +/// answer" scenario "Esc on the error-recovery dialog aborts": pinned +/// (process-lifecycle-hardening task 4.4) — the mapping predates the +/// scenario. +#[test] +fn error_dialog_esc_chooses_abort() { + let state = state_with(UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/left"), + dialog: RunningDialog::Error { + kind: filecommand_core::fs_ops::JobKind::Delete, + info: filecommand_core::fs_ops::ErrorInfo { path: PathBuf::from("/left/locked.db"), message: "in use".into() }, + progress: filecommand_core::fs_ops::ProgressInfo::starting(1, 1), + }, + }); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FileOpErrorChoice(ErrorChoice::Abort))); +} + #[test] fn error_dialog_routes_mnemonic_keys() { let state = state_with(UiPhase::FileOpRunning { @@ -263,9 +441,9 @@ fn summary_phase_dismisses_on_any_key() { #[test] fn printable_keys_route_to_the_command_line() { - assert_eq!(map(plain(KeyCode::Char('d')), &panels()), Some(Command::CommandLineChar('d'))); - assert_eq!(map(plain(KeyCode::Char(' ')), &panels()), Some(Command::CommandLineChar(' '))); - assert_eq!(map(key(KeyCode::Char('D'), KeyModifiers::SHIFT), &panels()), Some(Command::CommandLineChar('D'))); + assert_eq!(map(plain(KeyCode::Char('d')), &panels()), Some(Command::TextEdit(TextEditOp::Insert('d')))); + assert_eq!(map(plain(KeyCode::Char(' ')), &panels()), Some(Command::TextEdit(TextEditOp::Insert(' ')))); + assert_eq!(map(key(KeyCode::Char('D'), KeyModifiers::SHIFT), &panels()), Some(Command::TextEdit(TextEditOp::Insert('D')))); } #[test] @@ -304,14 +482,14 @@ fn esc_cancels_an_in_progress_drag_instead_of_requesting_quit() { #[test] fn backspacing_to_empty_releases_up_down_to_the_panel() { - // Backspace is now the only way to hand Up/Down back to the panel - // cursor: it already pops the buffer in `core::update`, and the mapper - // recomputes `typing` fresh from the (now empty) buffer on the very - // next key (command-line "Command history navigation": "Backspacing the - // buffer to empty SHALL be the mechanism that hands Up/Down back to the - // panel"). + // Backspace is one of the two ways to hand Up/Down back to the panel + // cursor (the clear-line chord is the other — see + // `ctrl_y_clears_a_composed_line`): it already pops the buffer in + // `core::update`, and the mapper recomputes `typing` fresh from the + // (now empty) buffer on the very next key (command-line "Command + // history navigation"). assert_eq!(map(plain(KeyCode::Up), &typing("d")), Some(Command::CommandLineHistoryPrev)); - assert_eq!(map(plain(KeyCode::Backspace), &typing("d")), Some(Command::CommandLineBackspace)); + assert_eq!(map(plain(KeyCode::Backspace), &typing("d")), Some(Command::TextEdit(TextEditOp::Backspace))); // Once the buffer is empty again, Up/Down move the panel cursor. assert_eq!(map(plain(KeyCode::Up), &panels()), Some(Command::MoveCursor(CursorMove::Up(1)))); assert_eq!(map(plain(KeyCode::Down), &panels()), Some(Command::MoveCursor(CursorMove::Down(1)))); @@ -319,13 +497,91 @@ fn backspacing_to_empty_releases_up_down_to_the_panel() { #[test] fn backspace_edits_the_buffer_while_typing() { - assert_eq!(map(plain(KeyCode::Backspace), &typing("dir")), Some(Command::CommandLineBackspace)); + assert_eq!(map(plain(KeyCode::Backspace), &typing("dir")), Some(Command::TextEdit(TextEditOp::Backspace))); +} + +#[test] +fn ctrl_y_clears_a_composed_line() { + // command-line "Clear the command line with a dedicated key": "Ctrl+Y + // clears a composed line". + assert_eq!(map(key(KeyCode::Char('y'), KeyModifiers::CONTROL), &typing("cd Program Files")), Some(Command::CommandLineClear)); +} + +#[test] +fn ctrl_y_on_an_empty_line_does_nothing() { + // command-line "Clear the command line with a dedicated key": "Ctrl+Y + // on an empty line does nothing" — no quit dialog, no cursor movement + // (design D2: a mistaken press never does anything surprising). + assert_eq!(map(key(KeyCode::Char('y'), KeyModifiers::CONTROL), &panels()), None); +} + +#[test] +fn clear_line_binding_is_config_overridable() { + // command-line "Clear the command line with a dedicated key": + // "Rebinding the clear key" — `key.clear_line = "ctrl+k"` makes Ctrl+K + // the clear chord and leaves Ctrl+Y inert. + let keys = Keys { clear_line: filecommand_core::config::KeyBinding::new(true, false, false, "k"), ..Keys::default() }; + let state = typing("dir"); + assert_eq!(map_key(key(KeyCode::Char('k'), KeyModifiers::CONTROL), &state, 5, &keys), Some(Command::CommandLineClear)); + assert_eq!(map_key(key(KeyCode::Char('y'), KeyModifiers::CONTROL), &state, 5, &keys), None, "the default chord no longer applies once rebound"); +} + +#[test] +fn command_line_clear_is_never_emitted_while_a_dialog_menu_viewer_or_editor_owns_input() { + // The chord is bound only while the panels own input (command-line + // "Clear the command line with a dedicated key"): no overlay or + // full-screen phase ever emits `CommandLineClear`, and their existing + // handling — including the unguarded `y` arms that treat Ctrl+Y as a + // plain `y` (command-line-clear-key design, Context) — is unchanged. + // The buffer is non-empty throughout, so the outcomes below always + // reflect the overlay's own keymap, never the empty-line guard. + let ctrl_y = key(KeyCode::Char('y'), KeyModifiers::CONTROL); + + let mut state = typing("dir"); + state.quit_confirm = true; + assert_eq!(map(ctrl_y, &state), Some(Command::ConfirmQuit), "the quit-confirm's unguarded y arm is unchanged"); + assert_ne!(map(ctrl_y, &state), Some(Command::CommandLineClear)); + + let delete_confirm = state_with(UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + })); + assert_eq!(map(ctrl_y, &delete_confirm), Some(Command::FileOpConfirm), "the delete-confirm's unguarded y arm is unchanged"); + + let destination = destination_input_state(); + assert_eq!(map(ctrl_y, &destination), Some(Command::TextEdit(TextEditOp::Insert('y'))), "text fields still type a literal y"); + + let mut state = typing("dir"); + state.open_overlay(Overlay::Menu(MenuState::opened())); + assert_eq!(map(ctrl_y, &state), None, "an open pull-down claims the key and ignores it"); + + let mut state = typing("dir"); + state.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); + assert_eq!(map(ctrl_y, &state), None, "the fuzzy-jump dialog claims the key and ignores it"); + + let mut state = typing("dir"); + state.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); + assert_eq!(map(ctrl_y, &state), None, "the user menu claims the key and ignores it"); + + let mut state = typing("dir"); + state.open_overlay(Overlay::Help(filecommand_core::dialogs::HelpState::new())); + assert_eq!(map(ctrl_y, &state), None, "the help window claims the key and ignores it"); + + let viewer = state_with(UiPhase::Viewer(filecommand_core::viewer::ViewerState::new(PathBuf::from("f.txt"), 100))); + assert_eq!(map(ctrl_y, &viewer), None, "the viewer owns its keys"); + + let editor = + state_with(UiPhase::Editor(filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), b"abc").expect("valid UTF-8 fixture"))); + assert_eq!(map(ctrl_y, &editor), None, "the editor owns its keys"); } #[test] fn plus_minus_star_type_into_a_non_empty_buffer_instead_of_selecting() { - assert_eq!(map(plain(KeyCode::Char('+')), &typing("echo 1")), Some(Command::CommandLineChar('+'))); - assert_eq!(map(plain(KeyCode::Char('*')), &typing("dir ")), Some(Command::CommandLineChar('*'))); + assert_eq!(map(plain(KeyCode::Char('+')), &typing("echo 1")), Some(Command::TextEdit(TextEditOp::Insert('+')))); + assert_eq!(map(plain(KeyCode::Char('*')), &typing("dir ")), Some(Command::TextEdit(TextEditOp::Insert('*')))); } #[test] @@ -369,13 +625,7 @@ fn ctrl_o_maps_to_the_scrollback_view() { #[test] fn ctrl_f3_through_f7_pick_the_sort_modes() { - let expected = [ - (3u8, SortMode::Name), - (4, SortMode::Extension), - (5, SortMode::Time), - (6, SortMode::Size), - (7, SortMode::Unsorted), - ]; + let expected = [(3u8, SortMode::Name), (4, SortMode::Extension), (5, SortMode::Time), (6, SortMode::Size), (7, SortMode::Unsorted)]; for (n, mode) in expected { assert_eq!( map(key(KeyCode::F(n), KeyModifiers::CONTROL), &panels()), @@ -395,10 +645,7 @@ fn plain_f5_to_f7_still_mean_copy_mkdir_not_sort() { fn sort_keys_target_whichever_panel_is_active() { let mut state = panels(); state.active = PanelSide::Right; - assert_eq!( - map(key(KeyCode::F(6), KeyModifiers::CONTROL), &state), - Some(Command::SetSortMode { side: PanelSide::Right, mode: SortMode::Size }) - ); + assert_eq!(map(key(KeyCode::F(6), KeyModifiers::CONTROL), &state), Some(Command::SetSortMode { side: PanelSide::Right, mode: SortMode::Size })); } #[test] @@ -417,7 +664,7 @@ fn ctrl_l_toggles_info_for_the_active_panel() { #[test] fn drive_dialog_claims_navigation_confirm_and_cancel() { let mut state = panels(); - state.drive_select = Some(DriveSelect::new(PanelSide::Left, vec!['A', 'C'], None)); + state.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['A', 'C'], None))); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::DriveSelectMove(-1))); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::DriveSelectMove(1))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::DriveSelectConfirm)); @@ -427,7 +674,7 @@ fn drive_dialog_claims_navigation_confirm_and_cancel() { #[test] fn the_drive_dialog_swallows_keys_that_would_otherwise_type() { let mut state = panels(); - state.drive_select = Some(DriveSelect::new(PanelSide::Left, vec!['C'], None)); + state.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C'], None))); assert_eq!(map(plain(KeyCode::Char('d')), &state), None, "typing must not leak past the dialog"); assert_eq!(map(plain(KeyCode::F(5)), &state), None); } @@ -444,7 +691,7 @@ fn f9_opens_the_menu_bar() { #[test] fn menu_navigation_keys_route_to_the_state_machine() { let mut state = panels(); - state.menu = Some(MenuState::opened()); + state.open_overlay(Overlay::Menu(MenuState::opened())); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::MenuSelectPrev)); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::MenuSelectNext)); assert_eq!(map(plain(KeyCode::Left), &state), Some(Command::MenuPrevMenu)); @@ -457,25 +704,23 @@ fn menu_navigation_keys_route_to_the_state_machine() { #[test] fn a_letter_while_the_bar_is_open_is_a_hotkey_not_typing() { let mut state = panels(); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); assert_eq!(map(plain(KeyCode::Char('c')), &state), Some(Command::MenuHotkey('c'))); } #[test] fn the_menu_claims_keys_ahead_of_the_command_line() { let mut state = typing("dir"); - state.menu = Some(MenuState::opened()); + state.open_overlay(Overlay::Menu(MenuState::opened())); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::MenuSelectPrev), "not history recall"); assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::MenuHotkey('x')), "not a typed character"); } -#[test] -fn the_drive_dialog_outranks_the_menu() { - let mut state = panels(); - state.menu = Some(MenuState::opened()); - state.drive_select = Some(DriveSelect::new(PanelSide::Left, vec!['C'], None)); - assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::DriveSelectCancel)); -} +// `the_drive_dialog_outranks_the_menu` used to build a state with both the +// menu and the drive dialog open — impossible now that both live in the one +// overlay slot. It lives on as the reducer test +// `drive_list_ready_replaces_an_open_menu_in_the_slot` (reducer-overlay- +// structure design D6). // --------------------------------------------------------------------- // M3: typing-sink arbitration @@ -514,7 +759,7 @@ fn exactly_one_typing_sink_claims_any_given_key() { for c in ['a', 'z', '1', '.', ' '] { let while_typing = map(plain(KeyCode::Char(c)), &panels()); let while_searching = map(plain(KeyCode::Char(c)), &searching); - assert!(matches!(while_typing, Some(Command::CommandLineChar(_)) | Some(Command::GroupSelectAll))); + assert!(matches!(while_typing, Some(Command::TextEdit(TextEditOp::Insert(_))) | Some(Command::GroupSelectAll))); assert!(matches!(while_searching, Some(Command::QuickSearchChar(_)))); assert_ne!(while_typing, while_searching, "`{c}` was claimed by both sinks"); } @@ -550,8 +795,8 @@ fn a_movement_key_exits_quick_search_and_is_still_applied_to_the_panel() { fn a_dialog_outranks_both_typing_sinks() { let mut state = destination_input_state(); state.quick_search = Some("r".to_string()); - state.command_line = "dir".to_string(); - assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::FileOpInputChar('x'))); + state.command_line = filecommand_core::line_edit::LineEdit::from("dir"); + assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::TextEdit(TextEditOp::Insert('x')))); } #[test] @@ -559,8 +804,8 @@ fn entries_are_irrelevant_to_key_mapping() { // The mapper reads modes, not contents: a populated panel maps the same // keys as an empty one. let mut state = panels(); - state.left.entries = vec![Entry { name: "a.txt".into(), kind: EntryKind::File, size: 1, modified: None }]; - assert_eq!(map(plain(KeyCode::Char('d')), &state), Some(Command::CommandLineChar('d'))); + state.left.entries = vec![Entry::new("a.txt", EntryKind::File, 1, None, false, false)]; + assert_eq!(map(plain(KeyCode::Char('d')), &state), Some(Command::TextEdit(TextEditOp::Insert('d')))); } // --------------------------------------------------------------------- @@ -579,7 +824,10 @@ fn f3_opens_the_viewer_and_plain_f4_requests_the_editor() { #[test] fn ctrl_f4_still_means_sort_by_extension_not_the_editor() { - assert_eq!(map(key(KeyCode::F(4), KeyModifiers::CONTROL), &panels()), Some(Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Extension })); + assert_eq!( + map(key(KeyCode::F(4), KeyModifiers::CONTROL), &panels()), + Some(Command::SetSortMode { side: PanelSide::Left, mode: SortMode::Extension }) + ); } #[test] @@ -646,7 +894,7 @@ fn viewer_search_prompt_owns_the_keyboard_while_open() { // --------------------------------------------------------------------- fn open_editor(text: &str) -> filecommand_core::editor::EditorState { - filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), text.as_bytes()) + filecommand_core::editor::EditorState::from_bytes(PathBuf::from("f.txt"), text.as_bytes()).expect("valid UTF-8 fixture") } #[test] @@ -738,14 +986,14 @@ fn editor_quit_confirm_owns_y_n_and_esc_over_everything_else() { #[test] fn ctrl_p_and_ctrl_j_open_quick_filter_and_fuzzy_jump_by_default() { let state = panels(); - assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterStart)); + assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterStart(PanelSide::Left))); assert_eq!(map(key(KeyCode::Char('j'), KeyModifiers::CONTROL), &state), Some(Command::FuzzyJumpOpen)); } #[test] fn quick_filter_owns_printables_and_backspace_but_not_esc_or_movement() { let mut state = panels(); - state.left.quick_filter = Some("re".to_string()); + state.left.set_quick_filter(Some("re".to_string())); assert_eq!(map(plain(KeyCode::Char('p')), &state), Some(Command::QuickFilterChar('p'))); assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::QuickFilterBackspace)); // Esc no longer exits the filter — it falls through to the unconditional @@ -766,18 +1014,18 @@ fn quick_filter_activation_key_toggles_the_filter_off_when_active() { // instead (quick-filter "Clearing the quick filter": "the activation // key toggles the filter"). let mut state = panels(); - assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterStart)); - state.left.quick_filter = Some("re".to_string()); + assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterStart(PanelSide::Left))); + state.left.set_quick_filter(Some("re".to_string())); assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterEnd)); } #[test] fn quick_filter_is_scoped_to_the_active_panel_only() { let mut state = panels(); - state.right.quick_filter = Some("x".to_string()); // right is inactive here + state.right.set_quick_filter(Some("x".to_string())); // right is inactive here assert_eq!( map(plain(KeyCode::Char('a')), &state), - Some(Command::CommandLineChar('a')), + Some(Command::TextEdit(TextEditOp::Insert('a'))), "the inactive panel's filter must not steal printables from the command line" ); } @@ -790,7 +1038,7 @@ fn alt_letter_does_not_start_type_ahead_while_the_quick_filter_is_active() { // the activation key toggling the filter off is the documented way out // of the filter first (quick-filter "Clearing the quick filter"). let mut state = panels(); - state.left.quick_filter = Some("re".to_string()); + state.left.set_quick_filter(Some("re".to_string())); assert_eq!( map(key(KeyCode::Char('r'), KeyModifiers::ALT), &state), None, @@ -818,7 +1066,7 @@ fn ctrl_c_copies_files_from_panels_in_any_command_line_state() { assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &typing("dir")), files); let mut filtering = panels(); - filtering.left.quick_filter = Some("re".to_string()); + filtering.left.set_quick_filter(Some("re".to_string())); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &filtering), files); } @@ -872,14 +1120,14 @@ fn ctrl_c_in_the_built_in_editor_still_copies() { // The sole exclusion: the editor's own Ctrl+C=Copy binding must survive // untouched (application-shell "Quit request keys and confirmation": // "in the built-in editor Ctrl+C SHALL remain Copy"). - let e = EditorState::from_bytes(PathBuf::from("f.txt"), b""); + let e = EditorState::from_bytes(PathBuf::from("f.txt"), b"").expect("valid UTF-8 fixture"); assert_eq!(map_editor_key(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &e, 20), Some(Command::EditorCopy)); } #[test] fn ctrl_c_no_longer_requests_quit_with_a_pull_down_menu_open() { let mut state = panels(); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &state), None); } @@ -889,19 +1137,19 @@ fn ctrl_c_no_longer_requests_quit_from_modal_dialogs_and_overlays() { // phase: fuzzy jump, find file, user menu, drive select, and the // file-op running dialog all now ignore Ctrl+C outright. let mut fuzzy = panels(); - fuzzy.fuzzy_jump = Some(filecommand_core::quicksearch::FuzzyJumpState::new()); + fuzzy.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &fuzzy), None); let mut find = panels(); - find.find_file = Some(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left"))); + find.open_overlay(Overlay::FindFile(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")))); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &find), None); let mut user_menu = panels(); - user_menu.user_menu = Some(filecommand_core::dialogs::UserMenuState::new()); + user_menu.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &user_menu), None); let mut drive = panels(); - drive.drive_select = Some(DriveSelect::new(PanelSide::Left, vec![], None)); + drive.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec![], None))); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &drive), None); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &progress_state()), None); @@ -916,10 +1164,7 @@ fn ctrl_c_types_a_literal_c_in_a_file_op_text_input() { // special case — it is not treated as "over the panels" for clipboard // purposes (clipboard-export "Clipboard key bindings": "over the // panels"). - assert_eq!( - map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &destination_input_state()), - Some(Command::FileOpInputChar('c')) - ); + assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &destination_input_state()), Some(Command::TextEdit(TextEditOp::Insert('c')))); } #[test] @@ -936,12 +1181,43 @@ fn quick_search_treats_ctrl_c_as_an_unrecognized_key_and_ends_type_ahead() { #[test] fn ctrl_t_ctrl_w_and_alt_digit_map_to_tab_commands() { let state = panels(); - assert_eq!(map(key(KeyCode::Char('t'), KeyModifiers::CONTROL), &state), Some(Command::OpenTab)); - assert_eq!(map(key(KeyCode::Char('w'), KeyModifiers::CONTROL), &state), Some(Command::CloseTab)); + assert_eq!(map(key(KeyCode::Char('t'), KeyModifiers::CONTROL), &state), Some(Command::OpenTab(PanelSide::Left))); + assert_eq!(map(key(KeyCode::Char('w'), KeyModifiers::CONTROL), &state), Some(Command::CloseTab(PanelSide::Left))); assert_eq!(map(key(KeyCode::Char('3'), KeyModifiers::ALT), &state), Some(Command::SwitchTab(3))); assert_eq!(map(key(KeyCode::Char('9'), KeyModifiers::ALT), &state), Some(Command::SwitchTab(9))); } +#[test] +fn tab_commands_carry_the_active_panel_side() { + // pulldown-menu-completion design D1: the keymap passes `state.active`; + // the Left/Right menus pass their own side instead. + let mut state = panels(); + state.active = PanelSide::Right; + assert_eq!(map(key(KeyCode::Char('t'), KeyModifiers::CONTROL), &state), Some(Command::OpenTab(PanelSide::Right))); + assert_eq!(map(key(KeyCode::Char('w'), KeyModifiers::CONTROL), &state), Some(Command::CloseTab(PanelSide::Right))); + assert_eq!(map(key(KeyCode::Char('p'), KeyModifiers::CONTROL), &state), Some(Command::QuickFilterStart(PanelSide::Right))); +} + +#[test] +fn ctrl_u_maps_to_swap_panels() { + let state = panels(); + assert_eq!(map(key(KeyCode::Char('u'), KeyModifiers::CONTROL), &state), Some(Command::SwapPanels)); + assert_eq!(map(key(KeyCode::Char('U'), KeyModifiers::CONTROL), &state), Some(Command::SwapPanels)); + // Alt+U / plain u are unaffected. + assert_eq!(map(key(KeyCode::Char('u'), KeyModifiers::ALT), &state), Some(Command::QuickSearchStart('u'))); + assert_eq!(map(plain(KeyCode::Char('u')), &state), Some(Command::TextEdit(TextEditOp::Insert('u')))); +} + +#[test] +fn alt_f8_opens_the_history_dialog_plain_f8_still_deletes() { + let state = panels(); + // The guarded Alt+F8 arm takes precedence over the unguarded F(8) arm, + // exactly as Alt+F7 does for find-file (command-line "Command history + // dialog (Alt+F8)"). + assert_eq!(map(key(KeyCode::F(8), KeyModifiers::ALT), &state), Some(Command::HistoryDialogOpen)); + assert_eq!(map(plain(KeyCode::F(8)), &state), Some(Command::RequestDelete)); +} + #[test] fn f1_f2_and_alt_f7_open_help_user_menu_and_find_file() { let state = panels(); @@ -956,9 +1232,9 @@ fn f1_f2_and_alt_f7_open_help_user_menu_and_find_file() { #[test] fn fuzzy_jump_dialog_owns_typing_movement_and_dismissal() { let mut state = panels(); - state.fuzzy_jump = Some(filecommand_core::quicksearch::FuzzyJumpState::new()); - assert_eq!(map(plain(KeyCode::Char('d')), &state), Some(Command::FuzzyJumpChar('d'))); - assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::FuzzyJumpBackspace)); + state.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); + assert_eq!(map(plain(KeyCode::Char('d')), &state), Some(Command::TextEdit(TextEditOp::Insert('d')))); + assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::TextEdit(TextEditOp::Backspace))); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::FuzzyJumpMove(-1))); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::FuzzyJumpMove(1))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::FuzzyJumpConfirm)); @@ -968,15 +1244,15 @@ fn fuzzy_jump_dialog_owns_typing_movement_and_dismissal() { #[test] fn find_file_dialog_input_stage_owns_typing_then_switches_to_result_navigation() { let mut state = panels(); - state.find_file = Some(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left"))); - assert_eq!(map(plain(KeyCode::Char('r')), &state), Some(Command::FindFileChar('r'))); - assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::FindFileBackspace)); + state.open_overlay(Overlay::FindFile(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")))); + assert_eq!(map(plain(KeyCode::Char('r')), &state), Some(Command::TextEdit(TextEditOp::Insert('r')))); + assert_eq!(map(plain(KeyCode::Backspace), &state), Some(Command::TextEdit(TextEditOp::Backspace))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::FindFileSubmit)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::FindFileCancel)); let mut submitted = filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")); submitted.submit(1); - state.find_file = Some(submitted); + state.overlay = Some(Overlay::FindFile(submitted)); assert_eq!(map(plain(KeyCode::Char('r')), &state), None, "the pattern is fixed once a search is in flight"); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::FindFileMove(-1))); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::FindFileMove(1))); @@ -987,7 +1263,7 @@ fn find_file_dialog_input_stage_owns_typing_then_switches_to_result_navigation() #[test] fn user_menu_dialog_owns_movement_confirm_and_dismissal() { let mut state = panels(); - state.user_menu = Some(filecommand_core::dialogs::UserMenuState::new()); + state.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::UserMenuMove(-1))); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::UserMenuMove(1))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::UserMenuConfirm)); @@ -996,24 +1272,45 @@ fn user_menu_dialog_owns_movement_confirm_and_dismissal() { assert_eq!(map(plain(KeyCode::Char('a')), &state), None); } +#[test] +fn history_dialog_owns_movement_confirm_and_dismissal() { + let mut state = panels(); + state.history = vec!["cd C:\\work".to_string()]; + state.open_overlay(Overlay::HistoryDialog(filecommand_core::dialogs::HistoryDialogState::new())); + assert_eq!(map(plain(KeyCode::Up), &state), Some(Command::HistoryDialogMove(-1))); + assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::HistoryDialogMove(1))); + // Home/End/PgUp/PgDn map to huge deltas, the same convention + // drive-select uses; the reducer's clamped cursor turns them into the + // exact ends (command-line "Command history dialog (Alt+F8)"). + assert_eq!(map(plain(KeyCode::Home), &state), Some(Command::HistoryDialogMove(isize::MIN / 2))); + assert_eq!(map(plain(KeyCode::PageUp), &state), Some(Command::HistoryDialogMove(isize::MIN / 2))); + assert_eq!(map(plain(KeyCode::End), &state), Some(Command::HistoryDialogMove(isize::MAX / 2))); + assert_eq!(map(plain(KeyCode::PageDown), &state), Some(Command::HistoryDialogMove(isize::MAX / 2))); + assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::HistoryDialogConfirm)); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::HistoryDialogCancel)); + // A list overlay, not a text field: printables never reach the command + // line while it is open. + assert_eq!(map(plain(KeyCode::Char('a')), &state), None); +} + #[test] fn help_dialog_precedence_list_then_page_then_about() { let mut state = panels(); let mut help = filecommand_core::dialogs::HelpState::new(); - state.help = Some(help); + state.open_overlay(Overlay::Help(help)); assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::HelpMove(1))); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::HelpActivate)); assert_eq!(map(plain(KeyCode::Char('c')), &state), Some(Command::HelpCancel)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::HelpCancel)); help.page = Some(1); - state.help = Some(help); + state.overlay = Some(Overlay::Help(help)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::HelpCancel)); assert_eq!(map(plain(KeyCode::Down), &state), None, "a topic page does not scroll a list that isn't shown"); help.page = None; help.about_open = true; - state.help = Some(help); + state.overlay = Some(Overlay::Help(help)); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::HelpCancel)); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::HelpCancel)); assert_eq!(map(plain(KeyCode::Char('o')), &state), Some(Command::HelpCancel)); @@ -1026,7 +1323,7 @@ fn help_ignores_ctrl_c_but_plain_c_still_cancels() { // must fall through to `_ => None` rather than closing Help, while the // plain key is untouched. let mut state = panels(); - state.help = Some(filecommand_core::dialogs::HelpState::new()); + state.open_overlay(Overlay::Help(filecommand_core::dialogs::HelpState::new())); assert_eq!(map(plain(KeyCode::Char('c')), &state), Some(Command::HelpCancel)); assert_eq!(map(plain(KeyCode::Char('C')), &state), Some(Command::HelpCancel)); assert_eq!(map(key(KeyCode::Char('c'), KeyModifiers::CONTROL), &state), None); @@ -1037,8 +1334,8 @@ fn help_ignores_ctrl_c_but_plain_c_still_cancels() { fn m5_dialogs_outrank_the_command_line_and_quick_search() { let mut state = typing("dir"); state.quick_search = Some("z".to_string()); - state.fuzzy_jump = Some(filecommand_core::quicksearch::FuzzyJumpState::new()); - assert_eq!(map(plain(KeyCode::Char('a')), &state), Some(Command::FuzzyJumpChar('a'))); + state.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); + assert_eq!(map(plain(KeyCode::Char('a')), &state), Some(Command::TextEdit(TextEditOp::Insert('a')))); } // --------------------------------------------------------------------- @@ -1048,20 +1345,301 @@ fn m5_dialogs_outrank_the_command_line_and_quick_search() { #[test] fn startup_warning_is_dismissed_by_any_key() { let mut state = panels(); - state.startup_warning = Some("usermenu.toml is malformed; F2 uses the default user menu".to_string()); + state.set_startup_warning("usermenu.toml is malformed; F2 uses the default user menu".to_string()); assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::DismissStartupWarning)); assert_eq!(map(plain(KeyCode::Enter), &state), Some(Command::DismissStartupWarning)); assert_eq!(map(plain(KeyCode::Char('x')), &state), Some(Command::DismissStartupWarning)); } #[test] -fn startup_warning_outranks_every_other_modal_and_typing_sink() { +fn startup_warning_outranks_every_typing_sink() { // The dialog can only ever be up at session start, before anything else // has had a chance to open, but it must still win the precedence check - // deterministically rather than relying on that invariant alone. + // deterministically over the typing sinks underneath it — a quick + // search and a composed command line — rather than relying on that + // invariant alone. (It cannot share the slot with another overlay, so + // the menu this test used to stack beneath it is gone — reducer-overlay- + // structure design D6.) let mut state = typing("dir"); - state.startup_warning = Some("usermenu.toml is malformed".to_string()); + state.set_startup_warning("usermenu.toml is malformed".to_string()); state.quick_search = Some("z".to_string()); - state.menu = Some(MenuState::opened()); assert_eq!(map(plain(KeyCode::Char('a')), &state), Some(Command::DismissStartupWarning)); } + +// --------------------------------------------------------------------- +// idle-aware-event-loop: `map_paste` — a terminal bracketed paste arrives +// as one paste (text-field-editing "Terminal paste arrives as one paste"; +// design D6; task 3.5) +// --------------------------------------------------------------------- + +#[test] +fn paste_into_the_command_line_over_the_panels_is_one_paste_op() { + // "Paste a path into the command line": one `TextEdit(Paste(..))`, and + // none of the pasted characters touch the panel cursor or selection. + let state = panels(); + assert_eq!(map_paste(r"C:\Program Files\App", &state), vec![Command::TextEdit(TextEditOp::Paste(r"C:\Program Files\App".to_string()))]); + let state = typing("cd "); + assert_eq!( + map_paste(r"D:\docs", &state), + vec![Command::TextEdit(TextEditOp::Paste(r"D:\docs".to_string()))], + "mid-composition too, exactly as Ctrl+V" + ); +} + +#[test] +fn paste_with_an_active_quick_filter_still_lands_in_the_command_line() { + // The quick filter claims only plain printables; Ctrl+V keeps its + // command-line meaning while it is active, so a terminal paste does too. + let mut state = panels(); + state.left.set_quick_filter(Some("re".to_string())); + assert_eq!(map_paste("report.txt", &state), vec![Command::TextEdit(TextEditOp::Paste("report.txt".to_string()))]); +} + +#[test] +fn a_multi_line_paste_is_flattened_and_never_executed() { + // "Multi-line paste is flattened and not executed": one paste op joined + // by single spaces — no command runs. + let state = panels(); + assert_eq!(map_paste("dir /b\r\necho hi\n", &state), vec![Command::TextEdit(TextEditOp::Paste("dir /b echo hi ".to_string()))]); +} + +#[test] +fn paste_into_a_dialog_field_is_one_paste_op() { + // "Paste into a dialog field": the destination field stands in for every + // field sharing `map_text_edit_key` (rename, conflict-rename, fuzzy jump). + let state = destination_input_state(); + assert_eq!(map_paste("backup", &state), vec![Command::TextEdit(TextEditOp::Paste("backup".to_string()))]); +} + +#[test] +fn paste_into_the_find_file_pattern_is_one_paste_op() { + let mut state = panels(); + state.open_overlay(Overlay::FindFile(filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")))); + assert_eq!(map_paste("*.rs", &state), vec![Command::TextEdit(TextEditOp::Paste("*.rs".to_string()))]); + // Once the search is in flight the pattern is fixed, so a paste goes + // nowhere (find-file "Find-file invocation"). + let mut submitted = filecommand_core::find_file::FindFileState::new(PathBuf::from("/left")); + submitted.submit(1); + state.overlay = Some(Overlay::FindFile(submitted)); + assert_eq!(map_paste("*.rs", &state), Vec::new()); +} + +#[test] +fn paste_into_the_editor_text_is_typed_characters_and_newlines() { + // "A paste into the built-in editor's text SHALL be inserted at the + // caret as if typed, line breaks included." + let state = state_with(UiPhase::Editor(open_editor("abc"))); + assert_eq!( + map_paste("x\r\ny\nz", &state), + vec![Command::EditorChar('x'), Command::EditorNewline, Command::EditorChar('y'), Command::EditorNewline, Command::EditorChar('z'),], + "CRLF is one line break" + ); + // The save-on-exit prompt is a yes/no dialog: a paste never answers it. + let mut quitting = state_with(UiPhase::Editor(open_editor("abc"))); + if let UiPhase::Editor(editor) = &mut quitting.phase { + editor.quit_confirm = true; + } + assert_eq!(map_paste("y", &quitting), Vec::new()); +} + +#[test] +fn paste_into_the_editor_and_viewer_search_prompts_is_flattened_characters() { + // Per-character prompt commands, line breaks flattened to spaces — a + // pasted line break never confirms the prompt. + let mut editor = open_editor("abc"); + editor.search_prompt = Some(String::new()); + let state = state_with(UiPhase::Editor(editor)); + assert_eq!(map_paste("a\nb", &state), vec![Command::EditorSearchChar('a'), Command::EditorSearchChar(' '), Command::EditorSearchChar('b')]); + let mut editor = open_editor("abc"); + editor.replace_prompt = Some(filecommand_core::editor::ReplacePrompt::Pattern(String::new())); + let state = state_with(UiPhase::Editor(editor)); + assert_eq!(map_paste("a\nb", &state), vec![Command::EditorReplaceChar('a'), Command::EditorReplaceChar(' '), Command::EditorReplaceChar('b')]); + + let mut viewer = open_viewer(filecommand_core::viewer::ViewMode::Text); + viewer.search_input = Some(String::new()); + let state = state_with(UiPhase::Viewer(viewer)); + assert_eq!(map_paste("a\nb", &state), vec![Command::ViewerSearchChar('a'), Command::ViewerSearchChar(' '), Command::ViewerSearchChar('b')]); + // Without the prompt open, the viewer has no text sink at all. + let state = state_with(UiPhase::Viewer(open_viewer(filecommand_core::viewer::ViewMode::Text))); + assert_eq!(map_paste("q", &state), Vec::new()); +} + +#[test] +fn paste_with_no_focused_text_input_is_ignored() { + // "Paste with no focused text input is ignored": a pasted `y` must not + // answer the delete confirmation. + let delete = state_with(UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + })); + assert_eq!(map_paste("y", &delete), Vec::new()); + + let mut menu_open = panels(); + menu_open.open_overlay(Overlay::Menu(MenuState::opened())); + assert_eq!(map_paste("q", &menu_open), Vec::new(), "a menu's hotkey letters are not text input"); + + let mut quitting = panels(); + quitting.quit_confirm = true; + assert_eq!(map_paste("y", &quitting), Vec::new(), "the quit dialog is never answered by a paste"); + + let mut warning = panels(); + warning.set_startup_warning("warn".to_string()); + assert_eq!(map_paste("x", &warning), Vec::new()); + + assert_eq!(map_paste("", &panels()), Vec::new(), "an empty paste produces nothing"); +} + +// --------------------------------------------------------------------- +// resize-preserves-session-state task 5.1: while the "terminal too small" +// placeholder hides the screen, no key reaches it (application-shell +// "Resize handling with 60x16 hard floor and placeholder"; design D3) +// --------------------------------------------------------------------- + +fn undersized(phase: UiPhase) -> State { + State { undersized: true, term_size: (50, 12), ..state_with(phase) } +} + +#[test] +fn undersized_over_the_editor_ignores_every_key_including_quit() { + // "Typing over the hidden editor does nothing": letters, Enter, F2, F10 + // and Esc all map to nothing, so no edit, save, quit prompt or quit + // dialog can happen to a buffer the user cannot see. + let state = undersized(UiPhase::Editor(open_editor("abc\n"))); + for k in [plain(KeyCode::Char('a')), plain(KeyCode::Enter), plain(KeyCode::F(2)), plain(KeyCode::F(10)), plain(KeyCode::Esc)] { + assert_eq!(map(k, &state), None, "{k:?} must be ignored over the hidden editor"); + } +} + +#[test] +fn undersized_over_panels_and_viewer_lets_only_f10_and_esc_through() { + // "F10 over the placeholder asks to quit": the quit dialog is drawn over + // the placeholder; everything else is ignored. + for state in [undersized(UiPhase::Panels), undersized(UiPhase::Viewer(open_viewer(filecommand_core::viewer::ViewMode::Text)))] { + assert_eq!(map(plain(KeyCode::F(10)), &state), Some(Command::RequestQuit)); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::RequestQuit)); + for k in [ + plain(KeyCode::Char('a')), + plain(KeyCode::Enter), + plain(KeyCode::Tab), + plain(KeyCode::F(5)), + plain(KeyCode::Down), + key(KeyCode::Char('j'), KeyModifiers::CONTROL), + ] { + assert_eq!(map(k, &state), None, "{k:?} must not reach the hidden screen"); + } + } +} + +#[test] +fn undersized_hides_open_overlays_from_the_keyboard() { + // A key is never routed blindly to a menu, dialog or typing sink the + // user cannot see (design D3 "Hidden overlay"). + let mut menu = undersized(UiPhase::Panels); + menu.open_overlay(Overlay::Menu(MenuState::opened())); + assert_eq!(map(plain(KeyCode::Enter), &menu), None, "Enter must not activate a hidden menu item"); + assert_eq!(map(plain(KeyCode::Esc), &menu), Some(Command::RequestQuit), "Esc asks to quit rather than closing the hidden menu"); + + let mut drive = undersized(UiPhase::Panels); + drive.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['C'], Some('C')))); + assert_eq!(map(plain(KeyCode::Enter), &drive), None); + + let mut typing = undersized(UiPhase::Panels); + typing.command_line = filecommand_core::line_edit::LineEdit::from("dir"); + assert_eq!(map(plain(KeyCode::Char('x')), &typing), None, "no text reaches the hidden command line"); + assert_eq!(map(plain(KeyCode::Enter), &typing), None, "the hidden command line is never executed"); + + let delete = undersized(UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: vec![], + source_dir: PathBuf::from("/left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter: 0, + })); + assert_eq!(map(plain(KeyCode::Enter), &delete), None, "a hidden delete confirmation cannot be confirmed"); + assert_eq!(map(plain(KeyCode::Esc), &delete), Some(Command::RequestQuit)); +} + +#[test] +fn undersized_keeps_the_quit_and_startup_warning_dialog_keys() { + // Both dialogs are drawn over the placeholder, so their keys still work. + let mut quitting = undersized(UiPhase::Panels); + quitting.quit_confirm = true; + assert_eq!(map(plain(KeyCode::Char('y')), &quitting), Some(Command::ConfirmQuit)); + assert_eq!(map(plain(KeyCode::Char('n')), &quitting), Some(Command::CancelQuit)); + assert_eq!(map(plain(KeyCode::Esc), &quitting), Some(Command::CancelQuit)); + + let mut over_viewer = undersized(UiPhase::Viewer(open_viewer(filecommand_core::viewer::ViewMode::Text))); + over_viewer.quit_confirm = true; + assert_eq!(map(plain(KeyCode::Enter), &over_viewer), Some(Command::ConfirmQuit)); + + let mut warning = undersized(UiPhase::Panels); + warning.set_startup_warning("usermenu.toml is malformed".to_string()); + assert_eq!(map(plain(KeyCode::Esc), &warning), Some(Command::DismissStartupWarning)); + assert_eq!(map(plain(KeyCode::Char('x')), &warning), Some(Command::DismissStartupWarning)); +} + +// --------------------------------------------------------------------- +// reducer-overlay-structure: one precedence order, one overlay slot +// (application-shell "Modal overlays are mutually exclusive by +// construction"; tasks 6.2, 6.3, 6.6) +// --------------------------------------------------------------------- + +/// 6.2 "Another overlay's shortcut is ignored while an overlay is open": +/// with the fuzzy-jump dialog up, F9 and Alt+F1 map to nothing, so neither +/// the menu nor the drive dialog can open and the dialog's pattern and +/// highlight are untouched. +#[test] +fn another_overlays_shortcut_maps_to_nothing_while_fuzzy_jump_is_open() { + let mut dialog = filecommand_core::quicksearch::FuzzyJumpState::new(); + dialog.apply(&TextEditOp::Insert('p')); + let mut state = panels(); + state.open_overlay(Overlay::FuzzyJump(dialog.clone())); + assert_eq!(map(plain(KeyCode::F(9)), &state), None, "F9 does not open the menu"); + assert_eq!(map(key(KeyCode::F(1), KeyModifiers::ALT), &state), None, "Alt+F1 does not open drive select"); + assert_eq!(map(key(KeyCode::F(2), KeyModifiers::ALT), &state), None, "Alt+F2 does not open drive select"); + assert_eq!(map(plain(KeyCode::F(1)), &state), None, "F1 does not open Help"); + assert_eq!(state.fuzzy_jump(), Some(&dialog), "the slot and the pattern are unchanged"); + assert_eq!(state.fuzzy_jump().unwrap().pattern.text(), "p"); +} + +/// 6.3 "Unbound keys never reach the panels or the command line": a +/// printable the user menu does not bind maps to nothing, so the command +/// line and the panel cursor cannot change. +#[test] +fn an_unbound_printable_maps_to_nothing_while_the_user_menu_is_open() { + let mut state = typing("dir"); + state.left.entries = + vec![Entry::new("a.txt", EntryKind::File, 1, None, false, false), Entry::new("b.txt", EntryKind::File, 1, None, false, false)]; + state.left.cursor = 1; + state.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); + for c in ['q', 'Z', '7', ' ', '/'] { + assert_eq!(map(plain(KeyCode::Char(c)), &state), None, "{c:?} is ignored"); + } + assert_eq!(map(key(KeyCode::Char('r'), KeyModifiers::ALT), &state), None, "type-ahead never starts under an overlay"); + assert_eq!(state.command_line.text(), "dir"); + assert_eq!(state.left.cursor, 1); +} + +/// 6.6 "The quit dialog stacks above an open overlay": after the reducer +/// cancels the quit dialog the pull-down is still in the slot, and the next +/// key maps to a menu command again. +#[test] +fn after_cancelling_quit_over_a_pulldown_the_next_key_is_a_menu_command() { + let mut state = panels(); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + assert_eq!( + map(plain(KeyCode::F(10)), &state), + Some(Command::MenuClose), + "F10 over a pull-down closes it; the quit request comes from Esc or the menu" + ); + let (state, _) = filecommand_core::update(state, Command::RequestQuit); + assert_eq!(map(plain(KeyCode::Down), &state), None, "the quit dialog owns the keys above the menu"); + assert_eq!(map(plain(KeyCode::Char('n')), &state), Some(Command::CancelQuit)); + let (state, _) = filecommand_core::update(state, Command::CancelQuit); + assert_eq!(state.menu().map(|m| m.active), Some(MenuId::Files), "the pull-down is exactly as it was"); + assert_eq!(map(plain(KeyCode::Down), &state), Some(Command::MenuSelectNext)); + assert_eq!(map(plain(KeyCode::Esc), &state), Some(Command::MenuCollapse)); +} diff --git a/crates/filecommand-tui/src/lib.rs b/crates/filecommand-tui/src/lib.rs index d17f071..6bbae57 100644 --- a/crates/filecommand-tui/src/lib.rs +++ b/crates/filecommand-tui/src/lib.rs @@ -5,6 +5,10 @@ pub mod app; pub mod clipboard; pub mod clock; +pub mod config_writer; +pub mod console; +pub mod diag; +pub mod history_writer; pub mod hitmap; pub mod input; pub mod layout; diff --git a/crates/filecommand-tui/src/main.rs b/crates/filecommand-tui/src/main.rs index 56eb748..d9fe805 100644 --- a/crates/filecommand-tui/src/main.rs +++ b/crates/filecommand-tui/src/main.rs @@ -11,6 +11,12 @@ fn main() { } fn print_usage() { + // Named here so a user can find their files without reading docs + // (config-profile-directory "Usage names the profile directory"; + // design D4). Nothing is created — resolution only. + let default_profile = filecommand_tui::app::platform_default_profile_dir() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "the current directory (no platform config directory could be resolved)".to_string()); println!( "FileCommand {version} A keyboard-driven, dual-panel file manager for the terminal. @@ -23,8 +29,13 @@ OPTIONS: --theme Override the active theme (built-in or custom) --nomouse Disable mouse capture --nosplash Skip the startup splash screen + --noanimations Disable decorative UI animations + --config-dir Use for config.toml, history.json, usermenu.toml + --log Append diagnostic messages to (or set FILECOMMAND_LOG) -Press F1 inside the application for the full keyboard reference.", +Press F1 inside the application for the full keyboard reference. + +Default profile directory: {default_profile}", version = env!("CARGO_PKG_VERSION") ); } diff --git a/crates/filecommand-tui/src/terminal.rs b/crates/filecommand-tui/src/terminal.rs index e18b037..5d5db97 100644 --- a/crates/filecommand-tui/src/terminal.rs +++ b/crates/filecommand-tui/src/terminal.rs @@ -5,6 +5,8 @@ use std::io::{self, Stdout}; use crossterm::cursor::{Hide, Show}; +#[cfg(not(windows))] +use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste}; use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; @@ -89,7 +91,10 @@ impl TerminalGuard { /// command line would inherit mouse tracking, and on Windows capture /// leaves conhost's QuickEdit disabled for as long as it stays on /// (design D1; application-shell "Suspended shell run gets a normal - /// terminal"). + /// terminal"). Bracketed paste is likewise disabled first, so a child + /// program gets a terminal without it (idle-aware-event-loop design D6; + /// text-field-editing "Child programs get a terminal without bracketed + /// paste"). pub fn suspend(&mut self) -> io::Result<()> { if !self.state.begin_suspend() { return Ok(()); @@ -97,6 +102,7 @@ impl TerminalGuard { suspend_with( self.mouse_enabled, disable_raw_mode, + || set_bracketed_paste(false), || execute!(io::stdout(), DisableMouseCapture).map(|_| ()), || execute!(io::stdout(), LeaveAlternateScreen, Show).map(|_| ()), ) @@ -118,6 +124,7 @@ impl TerminalGuard { self.mouse_enabled, enable_raw_mode, || execute!(io::stdout(), EnterAlternateScreen, Hide).map(|_| ()), + || set_bracketed_paste(true), || execute!(io::stdout(), EnableMouseCapture).map(|_| ()), )?; // The alternate screen we come back to is blank, and ratatui's @@ -141,35 +148,70 @@ impl Drop for TerminalGuard { /// `new` (which cannot be exercised in a headless test runner — see /// [`SuspendState`]'s doc comment — so it has no need of the closure-injected /// shape [`suspend_with`]/[`resume_with`] give `suspend`/`resume`): the -/// alternate screen, a hidden cursor, and — when `mouse_enabled` — mouse -/// capture, in that order. Mouse capture last so it only ever becomes active -/// once the alternate screen is already in place. +/// alternate screen, a hidden cursor, bracketed paste (off Windows — +/// crossterm's input layer reports `Event::Paste` only there, so the mode is +/// never enabled on Windows, where a paste arrives as ordinary key events; +/// idle-aware-event-loop design D6), and — when `mouse_enabled` — mouse +/// capture, in that order. Capture last so it only ever becomes active once +/// the alternate screen is already in place. fn enter_commands(w: &mut impl io::Write, mouse_enabled: bool) -> io::Result<()> { + #[cfg(not(windows))] + execute!(w, EnterAlternateScreen, Hide, EnableBracketedPaste)?; + #[cfg(windows)] + execute!(w, EnterAlternateScreen, Hide)?; if mouse_enabled { - execute!(w, EnterAlternateScreen, Hide, EnableMouseCapture) + execute!(w, EnableMouseCapture)?; + } + Ok(()) +} + +/// The bracketed-paste step of `enter_commands`, `suspend`, `resume`, and +/// [`restore_terminal`]: enable or disable the mode on the real terminal. +/// Off Windows this writes the standard `CSI ?2004h`/`CSI ?2004l` sequence +/// (a harmless no-op where the console does not support it). On Windows the +/// mode is never touched at all — crossterm cannot report pastes there, and +/// on an ANSI-capable console enabling it would wrap pastes in delimiters +/// the Windows reader would deliver as stray key events +/// (idle-aware-event-loop design D6; text-field-editing "Terminal paste +/// arrives as one paste"). +#[cfg(not(windows))] +fn set_bracketed_paste(enable: bool) -> io::Result<()> { + if enable { + execute!(io::stdout(), EnableBracketedPaste).map(|_| ()) } else { - execute!(w, EnterAlternateScreen, Hide) + execute!(io::stdout(), DisableBracketedPaste).map(|_| ()) } } +/// The Windows no-op half of the bracketed-paste step — see the +/// `cfg(not(windows))` arm above for why the mode is never touched there. +#[cfg(windows)] +fn set_bracketed_paste(enable: bool) -> io::Result<()> { + let _ = enable; + Ok(()) +} + /// The ordered steps [`TerminalGuard::suspend`] performs, with the actual -/// raw-mode, mouse-capture, and alternate-screen operations injected as -/// separate closures so the *order between them* — raw mode off, then (when -/// `mouse_enabled`) mouse capture off, then the alternate screen left — is -/// unit-testable without a real console, the same constraint -/// [`install_panic_hook_with`] works around for the panic hook (design D1: -/// "otherwise a child shell inherits mouse tracking"; application-shell -/// "Suspended shell run gets a normal terminal"). Production code and the -/// test in `tests/panic_restoration.rs` both call this one function, so -/// there is no separate "description" of the order to drift from what -/// actually runs. +/// raw-mode, bracketed-paste, mouse-capture, and alternate-screen operations +/// injected as separate closures so the *order between them* — raw mode off, +/// then bracketed paste off (a child program gets a terminal without it; +/// idle-aware-event-loop design D6), then (when `mouse_enabled`) mouse +/// capture off, then the alternate screen left — is unit-testable without a +/// real console, the same constraint [`install_panic_hook_with`] works around +/// for the panic hook (design D1: "otherwise a child shell inherits mouse +/// tracking"; application-shell "Suspended shell run gets a normal +/// terminal"). Production code and the test in `tests/panic_restoration.rs` +/// both call this one function, so there is no separate "description" of the +/// order to drift from what actually runs. pub fn suspend_with( mouse_enabled: bool, set_raw_mode_off: impl FnOnce() -> io::Result<()>, + disable_bracketed_paste: impl FnOnce() -> io::Result<()>, disable_mouse_capture: impl FnOnce() -> io::Result<()>, leave_alternate_screen: impl FnOnce() -> io::Result<()>, ) -> io::Result<()> { set_raw_mode_off()?; + disable_bracketed_paste()?; if mouse_enabled { disable_mouse_capture()?; } @@ -177,17 +219,20 @@ pub fn suspend_with( } /// The [`TerminalGuard::resume`] counterpart to [`suspend_with`]: raw mode -/// on, then the alternate screen re-entered, then (when `mouse_enabled`) -/// mouse capture re-enabled — the reverse of `suspend_with`'s order, so -/// capture is never active outside the alternate screen either way. +/// on, then the alternate screen re-entered, then bracketed paste re-enabled +/// (off Windows), then (when `mouse_enabled`) mouse capture re-enabled — the +/// reverse of `suspend_with`'s order, so neither capture nor paste mode is +/// ever active outside the alternate screen either way. pub fn resume_with( mouse_enabled: bool, set_raw_mode_on: impl FnOnce() -> io::Result<()>, enter_alternate_screen: impl FnOnce() -> io::Result<()>, + enable_bracketed_paste: impl FnOnce() -> io::Result<()>, enable_mouse_capture: impl FnOnce() -> io::Result<()>, ) -> io::Result<()> { set_raw_mode_on()?; enter_alternate_screen()?; + enable_bracketed_paste()?; if mouse_enabled { enable_mouse_capture()?; } @@ -195,19 +240,27 @@ pub fn resume_with( } /// Leave raw mode and the alternate screen, and unconditionally release -/// mouse capture — harmless when it was never enabled (the panic hook and -/// `Drop` have no access to the `[mouse] enabled` flag, so there is nothing -/// to gate on; application-shell "Panic hook restores the terminal before -/// reporting"; design D1). Idempotent and infallible from the caller's -/// perspective (errors are swallowed) — safe to call from `Drop` or from a -/// panic hook where nothing useful can be done with a failure anyway. +/// mouse capture and bracketed paste — harmless when either was never +/// enabled (the panic hook and `Drop` have no access to the `[mouse] +/// enabled` flag, so there is nothing to gate on; application-shell "Panic +/// hook restores the terminal before reporting"; design D1). Bracketed +/// paste's disable is itself a Windows no-op, since the mode is never +/// enabled there (idle-aware-event-loop design D6). Idempotent and +/// infallible from the caller's perspective (errors are swallowed) — safe to +/// call from `Drop` or from a panic hook where nothing useful can be done +/// with a failure anyway. pub fn restore_terminal() { let _ = disable_raw_mode(); + let _ = set_bracketed_paste(false); let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen, Show); } /// Install a panic hook that restores the terminal *before* the default (or /// previously installed) hook prints its report, then chains to that hook. +/// Must be called on the UI thread — the thread that owns the terminal and +/// runs the event loop — because only a panic on that thread restores and +/// reports (application-shell "Panic hook restores the terminal before +/// reporting"; process-lifecycle-hardening design D4). pub fn install_panic_hook() { install_panic_hook_with(restore_terminal); } @@ -216,9 +269,23 @@ pub fn install_panic_hook() { /// exists so tests can verify hook ordering/chaining without depending on a /// real console (raw-mode/alternate-screen calls are not portable inside a /// captured test harness). +/// +/// The installing thread's `ThreadId` is captured into the hook closure +/// (rather than a process-wide `OnceLock`, which the multi-threaded test +/// harness could not reset between installations): the hook runs `restore` +/// and the previous hook only when the panicking thread is that thread. A +/// worker panic does neither — `std::panic::set_hook` is process-wide, and +/// the default hook would otherwise print `thread '' panicked at +/// …` onto the alternate screen while ratatui keeps diff-drawing over it. +/// The worker's own `catch_unwind` recovers the payload and turns it into +/// that worker's normal failure reply (design D4/D6). pub fn install_panic_hook_with(restore: fn()) { + let ui_thread = std::thread::current().id(); let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { + if std::thread::current().id() != ui_thread { + return; + } restore(); previous(info); })); @@ -238,6 +305,12 @@ mod tests { /// runs, then the chained previous hook runs) via a real panic through /// `catch_unwind`, restoring whatever hook was installed immediately /// after so it can't leak into other tests. + /// + /// The second half (process-lifecycle-hardening design D4; scenario "A + /// worker panic leaves the terminal alone"): a panic on a *spawned* + /// thread, while the same hook is installed, records neither step — the + /// terminal is not restored out from under the UI, and no report is + /// written over it. #[test] fn panic_inside_guarded_scope_restores_terminal_before_report_prints() { // A plain `fn()` (required by `install_panic_hook_with`'s @@ -259,12 +332,19 @@ mod tests { ORDER.lock().unwrap().push("report"); })); + // Installed from this thread, which therefore plays the UI thread. install_panic_hook_with(record_restore); let result = std::panic::catch_unwind(|| panic!("boom")); - std::panic::set_hook(default_hook); - assert!(result.is_err()); - assert_eq!(*ORDER.lock().unwrap(), vec!["restore", "report"]); + assert_eq!(*ORDER.lock().unwrap(), vec!["restore", "report"], "a UI-thread panic restores first, then reports"); + + // A worker-thread panic, caught by joining: the hook declines both + // steps, so the log is unchanged. + let worker = std::thread::spawn(|| panic!("worker boom")).join(); + assert!(worker.is_err(), "the spawned thread did panic"); + assert_eq!(*ORDER.lock().unwrap(), vec!["restore", "report"], "a worker panic neither restores the terminal nor chains to the report hook"); + + std::panic::set_hook(default_hook); } #[test] diff --git a/crates/filecommand-tui/src/views/command_line.rs b/crates/filecommand-tui/src/views/command_line.rs index a43c8a0..535bda5 100644 --- a/crates/filecommand-tui/src/views/command_line.rs +++ b/crates/filecommand-tui/src/views/command_line.rs @@ -1,8 +1,13 @@ -//! The live command line: `C:\NORTON>dir_`. +//! The live command line: `C:\NORTON>dir`. //! //! The prompt is derived from the active panel's path (never stored), so it -//! follows Tab and directory changes for free. +//! follows Tab and directory changes for free. The text is the shared +//! `LineEdit` model behind every single-line field (text-field-editing); +//! the drawn `_` caret of the old append-only line is gone — the real +//! terminal cursor, positioned at the caret by the caller via +//! `Frame::set_cursor_position`, replaces it (design D5). +use filecommand_core::line_edit::LineEdit; use filecommand_core::listing::{display_width, pad_to_width}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use ratatui::buffer::Buffer; @@ -10,13 +15,14 @@ use ratatui::layout::Rect; use crate::style::role_style; -/// The block standing in for the text cursor. The terminal cursor itself is -/// hidden while the TUI owns the screen. -const CURSOR: char = '_'; - -pub fn render_command_line(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, prompt: &str, buffer_text: &str) { +/// Renders the command line and returns the terminal cursor's `(x, y)` +/// position — the caret's cell — or `None` when the row has no room to +/// draw at all. The text window scrolls around the caret so what is being +/// edited never falls off the right edge (responsive-layout "Command line +/// keeps the caret visible"). +pub fn render_command_line(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, prompt: &str, buffer: &LineEdit) -> Option<(u16, u16)> { if area.height == 0 || area.width == 0 { - return; + return None; } let style = role_style(theme, Role::CommandLine, depth); let width = area.width as usize; @@ -31,12 +37,18 @@ pub fn render_command_line(buf: &mut Buffer, area: Rect, theme: &Theme, depth: C } else { prompt }; - let full = format!("{prompt}{buffer_text}{CURSOR}"); - // A long path plus a long command would otherwise push what is being - // typed off the right edge, so scroll the *tail* into view instead of - // truncating it away. - let text = if display_width(&full) > width { clip_left(&full, width) } else { pad_to_width(&full, width) }; - buf.set_string(area.x, area.y, text, style); + let prompt_w = display_width(prompt); + let text_w = width.saturating_sub(prompt_w); + let (start, caret_col) = buffer.window(text_w.max(1)); + let slice: String = buffer.text().chars().skip(start).take(text_w).collect(); + buf.set_string(area.x, area.y, pad_to_width(&format!("{prompt}{slice}"), width), style); + // `caret_col` counts window chars; the cursor column is the display + // width of the chars before it. A caret at the end of a full window + // (`caret_col == text_w`) clamps onto the last cell, which is also the + // row's rightmost column — the cursor never lands outside the area. + let caret_cells = display_width(&slice.chars().take(caret_col).collect::()); + let col = (prompt_w + caret_cells).min(width.saturating_sub(1)); + Some((area.x + col as u16, area.y)) } /// Truncate `s` from the left, keeping its last `max_w - 1` display columns @@ -70,57 +82,53 @@ fn left_truncate_with_ellipsis(s: &str, max_w: usize) -> String { out } -/// Keep the last `width` display columns of `s`. -fn clip_left(s: &str, width: usize) -> String { - let mut out: Vec = Vec::new(); - let mut acc = 0usize; - for ch in s.chars().rev() { - let cw = display_width(&ch.to_string()); - if acc + cw > width { - break; - } - out.push(ch); - acc += cw; - } - out.reverse(); - let mut text: String = out.into_iter().collect(); - while display_width(&text) < width { - text.push(' '); - } - text -} - #[cfg(test)] mod tests { use super::*; - fn render(width: u16, prompt: &str, buffer_text: &str) -> String { + fn render(width: u16, prompt: &str, buffer: &LineEdit) -> (String, Option<(u16, u16)>) { let area = Rect { x: 0, y: 0, width, height: 1 }; let mut buf = Buffer::empty(area); - render_command_line(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, prompt, buffer_text); - (0..width).map(|x| buf[(x, 0)].symbol()).collect() + let caret = render_command_line(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, prompt, buffer); + ((0..width).map(|x| buf[(x, 0)].symbol()).collect(), caret) + } + + #[test] + fn renders_prompt_then_buffer_with_the_caret_past_the_text() { + let (line, caret) = render(20, r"C:\NORTON>", &LineEdit::from("dir")); + assert_eq!(line.trim_end(), r"C:\NORTON>dir"); + assert_eq!(caret, Some((13, 0)), "the terminal cursor replaces the old drawn `_` (prompt is 10 cells, text 3)"); } #[test] - fn renders_prompt_then_buffer_then_cursor() { - assert_eq!(render(20, r"C:\NORTON>", "dir").trim_end(), r"C:\NORTON>dir_"); + fn empty_buffer_reports_the_caret_right_after_the_prompt() { + let (line, caret) = render(20, r"C:\>", &LineEdit::default()); + assert_eq!(line.trim_end(), r"C:\>"); + assert_eq!(caret, Some((4, 0))); } #[test] - fn empty_buffer_renders_just_the_prompt_and_cursor() { - assert_eq!(render(20, r"C:\>", "").trim_end(), r"C:\>_"); + fn caret_mid_text_stays_put_and_reports_its_cell() { + let mut buffer = LineEdit::from("echo hello"); + buffer.left(); + buffer.left(); + let (line, caret) = render(30, r"C:\>", &buffer); + assert_eq!(line.trim_end(), r"C:\>echo hello"); + assert_eq!(caret, Some((4 + 8, 0)), "caret before `lo`: prompt width 4 plus 8 chars"); } #[test] - fn overlong_lines_keep_the_typed_tail_visible() { - let line = render(10, r"C:\a\very\long\path>", "echo hello"); - assert_eq!(line, "cho hello_"); + fn overlong_lines_scroll_to_keep_the_caret_visible() { + let (line, caret) = render(10, r"C:\a\very\long\path>", &LineEdit::from("echo hello")); + assert_eq!(line, "…ath>hello", "the prompt left-truncates and the window shows the text's tail"); + assert_eq!(caret, Some((9, 0)), "the caret at the end clamps onto the row's last cell"); } #[test] fn output_always_fills_the_row_exactly() { for width in [1u16, 5, 40, 80] { - assert_eq!(render(width, r"C:\>", "dir").chars().count(), width as usize); + let (line, _) = render(width, r"C:\>", &LineEdit::from("dir")); + assert_eq!(line.chars().count(), width as usize); } } @@ -128,34 +136,33 @@ mod tests { fn a_prompt_over_half_the_width_left_truncates_with_ellipsis() { // Prompt is 20 columns wide; at width 30, half is 15 — over the // limit, so it left-truncates to 15 columns with a leading `…`. - let line = render(30, r"C:\a\very\long\path>", ""); + let (line, _) = render(30, r"C:\a\very\long\path>", &LineEdit::default()); assert!(line.starts_with('\u{2026}'), "`{line}`"); - assert!(line.trim_end().ends_with("path>_"), "the tail (with the `>`) survives: `{line}`"); - let prompt_part = line.trim_end().strip_suffix('_').unwrap(); - assert_eq!(display_width(prompt_part), 15, "prompt truncates to half the width"); + assert!(line.trim_end().ends_with("path>"), "the tail (with the `>`) survives: `{line}`"); + assert_eq!(display_width(line.trim_end()), 15, "prompt truncates to half the width"); } #[test] fn a_short_prompt_is_never_truncated() { - let line = render(30, r"C:\>", ""); + let (line, _) = render(30, r"C:\>", &LineEdit::default()); assert!(!line.contains('\u{2026}'), "`{line}`"); - assert!(line.starts_with(r"C:\>_"), "`{line}`"); + assert!(line.starts_with(r"C:\>"), "`{line}`"); } #[test] fn prompt_truncation_and_caret_scrolling_combine() { // A long prompt (left-truncated with `…`) plus a long typed command // (right-scrolled to keep the caret visible) at once. - let line = render(20, r"C:\a\very\long\nested\path>", "echo hello world"); + let (line, caret) = render(20, r"C:\a\very\long\nested\path>", &LineEdit::from("echo hello world")); assert_eq!(display_width(&line), 20); - assert!(line.ends_with('_'), "the caret stays visible: `{line}`"); - assert!(line.ends_with("world_"), "`{line}`"); + assert!(line.ends_with(" world"), "the caret's end of the text stays visible: `{line}`"); + assert_eq!(caret, Some((19, 0)), "the caret clamps onto the last cell"); } #[test] fn zero_width_area_is_a_noop() { let area = Rect { x: 0, y: 0, width: 0, height: 1 }; let mut buf = Buffer::empty(area); - render_command_line(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, ">", ""); + assert_eq!(render_command_line(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, ">", &LineEdit::default()), None); } } diff --git a/crates/filecommand-tui/src/views/conflict_dialog.rs b/crates/filecommand-tui/src/views/conflict_dialog.rs index 779a1af..92f7bbe 100644 --- a/crates/filecommand-tui/src/views/conflict_dialog.rs +++ b/crates/filecommand-tui/src/views/conflict_dialog.rs @@ -1,28 +1,102 @@ -//! Overwrite-conflict dialog: source vs. target size/date, and the -//! Overwrite/Skip/Rename/Overwrite All/Skip All choices. +//! Overwrite-conflict dialog: source vs. target size/date, the +//! Overwrite/Skip/Rename/Overwrite All/Skip All choices, and — on its own +//! row below them — Cancel (operation-dialogs "Overwrite-conflict dialog"; +//! process-lifecycle-hardening design D7). use filecommand_core::dialogs::overlay_rect; use filecommand_core::fs_ops::ConflictInfo; +use filecommand_core::line_edit::LineEdit; use filecommand_core::listing::{display_width, format_date, format_size, format_time, pad_to_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::{label_spans, DialogLayout}; use crate::style::role_style; const BOX_INNER_W: usize = 46; const MIN_INNER_W: usize = 30; +/// Frame rows: title, "already exists", Source, Target, blank, the choice +/// row (or the rename field), the Cancel row, blank, bottom border. One +/// taller than before the Cancel row existed (design D7). +const BOX_H: u16 = 9; +const CHOICES_ROW: &str = "(O)verwrite (S)kip (R)ename Over(w)rite All Skip (A)ll"; +const CANCEL_ROW: &str = "(C)ancel"; -pub fn render_conflict(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, info: &ConflictInfo, rename_input: &Option) { +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle; its text rows — ` already exists`, Source, Target, +/// a blank, then either the choice row and the `(C)ancel` row or two blanks +/// while the in-place rename field is showing, then a blank — each clamped +/// to the inner width; and the hotkey spans of the choice and Cancel rows +/// recorded as buttons (design D2: this dialog has no framed buttons, so +/// each hotkey span itself is a button). The buttons are empty while the +/// rename input is showing instead of those rows: there Esc closes the field +/// rather than cancelling the job, and `c` is text. +pub fn layout(area: Rect, info: &ConflictInfo, rename_input: &Option) -> DialogLayout { + let r = overlay_rect((BOX_INNER_W as u16 + 2, BOX_H), (MIN_INNER_W as u16 + 2, BOX_H), (area.width, area.height)); + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + let fit = |text: &str| pad_to_width(&truncate_with_ellipsis(text, inner_w), inner_w); + + let src_dt = info.source_modified.map(|d| format!("{} {}", format_date(d), format_time(d))).unwrap_or_default(); + let tgt_dt = info.target_modified.map(|d| format!("{} {}", format_date(d), format_time(d))).unwrap_or_default(); + let mut lines = vec![ + fit(&format!("{} already exists", info.source_name.to_string_lossy())), + fit(&format!("Source: {} {}", format_size(info.source_size), src_dt)), + fit(&format!("Target: {} {}", format_size(info.target_size), tgt_dt)), + fit(""), + ]; + let mut buttons = Vec::new(); + if rename_input.is_some() { + lines.push(fit("")); + lines.push(fit("")); + } else { + let choices = truncate_with_ellipsis(CHOICES_ROW, inner_w); + let cancel = truncate_with_ellipsis(CANCEL_ROW, inner_w); + buttons.extend(label_spans( + &choices, + rect.x + 1, + rect.y + 5, + &[ + ("(O)verwrite", ButtonId::ConflictOverwrite), + ("(S)kip", ButtonId::ConflictSkip), + ("(R)ename", ButtonId::ConflictRename), + ("Over(w)rite All", ButtonId::ConflictOverwriteAll), + ("Skip (A)ll", ButtonId::ConflictSkipAll), + ], + )); + buttons.extend(label_spans(&cancel, rect.x + 1, rect.y + 6, &[("(C)ancel", ButtonId::ConflictCancel)])); + lines.push(pad_to_width(&choices, inner_w)); + lines.push(pad_to_width(&cancel, inner_w)); + } + lines.push(fit("")); + DialogLayout { rect, buttons, rows: Vec::new(), lines } +} + +/// Renders the dialog. Returns the terminal cursor's `(x, y)` position +/// while the in-place rename field is showing (it is a shared text field +/// per text-field-editing, so the cursor sits at its caret), `None` +/// otherwise. +pub fn render_conflict( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + info: &ConflictInfo, + rename_input: &Option, +) -> Option<(u16, u16)> { + draw_conflict(buf, theme, depth, rename_input, &layout(area, info, rename_input)) +} + +/// Paint the dialog from an already computed [`layout`]; `rename_input` is +/// the field drawn over row 5 while it is open. Returns the field's caret +/// position, as [`render_conflict`] does. +pub fn draw_conflict(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, rename_input: &Option, l: &DialogLayout) -> Option<(u16, u16)> { let body = role_style(theme, Role::DialogPrimary, depth); let field_style = role_style(theme, Role::DialogInput, depth); - - let r = overlay_rect((BOX_INNER_W as u16 + 2, 8), (MIN_INNER_W as u16 + 2, 8), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); let title = " File Exists "; let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); @@ -33,65 +107,73 @@ pub fn render_conflict(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Color let row = |buf: &mut Buffer, dy: u16, text: &str| { buf.set_string(x, y + dy, "\u{2551}", body); - buf.set_string(x + 1, y + dy, pad_to_width(&truncate_with_ellipsis(text, inner_w), inner_w), body); + buf.set_string(x + 1, y + dy, text, body); buf.set_string(x + 1 + inner_w as u16, y + dy, "\u{2551}", body); }; + for (i, text) in l.lines.iter().enumerate() { + row(buf, 1 + i as u16, text); + } - row(buf, 1, &format!("{} already exists", info.source_name.to_string_lossy())); - let src_dt = info.source_modified.map(|d| format!("{} {}", format_date(d), format_time(d))).unwrap_or_default(); - row(buf, 2, &format!("Source: {} {}", format_size(info.source_size), src_dt)); - let tgt_dt = info.target_modified.map(|d| format!("{} {}", format_date(d), format_time(d))).unwrap_or_default(); - row(buf, 3, &format!("Target: {} {}", format_size(info.target_size), tgt_dt)); - row(buf, 4, ""); + let caret = rename_input.as_ref().map(|edit| { + // The rename field keeps its pre-editing `[name]` space-padded look + // (the drawn `_` caret is gone — the terminal cursor at the caret + // replaces it, design D5), scrolled via `window` like every other + // field. It overdraws the blank row 5 the layout left for it. + let field_w = inner_w.saturating_sub(12).max(1); + let (start, caret_col) = edit.window(field_w); + let slice: String = edit.text().chars().skip(start).take(field_w).collect(); + let pad = inner_w.saturating_sub(10).saturating_sub(2 + display_width(&slice)); + buf.set_string(x + 1, y + 5, "New name:", body); + buf.set_string(x + 11, y + 5, "[", field_style); + buf.set_string(x + 12, y + 5, &slice, field_style); + buf.set_string(x + 12 + display_width(&slice) as u16, y + 5, format!("]{}", " ".repeat(pad)), field_style); + let caret_cells = display_width(&slice.chars().take(caret_col).collect::()); + (x + 12 + caret_cells.min(field_w.saturating_sub(1)) as u16, y + 5) + }); + + buf.set_string(x, y + l.rect.height - 1, &bottom, body); + caret +} - match rename_input { - Some(name) => { - let field = format!("[{name}_]"); - buf.set_string(x, y + 5, "\u{2551}", body); - buf.set_string(x + 1, y + 5, "New name:", body); - buf.set_string(x + 11, y + 5, pad_to_width(&field, inner_w.saturating_sub(10)), field_style); - buf.set_string(x + 1 + inner_w as u16, y + 5, "\u{2551}", body); +#[cfg(test)] +mod tests { + use super::*; + + fn info() -> ConflictInfo { + ConflictInfo { + source_name: "a.txt".into(), + source_size: 1, + source_modified: None, + target_path: std::path::PathBuf::from("/right/a.txt"), + target_size: 2, + target_modified: None, } - None => row(buf, 5, "(O)verwrite (S)kip (R)ename Over(w)rite All Skip (A)ll"), } - buf.set_string(x, y + 6, "\u{2551}", body); - buf.set_string(x + 1, y + 6, " ".repeat(inner_w), body); - buf.set_string(x + 1 + inner_w as u16, y + 6, "\u{2551}", body); - buf.set_string(x, y + box_h - 1, &bottom, body); -} - -/// The hotkey-text button spans `render_conflict` currently draws at `area` -/// for the `(O)verwrite (S)kip (R)ename Over(w)rite All Skip (A)ll` row -/// — this dialog has no framed buttons, so each hotkey span itself is -/// recorded as a button (design D2). Empty while the in-place rename input -/// is showing instead of that row, mirroring the renderer exactly. Mirrors -/// `render_conflict`'s own `overlay_rect` geometry so the rects can never -/// drift from what's actually on screen. -pub fn hit_buttons(area: Rect, rename_input: &Option) -> Vec<(Rect, ButtonId)> { - if rename_input.is_some() { - return vec![]; + /// process-lifecycle-hardening task 4.6: the `(C)ancel` control sits on + /// its own row directly below the choice row, at the dialog's left text + /// column, and the choice row's own spans are exactly what they were + /// before the Cancel row existed (design D7). Note the pre-existing + /// state of that row: at the dialog's 46-column inner width it is + /// truncated with an ellipsis, so only the first three labels are drawn + /// whole and recorded as spans — unchanged by this change. + #[test] + fn hit_buttons_record_the_cancel_span_below_the_choice_row() { + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let buttons = layout(area, &info(), &None).buttons; + let ids: Vec<&ButtonId> = buttons.iter().map(|(_, id)| id).collect(); + assert_eq!(ids, vec![&ButtonId::ConflictOverwrite, &ButtonId::ConflictSkip, &ButtonId::ConflictRename, &ButtonId::ConflictCancel]); + let choice_row = buttons[0].0.y; + let (cancel_rect, _) = buttons.last().unwrap(); + assert_eq!(cancel_rect.y, choice_row + 1, "one row below the choices"); + assert_eq!(cancel_rect.x, buttons[0].0.x, "at the same left column as (O)verwrite"); + assert_eq!(cancel_rect.width, display_width("(C)ancel") as u16); + assert!(buttons[..3].iter().all(|(r, _)| r.y == choice_row), "the choice spans keep their row"); } - let r = overlay_rect((BOX_INNER_W as u16 + 2, 8), (MIN_INNER_W as u16 + 2, 8), (area.width, area.height)); - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - let text = truncate_with_ellipsis("(O)verwrite (S)kip (R)ename Over(w)rite All Skip (A)ll", inner_w); - let base_x = x + 1; - let row_y = y + 5; - let mut out = Vec::new(); - for (label, id) in [ - ("(O)verwrite", ButtonId::ConflictOverwrite), - ("(S)kip", ButtonId::ConflictSkip), - ("(R)ename", ButtonId::ConflictRename), - ("Over(w)rite All", ButtonId::ConflictOverwriteAll), - ("Skip (A)ll", ButtonId::ConflictSkipAll), - ] { - if let Some(byte_idx) = text.find(label) { - let col = text[..byte_idx].chars().count() as u16; - out.push((Rect { x: base_x + col, y: row_y, width: display_width(label) as u16, height: 1 }, id)); - } + #[test] + fn hit_buttons_are_empty_while_the_rename_field_is_open() { + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + assert!(layout(area, &info(), &Some(LineEdit::default())).buttons.is_empty()); } - out } diff --git a/crates/filecommand-tui/src/views/delete_confirm.rs b/crates/filecommand-tui/src/views/delete_confirm.rs index a7a9baa..5708ad6 100644 --- a/crates/filecommand-tui/src/views/delete_confirm.rs +++ b/crates/filecommand-tui/src/views/delete_confirm.rs @@ -1,87 +1,200 @@ //! F8 delete confirmation dialog: names a single item or gives a count for -//! a multi-selection, warns the deletion is permanent, and asks a second -//! time when a selected directory may be non-empty. +//! a multi-selection, warns the deletion is permanent, asks a second time +//! when a selected directory may be non-empty, and discloses how many of the +//! selected sources the source panel's quick filter hides (selection +//! "Operations disclose selected entries hidden by the quick filter"). use filecommand_core::dialogs::overlay_rect; -use filecommand_core::fs_ops::dialog::FileOpSetup; +use filecommand_core::fs_ops::dialog::{hidden_by_filter_disclosure, FileOpSetup}; +use filecommand_core::fs_ops::SourceItem; use filecommand_core::listing::{display_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::{label_spans, DialogLayout}; use crate::style::role_style; const MIN_INNER_W: usize = 24; +const BUTTONS: &str = "(Y)es (N)o"; -pub fn render_delete_confirm(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, setup: &FileOpSetup) { - let FileOpSetup::DeleteConfirm { sources, needs_second_confirm, confirmed_once, .. } = setup else { return }; - let body = role_style(theme, Role::DialogPrimary, depth); - - let subject = match sources.as_slice() { +/// The question line. +fn question_for(sources: &[SourceItem], needs_second_confirm: bool, confirmed_once: bool) -> String { + let subject = match sources { [one] => format!("{}", one.original_name.to_string_lossy()), many => format!("{} files", many.len()), }; - let question = if *needs_second_confirm && *confirmed_once { + if needs_second_confirm && confirmed_once { format!("{subject} contains a directory — delete its entire contents?") } else { format!("Delete {subject}? This cannot be undone.") - }; + } +} +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle, its content rows in order — the question, the +/// hidden-selection disclosure when there is one, the `(Y)es (N)o` row — +/// already truncated to the content width, and the two button spans on the +/// cells those labels occupy. The box is 4 rows tall, or 5 when the +/// disclosure takes its own row between the question and the buttons +/// (filter-selection-safety design D3) — both the preferred and the minimum +/// height move together, staying within responsive-layout "Unified overlay +/// geometry"'s declared-minimum cap. Empty for a setup that is not a delete +/// confirmation. +pub fn layout(area: Rect, setup: &FileOpSetup) -> DialogLayout { + let FileOpSetup::DeleteConfirm { sources, needs_second_confirm, confirmed_once, hidden_by_filter, .. } = setup else { + return DialogLayout::default(); + }; + let question = question_for(sources, *needs_second_confirm, *confirmed_once); + let disclosure = hidden_by_filter_disclosure(*hidden_by_filter, sources.len()); let preferred_inner_w = display_width(&question).max(20) + 2; - let r = overlay_rect((preferred_inner_w as u16 + 2, 4), (MIN_INNER_W as u16 + 2, 4), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - let content_w = inner_w.saturating_sub(2); + let rows: u16 = if disclosure.is_some() { 5 } else { 4 }; + let r = overlay_rect((preferred_inner_w as u16 + 2, rows), (MIN_INNER_W as u16 + 2, rows), (area.width, area.height)); + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let content_w = rect.width.saturating_sub(4) as usize; - let top = format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner_w)); - let bottom = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner_w)); - let mid = format!("\u{2502} {} \u{2502}", pad(&truncate_with_ellipsis(&question, content_w), content_w)); - let buttons = format!("\u{2502} {} \u{2502}", pad(&truncate_with_ellipsis("(Y)es (N)o", content_w), content_w)); + let mut lines = vec![truncate_with_ellipsis(&question, content_w)]; + if let Some(text) = disclosure { + lines.push(truncate_with_ellipsis(&text, content_w)); + } + let buttons_text = truncate_with_ellipsis(BUTTONS, content_w); + let buttons_row = rect.y + lines.len() as u16 + 1; + let buttons = label_spans(&buttons_text, rect.x + 2, buttons_row, &[("(Y)es", ButtonId::DeleteConfirmYes), ("(N)o", ButtonId::DeleteConfirmNo)]); + lines.push(buttons_text); + DialogLayout { rect, buttons, rows: Vec::new(), lines } +} - buf.set_string(x, y, &top, body); - buf.set_string(x, y + 1, &mid, body); - buf.set_string(x, y + 2, &buttons, body); - buf.set_string(x, y + box_h - 1, &bottom, body); +pub fn render_delete_confirm(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, setup: &FileOpSetup) { + draw_delete_confirm(buf, theme, depth, &layout(area, setup)); +} + +/// Paint the dialog from an already computed [`layout`]: one framed row per +/// content line, in order. A no-op for an empty layout (not a delete +/// confirmation). +pub fn draw_delete_confirm(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, l: &DialogLayout) { + if l.lines.is_empty() { + return; + } + let body = role_style(theme, Role::DialogPrimary, depth); + let inner_w = l.rect.width.saturating_sub(2) as usize; + let content_w = inner_w.saturating_sub(2); + let (x, y) = (l.rect.x, l.rect.y); + + buf.set_string(x, y, format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner_w)), body); + for (i, line) in l.lines.iter().enumerate() { + buf.set_string(x, y + 1 + i as u16, format!("\u{2502} {} \u{2502}", pad(line, content_w)), body); + } + buf.set_string(x, y + l.rect.height - 1, format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner_w)), body); } fn pad(s: &str, width: usize) -> String { filecommand_core::listing::pad_to_width(s, width) } -/// The `(Y)es`/`(N)o` button spans `render_delete_confirm` currently draws -/// at `area` (mouse-input "Dialog button"). Mirrors the renderer's own -/// `question`/`overlay_rect` geometry so the rects can never drift from -/// what's actually on screen. -pub fn hit_buttons(area: Rect, setup: &FileOpSetup) -> Vec<(Rect, ButtonId)> { - let FileOpSetup::DeleteConfirm { sources, needs_second_confirm, confirmed_once, .. } = setup else { return vec![] }; - let subject = match sources.as_slice() { - [one] => one.original_name.to_string_lossy().into_owned(), - many => format!("{} files", many.len()), - }; - let question = if *needs_second_confirm && *confirmed_once { - format!("{subject} contains a directory — delete its entire contents?") - } else { - format!("Delete {subject}? This cannot be undone.") - }; - let preferred_inner_w = display_width(&question).max(20) + 2; - let r = overlay_rect((preferred_inner_w as u16 + 2, 4), (MIN_INNER_W as u16 + 2, 4), (area.width, area.height)); - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - let content_w = inner_w.saturating_sub(2); - let buttons_text = truncate_with_ellipsis("(Y)es (N)o", content_w); - let base_x = x + 2; - let row_y = y + 2; - - let mut out = Vec::new(); - for (label, id) in [("(Y)es", ButtonId::DeleteConfirmYes), ("(N)o", ButtonId::DeleteConfirmNo)] { - if let Some(byte_idx) = buttons_text.find(label) { - let col = buttons_text[..byte_idx].chars().count() as u16; - out.push((Rect { x: base_x + col, y: row_y, width: display_width(label) as u16, height: 1 }, id)); +#[cfg(test)] +mod tests { + use std::ffi::OsString; + use std::path::PathBuf; + + use super::*; + + fn source_items(n: usize) -> Vec { + (0..n) + .map(|i| SourceItem { + original_name: OsString::from(format!("f{i}.txt")), + path: PathBuf::from(format!(r"C:\left\f{i}.txt")), + is_dir: false, + }) + .collect() + } + + fn delete_setup(sources: Vec, hidden_by_filter: usize) -> FileOpSetup { + FileOpSetup::DeleteConfirm { + sources, + source_dir: PathBuf::from(r"C:\left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter, + } + } + + fn area() -> Rect { + Rect { x: 0, y: 0, width: 80, height: 24 } + } + + fn buffer_to_text(buf: &Buffer, area: Rect) -> String { + let mut out = String::new(); + for y in 0..area.height { + for x in 0..area.width { + out.push_str(buf[(area.x + x, area.y + y)].symbol()); + } + out.push('\n'); + } + out + } + + // ----------------------------------------------------------------- + // filter-selection-safety: the hidden-selection disclosure row + // (tasks.md 2.4/4.6; design D3) + // ----------------------------------------------------------------- + + #[test] + fn no_hidden_selection_leaves_the_dialog_unchanged() { + let theme = Theme::classic(); + let setup = delete_setup(source_items(5), 0); + let mut buf = Buffer::empty(area()); + render_delete_confirm(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(text.contains("Delete 5 files? This cannot be undone."), "the question is untouched:\n{text}"); + assert!(!text.contains("hidden by filter"), "no disclosure without hidden selections:\n{text}"); + } + + #[test] + fn the_disclosure_takes_its_own_row_between_the_question_and_the_buttons() { + let theme = Theme::classic(); + let setup = delete_setup(source_items(5), 2); + let mut buf = Buffer::empty(area()); + render_delete_confirm(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(text.contains("Delete 5 files? This cannot be undone."), "the question is untouched:\n{text}"); + assert!(text.contains("2 of 5 selected hidden by filter"), "the disclosure row is shown:\n{text}"); + } + + #[test] + fn a_single_hidden_source_gets_the_singular_wording() { + let theme = Theme::classic(); + let setup = delete_setup(source_items(1), 1); + let mut buf = Buffer::empty(area()); + render_delete_confirm(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(text.contains("Delete f0.txt? This cannot be undone."), "the single item is still named:\n{text}"); + assert!(text.contains("Selected item is hidden by filter"), "singular wording:\n{text}"); + } + + #[test] + fn hit_buttons_sit_on_the_shifted_row_when_the_disclosure_is_shown() { + // The rects must describe the row the buttons are actually painted + // on, whether or not the disclosure row is present — and with the + // disclosure, that row sits directly beneath it (design D3). + let theme = Theme::classic(); + let painted_row = |needle: &str, hidden: usize| -> u16 { + let setup = delete_setup(source_items(5), hidden); + let mut buf = Buffer::empty(area()); + render_delete_confirm(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + text.lines().position(|line| line.contains(needle)).unwrap_or_else(|| panic!("{needle:?} is painted")) as u16 + }; + let plain_buttons = painted_row("(Y)es", 0); + let disclosed_buttons = painted_row("(Y)es", 2); + let disclosure = painted_row("2 of 5 selected hidden by filter", 2); + assert_eq!(disclosure, disclosed_buttons - 1, "the disclosure row sits between the question and the buttons"); + assert_eq!(painted_row("Delete 5 files?", 2), disclosure - 1); + + for (setup, row) in [(delete_setup(source_items(5), 0), plain_buttons), (delete_setup(source_items(5), 2), disclosed_buttons)] { + let hits = layout(area(), &setup).buttons; + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|(r, _)| r.y == row), "every button rect sits on the painted button row {row}: {hits:?}"); } } - out } diff --git a/crates/filecommand-tui/src/views/destination_input.rs b/crates/filecommand-tui/src/views/destination_input.rs index a73d571..6c3ffd0 100644 --- a/crates/filecommand-tui/src/views/destination_input.rs +++ b/crates/filecommand-tui/src/views/destination_input.rs @@ -4,7 +4,7 @@ //! "Drop-initiated destination dialog"; mouse-panel-drag design D3). use filecommand_core::dialogs::overlay_rect; -use filecommand_core::fs_ops::dialog::{DropButtons, FileOpSetup}; +use filecommand_core::fs_ops::dialog::{hidden_by_filter_disclosure, DropButtons, FileOpSetup}; use filecommand_core::fs_ops::JobKind; use filecommand_core::listing::{display_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; @@ -12,6 +12,7 @@ use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::DialogLayout; use crate::style::role_style; const FIELD_WIDTH: usize = 40; @@ -21,8 +22,11 @@ const BOX_INNER_W: usize = FIELD_WIDTH + 4; /// each represents — `None` for `[ Cancel ]`, which is never the focused /// button (only `[ Copy ]`/`[ Move ]` alternate focus, per the verb the drag /// proposed — mouse-drag design D1/D2). -const DROP_BUTTONS: [(&str, Option, ButtonId); 3] = - [("[ Copy ]", Some(JobKind::Copy), ButtonId::DropDialogCopy), ("[ Move ]", Some(JobKind::Move), ButtonId::DropDialogMove), ("[ Cancel ]", None, ButtonId::DropDialogCancel)]; +const DROP_BUTTONS: [(&str, Option, ButtonId); 3] = [ + ("[ Copy ]", Some(JobKind::Copy), ButtonId::DropDialogCopy), + ("[ Move ]", Some(JobKind::Move), ButtonId::DropDialogMove), + ("[ Cancel ]", None, ButtonId::DropDialogCancel), +]; /// The gap, in display columns, between adjacent button labels. const BUTTON_GAP: usize = 2; @@ -58,8 +62,7 @@ fn drop_dialog_title(focused: JobKind, count: usize) -> String { } /// The button row's total display width, including the gaps between labels -/// — shared by the renderer and [`hit_buttons`] so their centering can never -/// disagree. +/// — the centering [`layout`] places the button spans with. fn buttons_total_width() -> usize { DROP_BUTTONS.iter().map(|(label, ..)| display_width(label)).sum::() + BUTTON_GAP * (DROP_BUTTONS.len() - 1) } @@ -85,126 +88,163 @@ fn prompt_for(kind: JobKind) -> &'static str { } } -pub fn render_destination_input(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, setup: &FileOpSetup) { +/// The title and prompt the box shows for `setup`, and the drop-dialog +/// button row when it has one. `None` for a delete confirmation, which this +/// module does not draw. +fn box_texts(setup: &FileOpSetup) -> Option<(String, &'static str, Option, Option)> { match setup { - FileOpSetup::DestinationInput { kind, sources, input, buttons, .. } => { + FileOpSetup::DestinationInput { kind, sources, buttons, hidden_by_filter, .. } => { let title = match buttons { Some(db) => drop_dialog_title(db.focused, sources.len()), None => title_for(*kind).to_string(), }; - render_input_box(buf, area, theme, depth, &title, prompt_for(*kind), input, *buttons); + Some((title, prompt_for(*kind), *buttons, hidden_by_filter_disclosure(*hidden_by_filter, sources.len()))) } // The file-action menu's in-place Rename: same bracket-and-dots // input box, pre-filled with the target's current name (design D2; // file-action-menu "In-place Rename": "an input dialog pre-filled // with the target entry's current name"). Never a drop-initiated // dialog, so it always gets the plain 6-row box. - FileOpSetup::RenameInput { input, .. } => render_input_box(buf, area, theme, depth, "Rename", "New name:", input, None), - FileOpSetup::DeleteConfirm { .. } => {} + FileOpSetup::RenameInput { .. } => Some(("Rename".to_string(), "New name:", None, None)), + FileOpSetup::DeleteConfirm { .. } => None, } } -/// `buttons: None` renders exactly the pre-`mouse-panel-drag` 6-row box — -/// every row below is drawn at the same offset it always was, and the -/// button row is only ever drawn when `buttons: Some` — so the F5/F6/F7/ -/// Rename dialogs stay byte-for-byte unchanged (operation-dialogs "F5 -/// dialog unchanged"). -#[allow(clippy::too_many_arguments)] -fn render_input_box(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, title: &str, prompt: &str, input: &str, buttons: Option) { +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle; its text — the framed title, the prompt, and the +/// hidden-selection disclosure (an empty string when there is none), each +/// clamped to the row; and, for a drop-initiated dialog only, the +/// `[ Copy ] [ Move ] [ Cancel ]` button spans, centered in the box exactly +/// where the renderer paints them (operation-dialogs "Drop-initiated +/// destination dialog"). The plain keyboard dialog has no button row at all +/// (operation-dialogs "F5 dialog unchanged"), so its `buttons` are empty. +/// Empty for a delete confirmation. +pub fn layout(area: Rect, setup: &FileOpSetup) -> DialogLayout { + let Some((title, prompt, buttons, disclosure)) = box_texts(setup) else { return DialogLayout::default() }; + let preferred = (BOX_INNER_W as u16 + 2, box_height(buttons)); + let r = overlay_rect(preferred, preferred, (area.width, area.height)); + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + + let mut button_spans = Vec::new(); + if buttons.is_some() { + let mut bx = rect.x + 1 + (inner_w.saturating_sub(buttons_total_width()) / 2) as u16; + for (label, _, id) in DROP_BUTTONS { + button_spans.push((Rect { x: bx, y: rect.y + 5, width: display_width(label) as u16, height: 1 }, id)); + bx += display_width(label) as u16 + BUTTON_GAP as u16; + } + } + let lines = vec![ + format!(" {title} "), + truncate_with_ellipsis(prompt, inner_w.saturating_sub(2)), + disclosure.map(|text| truncate_with_ellipsis(&text, inner_w.saturating_sub(2))).unwrap_or_default(), + ]; + DialogLayout { rect, buttons: button_spans, rows: Vec::new(), lines } +} + +pub fn render_destination_input(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, setup: &FileOpSetup) -> Option<(u16, u16)> { + draw_destination_input(buf, theme, depth, setup, &layout(area, setup)) +} + +/// Paint the dialog from an already computed [`layout`]. `buttons: None` +/// renders exactly the pre-`mouse-panel-drag` 6-row box — every row below is +/// drawn at the same offset it always was, and the button row is only ever +/// drawn when `buttons: Some` — so the F5/F6/F7/Rename dialogs stay +/// byte-for-byte unchanged (operation-dialogs "F5 dialog unchanged"). +/// Returns the terminal cursor's position: the field's caret +/// (text-field-editing "Field rendering keeps the caret visible"). The +/// hidden-selection disclosure (`Some` only when the source panel's quick +/// filter hides part of the selection) is drawn on the blank row below the +/// field — the box size, field, caret, title, and button row are all +/// unchanged (filter-selection-safety design D3; selection "Operations +/// disclose selected entries hidden by the quick filter"). A no-op for an +/// empty layout (a delete confirmation). +pub fn draw_destination_input(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, setup: &FileOpSetup, l: &DialogLayout) -> Option<(u16, u16)> { + let (input, buttons) = match setup { + FileOpSetup::DestinationInput { input, buttons, .. } => (input, *buttons), + FileOpSetup::RenameInput { input, .. } => (input, None), + FileOpSetup::DeleteConfirm { .. } => return None, + }; + let [title, prompt, disclosure] = &l.lines[..] else { return None }; let body = role_style(theme, Role::DialogPrimary, depth); let field_style = role_style(theme, Role::DialogInput, depth); - let preferred = (BOX_INNER_W as u16 + 2, box_height(buttons)); - let r = overlay_rect(preferred, preferred, (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; + let inner_w = l.rect.width.saturating_sub(2) as usize; let field_w = FIELD_WIDTH.min(inner_w.saturating_sub(2)); - let x = area.x + r.x; - let y = area.y + r.y; + let (x, y) = (l.rect.x, l.rect.y); - let title = format!(" {title} "); let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); let bottom = format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)); let blank = format!("\u{2551}{}\u{2551}", " ".repeat(inner_w)); buf.set_string(x, y, &top, body); - let title_x = x + 1 + ((inner_w.saturating_sub(display_width(&title))) / 2) as u16; - buf.set_string(title_x, y, &title, body); + let title_x = x + 1 + ((inner_w.saturating_sub(display_width(title))) / 2) as u16; + buf.set_string(title_x, y, title, body); buf.set_string(x, y + 1, &blank, body); buf.set_string(x, y + 2, "\u{2551}", body); - buf.set_string(x + 2, y + 2, truncate_with_ellipsis(prompt, inner_w.saturating_sub(2)), body); + buf.set_string(x + 2, y + 2, prompt, body); buf.set_string(x + 1 + inner_w as u16, y + 2, "\u{2551}", body); - // Bracket-and-dots input field: `[text...........]`. - let mut field_text = input.to_string(); - if display_width(&field_text) > field_w { - let excess = display_width(&field_text) - field_w; - field_text = field_text.chars().skip(excess).collect(); - } - let pad = field_w - display_width(&field_text); - let field = format!("[{field_text}{}]", ".".repeat(pad)); + // Bracket-and-dots input field: `[text...........]`, scrolled so the + // caret is always visible (text-field-editing "Field rendering keeps + // the caret visible"; design D5). buf.set_string(x, y + 3, "\u{2551}", body); - buf.set_string(x + 2, y + 3, &field, field_style); + buf.set_string(x + 2, y + 3, "[", field_style); + let caret_x = crate::views::draw_text_field(buf, x + 3, y + 3, field_w, input, field_style); + buf.set_string(x + 3 + field_w as u16, y + 3, "]", field_style); buf.set_string(x + 1 + inner_w as u16, y + 3, "\u{2551}", body); buf.set_string(x, y + 4, &blank, body); + // The hidden-selection disclosure reuses this otherwise-blank row + // (filter-selection-safety design D3), drawn with the same left margin + // as the prompt and truncated to the row's inner width. + if !disclosure.is_empty() { + buf.set_string(x + 2, y + 4, disclosure, body); + } if let Some(db) = buttons { - render_button_row(buf, x, y + 5, inner_w, theme, depth, db.focused, body); + draw_button_row(buf, x, y + 5, inner_w, theme, depth, db.focused, body, &l.buttons); } - buf.set_string(x, y + box_h - 1, &bottom, body); + buf.set_string(x, y + l.rect.height - 1, &bottom, body); + Some((caret_x, y + 3)) } -/// The drop dialog's `[ Copy ] [ Move ] [ Cancel ]` row, centered in -/// `inner_w` — the button representing `focused` (Copy or Move, whichever -/// the drag proposed) renders in `Role::ButtonFocused`; the other verb and -/// `Cancel` always render in `Role::ButtonNormal` (operation-dialogs -/// "Drop-initiated destination dialog"; mouse-drag design D1/D2). +/// The drop dialog's `[ Copy ] [ Move ] [ Cancel ]` row, each label on the +/// cells its layout span names — the button representing `focused` (Copy or +/// Move, whichever the drag proposed) renders in `Role::ButtonFocused`; the +/// other verb and `Cancel` always render in `Role::ButtonNormal` +/// (operation-dialogs "Drop-initiated destination dialog"; mouse-drag design +/// D1/D2). #[allow(clippy::too_many_arguments)] -fn render_button_row(buf: &mut Buffer, x: u16, y: u16, inner_w: usize, theme: &Theme, depth: ColorDepth, focused: JobKind, body: ratatui::style::Style) { +fn draw_button_row( + buf: &mut Buffer, + x: u16, + y: u16, + inner_w: usize, + theme: &Theme, + depth: ColorDepth, + focused: JobKind, + body: ratatui::style::Style, + spans: &[(Rect, ButtonId)], +) { let normal = role_style(theme, Role::ButtonNormal, depth); let focused_style = role_style(theme, Role::ButtonFocused, depth); buf.set_string(x, y, "\u{2551}", body); buf.set_string(x + 1, y, " ".repeat(inner_w), body); - let mut bx = x + 1 + (inner_w.saturating_sub(buttons_total_width()) / 2) as u16; - for (label, kind, _) in DROP_BUTTONS { + for ((rect, _), (label, kind, _)) in spans.iter().zip(DROP_BUTTONS) { let style = if kind == Some(focused) { focused_style } else { normal }; - buf.set_string(bx, y, label, style); - bx += display_width(label) as u16 + BUTTON_GAP as u16; + buf.set_string(rect.x, rect.y, label, style); } buf.set_string(x + 1 + inner_w as u16, y, "\u{2551}", body); } -/// The `[ Copy ] [ Move ] [ Cancel ]` button rects `render_input_box` -/// currently draws — empty unless `setup` is a drop-initiated -/// `DestinationInput` (`buttons: Some`), since the plain keyboard dialog has -/// no button row at all (operation-dialogs "F5 dialog unchanged"). Mirrors -/// `render_button_row`'s own `box_height`/`overlay_rect`/centering geometry -/// exactly, so a hit rect can never describe a button that isn't actually -/// where the renderer painted it. -pub fn hit_buttons(area: Rect, setup: &FileOpSetup) -> Vec<(Rect, ButtonId)> { - let FileOpSetup::DestinationInput { buttons: Some(db), .. } = setup else { return vec![] }; - let preferred = (BOX_INNER_W as u16 + 2, box_height(Some(*db))); - let r = overlay_rect(preferred, preferred, (area.width, area.height)); - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y + 5; - - let mut bx = x + 1 + (inner_w.saturating_sub(buttons_total_width()) / 2) as u16; - let mut out = Vec::new(); - for (label, _, id) in DROP_BUTTONS { - out.push((Rect { x: bx, y, width: display_width(label) as u16, height: 1 }, id)); - bx += display_width(label) as u16 + BUTTON_GAP as u16; - } - out -} - #[cfg(test)] mod tests { use std::ffi::OsString; use std::path::PathBuf; use filecommand_core::fs_ops::SourceItem; + use filecommand_core::line_edit::LineEdit; use super::*; @@ -220,7 +260,13 @@ mod tests { } fn source_items(n: usize) -> Vec { - (0..n).map(|i| SourceItem { original_name: OsString::from(format!("f{i}.txt")), path: PathBuf::from(format!(r"C:\left\f{i}.txt")), is_dir: false }).collect() + (0..n) + .map(|i| SourceItem { + original_name: OsString::from(format!("f{i}.txt")), + path: PathBuf::from(format!(r"C:\left\f{i}.txt")), + is_dir: false, + }) + .collect() } fn area() -> Rect { @@ -242,15 +288,16 @@ mod tests { kind: JobKind::Copy, sources: source_items(1), source_dir: PathBuf::from(r"C:\left"), - input: r"D:\BACKUP".to_string(), + input: LineEdit::from(r"D:\BACKUP"), buttons: None, + hidden_by_filter: 0, }; let mut buf = Buffer::empty(area()); render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); let text = buffer_to_text(&buf, area()); assert!(text.contains(" Copy "), "title must stay the plain title_for(kind) form:\n{text}"); assert!(!text.contains("[ Copy ]"), "no button row must be drawn when buttons is None:\n{text}"); - assert!(hit_buttons(area(), &setup).is_empty(), "no clickable buttons exist for the keyboard dialog"); + assert!(layout(area(), &setup).buttons.is_empty(), "no clickable buttons exist for the keyboard dialog"); } #[test] @@ -262,8 +309,9 @@ mod tests { kind: JobKind::Copy, sources: source_items(3), source_dir: PathBuf::from(r"C:\left"), - input: r"D:\BACKUP\OLD".to_string(), + input: LineEdit::from(r"D:\BACKUP\OLD"), buttons: Some(DropButtons { focused: JobKind::Copy }), + hidden_by_filter: 0, }; let mut buf = Buffer::empty(area()); render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); @@ -271,7 +319,7 @@ mod tests { assert!(text.contains("Copy 3 files"), "title must name the focused verb and item count:\n{text}"); assert!(text.contains("[ Copy ]") && text.contains("[ Move ]") && text.contains("[ Cancel ]"), "the button row must be drawn:\n{text}"); - let hits = hit_buttons(area(), &setup); + let hits = layout(area(), &setup).buttons; assert_eq!(hits.len(), 3, "all three buttons are clickable"); assert!(hits.iter().any(|(_, id)| *id == ButtonId::DropDialogCopy)); assert!(hits.iter().any(|(_, id)| *id == ButtonId::DropDialogMove)); @@ -294,15 +342,16 @@ mod tests { kind: JobKind::Move, sources: source_items(1), source_dir: PathBuf::from(r"C:\left"), - input: r"D:\BACKUP".to_string(), + input: LineEdit::from(r"D:\BACKUP"), buttons: Some(DropButtons { focused: JobKind::Move }), + hidden_by_filter: 0, }; let mut buf = Buffer::empty(area()); render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); let text = buffer_to_text(&buf, area()); assert!(text.contains("Move 1 file"), "singular count, no trailing s:\n{text}"); - let hits = hit_buttons(area(), &setup); + let hits = layout(area(), &setup).buttons; let move_rect = hits.iter().find(|(_, id)| *id == ButtonId::DropDialogMove).unwrap().0; let copy_rect = hits.iter().find(|(_, id)| *id == ButtonId::DropDialogCopy).unwrap().0; let focused = role_style(&theme, Role::ButtonFocused, ColorDepth::Ansi16); @@ -314,11 +363,81 @@ mod tests { #[test] fn rename_input_never_gets_a_button_row() { let theme = Theme::classic(); - let setup = FileOpSetup::RenameInput { source_dir: PathBuf::from(r"C:\left"), original_name: OsString::from("a.txt"), is_dir: false, input: "a.txt".to_string() }; + let setup = FileOpSetup::RenameInput { + source_dir: PathBuf::from(r"C:\left"), + original_name: OsString::from("a.txt"), + is_dir: false, + input: LineEdit::from("a.txt"), + }; let mut buf = Buffer::empty(area()); render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); let text = buffer_to_text(&buf, area()); assert!(!text.contains("[ Copy ]")); - assert!(hit_buttons(area(), &setup).is_empty()); + assert!(layout(area(), &setup).buttons.is_empty()); + } + + // ----------------------------------------------------------------- + // filter-selection-safety: the hidden-selection disclosure on the + // blank row below the field (tasks.md 2.5/4.6; design D3) + // ----------------------------------------------------------------- + + #[test] + fn the_hidden_selection_disclosure_reuses_the_blank_row_below_the_field() { + // selection "Destination dialog states the hidden count"; design D3: + // the box geometry, field, caret, and title are all unchanged — the + // text lands on the previously blank row directly below the field. + let theme = Theme::classic(); + let setup = FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: source_items(5), + source_dir: PathBuf::from(r"C:\left"), + input: LineEdit::from(r"D:\BACKUP"), + buttons: None, + hidden_by_filter: 2, + }; + let mut buf = Buffer::empty(area()); + render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(text.contains("2 of 5 selected hidden by filter"), "the disclosure is shown:\n{text}"); + assert!(text.contains(" Copy "), "the title is unchanged:\n{text}"); + assert!(!text.contains("[ Copy ]"), "no button row appears — the box is unchanged:\n{text}"); + assert!(layout(area(), &setup).buttons.is_empty(), "still no clickable buttons"); + let prompt_row = text.lines().position(|l| l.contains("Copy/move to:")).expect("the prompt row"); + let disclosure_row = text.lines().position(|l| l.contains("hidden by filter")).expect("the disclosure row"); + assert_eq!(disclosure_row, prompt_row + 2, "the disclosure lands on the blank row directly below the field"); + } + + #[test] + fn a_single_hidden_source_gets_the_singular_wording() { + let theme = Theme::classic(); + let setup = FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: source_items(1), + source_dir: PathBuf::from(r"C:\left"), + input: LineEdit::from(r"D:\BACKUP"), + buttons: None, + hidden_by_filter: 1, + }; + let mut buf = Buffer::empty(area()); + render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(text.contains("Selected item is hidden by filter"), "singular wording:\n{text}"); + } + + #[test] + fn no_hidden_selection_leaves_the_dialog_unchanged() { + let theme = Theme::classic(); + let setup = FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: source_items(5), + source_dir: PathBuf::from(r"C:\left"), + input: LineEdit::from(r"D:\BACKUP"), + buttons: None, + hidden_by_filter: 0, + }; + let mut buf = Buffer::empty(area()); + render_destination_input(&mut buf, area(), &theme, ColorDepth::Ansi16, &setup); + let text = buffer_to_text(&buf, area()); + assert!(!text.contains("hidden by filter"), "no disclosure without hidden selections:\n{text}"); } } diff --git a/crates/filecommand-tui/src/views/drive_select.rs b/crates/filecommand-tui/src/views/drive_select.rs index a034f9d..b220afb 100644 --- a/crates/filecommand-tui/src/views/drive_select.rs +++ b/crates/filecommand-tui/src/views/drive_select.rs @@ -132,10 +132,7 @@ mod tests { let text = render(&dialog).join("\n"); assert!(text.contains("C:")); let theme = Theme::classic(); - assert_ne!( - role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16), - role_style(&theme, Role::MenuBody, ColorDepth::Ansi16) - ); + assert_ne!(role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16), role_style(&theme, Role::MenuBody, ColorDepth::Ansi16)); } #[test] diff --git a/crates/filecommand-tui/src/views/editor.rs b/crates/filecommand-tui/src/views/editor.rs index e1776b1..a0ac732 100644 --- a/crates/filecommand-tui/src/views/editor.rs +++ b/crates/filecommand-tui/src/views/editor.rs @@ -63,6 +63,32 @@ pub fn render_editor(buf: &mut Buffer, area: Rect, editor: &EditorState, theme: buf.set_string(area.x, y, pad_to_width(&clipped, w), style); } + // The last body row, directly above the F-key bar, carries a one-row + // notice while there is something to say about the save: `Saving…` + // while a save is in flight on the worker thread (application-shell + // "Saving to a slow share shows progress and accepts no edits"; + // offload-blocking-io design D6), otherwise the most recent failure, + // until the next edit or successful save (builtin-editor "A failed save + // leaves the original intact"; editor-encoding-safety design D6). The + // two share the row: a retry in flight covers the earlier failure + // message, and its reply then clears or replaces it. The header and the + // F-key bar are untouched, the same way the save-on-exit confirm + // overlays the body. + let notice = if editor.save_pending { + Some(("Saving…".to_string(), role_style(theme, Role::ViewerHeader, depth))) + } else { + editor.save_error.as_ref().map(|message| (format!("Save failed: {message}"), role_style(theme, Role::DialogError, depth))) + }; + let save_error_row = match (notice, body_h) { + (Some((text, style)), h) if h > 0 => { + let y = body_y + h - 1; + let text = clip_from(&text, 0, w); + buf.set_string(area.x, y, pad_to_width(&text, w), style); + Some(y) + } + _ => None, + }; + let keybar_area = Rect { x: area.x, y: area.y + area.height - 1, width: area.width, height: 1 }; render_editor_keybar(buf, keybar_area, theme, depth); @@ -75,6 +101,10 @@ pub fn render_editor(buf: &mut Buffer, area: Rect, editor: &EditorState, theme: if cursor_row as u16 >= body_h { return None; } + if save_error_row == Some(body_y + cursor_row as u16) { + // The caret's row is hidden behind the save-failure overlay. + return None; + } let cursor_col = editor.caret.col.checked_sub(editor.left_col)?; if cursor_col >= w { return None; @@ -147,13 +177,16 @@ mod tests { use std::path::PathBuf; fn editor_from(path: &str, text: &str) -> EditorState { - EditorState::from_bytes(PathBuf::from(path), text.as_bytes()) + EditorState::from_bytes(PathBuf::from(path), text.as_bytes()).expect("test fixture is valid UTF-8") } fn render(editor: &EditorState, area: Rect) -> (String, Option<(u16, u16)>) { let mut buf = Buffer::empty(area); let cursor = render_editor(&mut buf, area, editor, &Theme::classic(), ColorDepth::Ansi16); - let text = (0..area.height).map(|y| (0..area.width).map(|x| buf[(area.x + x, area.y + y)].symbol()).collect::()).collect::>().join("\n"); + let text = (0..area.height) + .map(|y| (0..area.width).map(|x| buf[(area.x + x, area.y + y)].symbol()).collect::()) + .collect::>() + .join("\n"); (text, cursor) } @@ -266,6 +299,71 @@ mod tests { assert!(text.contains("Save changes to notes.txt?"), "{text}"); } + #[test] + fn a_save_failure_overlays_the_last_body_row_and_leaves_the_chrome_alone() { + let mut e = editor_from("C:\\notes.txt", "abc\n"); + e.type_char('!'); + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let (clean, _) = render(&e, area); + e.save_error = Some("Cannot save notes.txt: notes.txt is read-only — not saved".to_string()); + let (text, cursor) = render(&e, area); + let clean_rows: Vec<&str> = clean.lines().collect(); + let rows: Vec<&str> = text.lines().collect(); + assert_eq!(rows[0], clean_rows[0], "the header is unchanged"); + assert_eq!(rows[23], clean_rows[23], "the F-key bar is unchanged"); + assert!(rows[22].starts_with("Save failed: Cannot save notes.txt: notes.txt is read-only — not saved"), "`{}`", rows[22]); + assert_eq!(rows[1], clean_rows[1], "the text body above the overlay is unchanged"); + assert_eq!(cursor, Some((1, 1)), "the caret is still shown when it is not on the overlay row"); + + let mut buf = Buffer::empty(area); + render_editor(&mut buf, area, &e, &Theme::classic(), ColorDepth::Ansi16); + let error_style = role_style(&Theme::classic(), Role::DialogError, ColorDepth::Ansi16); + assert_eq!(Some(buf[(0, 22)].bg), error_style.bg, "the overlay uses the dialog.error role"); + } + + /// application-shell "Saving to a slow share shows progress and accepts + /// no edits" (offload-blocking-io design D6): while a save is in flight + /// the last body row reads `Saving…`, covering an earlier failure + /// message, and the header and F-key bar are exactly as without it. + #[test] + fn a_pending_save_shows_saving_on_the_last_body_row_and_leaves_the_chrome_alone() { + let mut e = editor_from("C:\\notes.txt", "abc\n"); + e.type_char('!'); + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let (clean, _) = render(&e, area); + e.save_error = Some("Cannot save notes.txt: disk full".to_string()); + e.save_pending = true; + let (text, cursor) = render(&e, area); + let clean_rows: Vec<&str> = clean.lines().collect(); + let rows: Vec<&str> = text.lines().collect(); + assert_eq!(rows[0], clean_rows[0], "the header is unchanged"); + assert_eq!(rows[23], clean_rows[23], "the F-key bar is unchanged"); + assert!(rows[22].starts_with("Saving…"), "`{}`", rows[22]); + assert!(!text.contains("Save failed"), "the in-flight retry covers the earlier failure message"); + assert_eq!(rows[1], clean_rows[1], "the text body above the notice is unchanged"); + assert_eq!(cursor, Some((1, 1)), "the caret is still shown when it is not on the notice row"); + + // The reply lifts the notice: a failure shows its message again, a + // success shows nothing. + e.save_pending = false; + let (text, _) = render(&e, area); + assert!(text.lines().nth(22).unwrap().starts_with("Save failed: Cannot save notes.txt: disk full")); + e.save_error = None; + let (text, _) = render(&e, area); + assert_eq!(text, clean); + } + + #[test] + fn a_long_save_failure_message_is_truncated_to_the_width() { + let mut e = editor_from("f.txt", "abc\n"); + e.save_error = Some("x".repeat(200)); + let area = Rect { x: 0, y: 0, width: 40, height: 6 }; + let (text, _) = render(&e, area); + let overlay = text.lines().nth(4).unwrap(); + assert_eq!(display_width(overlay), 40); + assert!(overlay.starts_with("Save failed: xxx"), "`{overlay}`"); + } + #[test] fn selection_and_body_reuse_the_viewer_roles() { // A cheap regression guard for the doc claim that the editor reuses diff --git a/crates/filecommand-tui/src/views/error_dialog.rs b/crates/filecommand-tui/src/views/error_dialog.rs index a078e37..da70b52 100644 --- a/crates/filecommand-tui/src/views/error_dialog.rs +++ b/crates/filecommand-tui/src/views/error_dialog.rs @@ -9,20 +9,53 @@ use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::{label_spans, DialogLayout}; use crate::style::role_style; const MIN_INNER_W: usize = 34; +const CHOICES_ROW: &str = "(R)etry (S)kip Skip (A)ll A(b)ort"; -pub fn render_error(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, info: &ErrorInfo) { - let body = role_style(theme, Role::DialogError, depth); - +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle; its rows — the path, the message, a blank, and the +/// `(R)etry (S)kip Skip (A)ll A(b)ort` row — truncated to the inner +/// width; and that row's hotkey spans recorded as buttons (design D2: no +/// framed buttons, so each hotkey span is a button). +pub fn layout(area: Rect, info: &ErrorInfo) -> DialogLayout { let path_line = info.path.display().to_string(); let preferred_inner_w = display_width(&info.message).max(display_width(&path_line)).max(30) + 2; let r = overlay_rect((preferred_inner_w as u16 + 2, 6), (MIN_INNER_W as u16, 6), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + let choices = truncate_with_ellipsis(CHOICES_ROW, inner_w); + let buttons = label_spans( + &choices, + rect.x + 1, + rect.y + 4, + &[ + ("(R)etry", ButtonId::ErrorRetry), + ("(S)kip", ButtonId::ErrorSkip), + ("Skip (A)ll", ButtonId::ErrorSkipAll), + ("A(b)ort", ButtonId::ErrorAbort), + ], + ); + let lines = vec![ + pad_to_width(&truncate_with_ellipsis(&path_line, inner_w), inner_w), + pad_to_width(&truncate_with_ellipsis(&info.message, inner_w), inner_w), + pad_to_width("", inner_w), + pad_to_width(&choices, inner_w), + ]; + DialogLayout { rect, buttons, rows: Vec::new(), lines } +} + +pub fn render_error(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, info: &ErrorInfo) { + draw_error(buf, theme, depth, &layout(area, info)); +} + +/// Paint the dialog from an already computed [`layout`]. +pub fn draw_error(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, l: &DialogLayout) { + let body = role_style(theme, Role::DialogError, depth); + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); let title = " Error "; let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); @@ -31,44 +64,11 @@ pub fn render_error(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDep let title_x = x + 1 + ((inner_w.saturating_sub(display_width(title))) / 2) as u16; buf.set_string(title_x, y, title, body); - let row = |buf: &mut Buffer, dy: u16, text: &str| { + for (i, text) in l.lines.iter().enumerate() { + let dy = 1 + i as u16; buf.set_string(x, y + dy, "\u{2551}", body); - buf.set_string(x + 1, y + dy, pad_to_width(&truncate_with_ellipsis(text, inner_w), inner_w), body); + buf.set_string(x + 1, y + dy, text, body); buf.set_string(x + 1 + inner_w as u16, y + dy, "\u{2551}", body); - }; - row(buf, 1, &path_line); - row(buf, 2, &info.message); - row(buf, 3, ""); - row(buf, 4, "(R)etry (S)kip Skip (A)ll A(b)ort"); - buf.set_string(x, y + box_h - 1, &bottom, body); -} - -/// The hotkey-text button spans `render_error` currently draws at `area` -/// for the `(R)etry (S)kip Skip (A)ll A(b)ort` row — no framed buttons, -/// so each hotkey span is recorded as a button (design D2). Mirrors -/// `render_error`'s own `overlay_rect` geometry exactly. -pub fn hit_buttons(area: Rect, info: &ErrorInfo) -> Vec<(Rect, ButtonId)> { - let path_line = info.path.display().to_string(); - let preferred_inner_w = display_width(&info.message).max(display_width(&path_line)).max(30) + 2; - let r = overlay_rect((preferred_inner_w as u16 + 2, 6), (MIN_INNER_W as u16, 6), (area.width, area.height)); - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - let text = truncate_with_ellipsis("(R)etry (S)kip Skip (A)ll A(b)ort", inner_w); - let base_x = x + 1; - let row_y = y + 4; - - let mut out = Vec::new(); - for (label, id) in [ - ("(R)etry", ButtonId::ErrorRetry), - ("(S)kip", ButtonId::ErrorSkip), - ("Skip (A)ll", ButtonId::ErrorSkipAll), - ("A(b)ort", ButtonId::ErrorAbort), - ] { - if let Some(byte_idx) = text.find(label) { - let col = text[..byte_idx].chars().count() as u16; - out.push((Rect { x: base_x + col, y: row_y, width: display_width(label) as u16, height: 1 }, id)); - } } - out + buf.set_string(x, y + l.rect.height - 1, &bottom, body); } diff --git a/crates/filecommand-tui/src/views/file_action_menu.rs b/crates/filecommand-tui/src/views/file_action_menu.rs index bcb4d56..60c51b3 100644 --- a/crates/filecommand-tui/src/views/file_action_menu.rs +++ b/crates/filecommand-tui/src/views/file_action_menu.rs @@ -1,77 +1,82 @@ //! The Enter-on-file action menu: a primary-style modal list of the -//! target entry's available actions (Run when executable, then View, Edit, -//! Copy, Rename, Move, Delete), mirroring `user_menu`'s double-line frame +//! target entry's available actions (Open first — or Run then Open when +//! executable — then View, Edit, Send to clipboard, Copy, Rename, Move, +//! Delete), mirroring `user_menu`'s double-line frame //! and highlighted-row conventions (file-action-menu "Menu contents, //! ordering, and navigation": "render as a primary-style modal dialog //! (§4.4)"). +use filecommand_core::anim; use filecommand_core::dialogs::{overlay_rect, FileActionMenuState}; use filecommand_core::listing::{display_width, pad_to_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::DialogLayout; use crate::style::role_style; const MIN_INNER_W: usize = 16; const MAX_INNER_W: usize = 50; -pub fn render_file_action_menu(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &FileActionMenuState) { - let body = role_style(theme, Role::DialogPrimary, depth); - let highlight = role_style(theme, Role::MenuHighlight, depth); - +/// The menu's geometry at `area` (reducer-overlay-structure design D8): its +/// rectangle; its text — the title, then one padded ` label` row per visible +/// entry; and each visible entry's row rectangle, indexed by position in +/// `dialog.entries` so the index matches exactly what +/// `FileActionMenuState::cursor` would land on (mouse-input "File-action +/// menu entries are clickable"). +pub fn layout(area: Rect, dialog: &FileActionMenuState) -> DialogLayout { let title = format!(" {} ", dialog.target_name.to_string_lossy()); let widest_label = dialog.entries.iter().map(|e| display_width(e.label())).max().unwrap_or(0); let preferred_inner_w = (widest_label + 2).max(display_width(&title)).clamp(MIN_INNER_W, MAX_INNER_W); let content_rows = dialog.entries.len() as u16; let r = overlay_rect((preferred_inner_w as u16 + 2, content_rows + 2), (MIN_INNER_W as u16 + 2, 3), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; - let visible_rows = box_h.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - - buf.set_string(x, y, format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)), body); - let title_x = x + 1 + ((inner_w.saturating_sub(display_width(&title))) / 2) as u16; - buf.set_string(title_x, y, truncate_with_ellipsis(&title, inner_w), body); + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + let visible_rows = rect.height.saturating_sub(2) as usize; + let mut lines = vec![truncate_with_ellipsis(&title, inner_w)]; + let mut rows = Vec::with_capacity(dialog.entries.len().min(visible_rows)); for (i, entry) in dialog.entries.iter().take(visible_rows).enumerate() { - let ry = y + 1 + i as u16; - let style = if i == dialog.cursor { highlight } else { body }; - buf.set_string(x, ry, "\u{2551}", body); - buf.set_string(x + 1, ry, pad_to_width(&truncate_with_ellipsis(&format!(" {}", entry.label()), inner_w), inner_w), style); - buf.set_string(x + 1 + inner_w as u16, ry, "\u{2551}", body); + lines.push(pad_to_width(&truncate_with_ellipsis(&format!(" {}", entry.label()), inner_w), inner_w)); + rows.push((Rect { x: rect.x + 1, y: rect.y + 1 + i as u16, width: inner_w as u16, height: 1 }, i)); } + DialogLayout { rect, buttons: Vec::new(), rows, lines } +} - buf.set_string(x, y + box_h - 1, format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)), body); +/// Draws the Enter-on-file action menu. `now` is the reducer clock when +/// animations are on (`State::anim_now`) and `None` for the final frame: the +/// box unfolds top-down on open and a newly highlighted row flashes in the +/// hotkey accent before settling, exactly as the F9 pull-downs and the F2 +/// user menu do (ui-animations "Pop-up menus unfold and highlights settle"). +pub fn render_file_action_menu(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &FileActionMenuState, now: Option) { + let l = layout(area, dialog); + super::unfold(buf, now, dialog.opened_at_ms, |b| draw_file_action_menu(b, theme, depth, dialog, now, &l)); } -/// The open file-action menu's clickable row rects at `area` (the same -/// full-screen `area` `render_file_action_menu` is given), indexed by -/// position in `dialog.entries` so the index matches exactly what -/// `FileActionMenuState::cursor` would land on (mouse-input "File-action -/// menu entries are clickable"). Mirrors `render_file_action_menu`'s own -/// box-geometry math (`overlay_rect`, `box_h`, `inner_w`, `visible_rows`, -/// `x`, `y`) so a row rect can never land somewhere the box isn't actually -/// drawn this frame. -pub fn hit_items(area: Rect, dialog: &FileActionMenuState) -> Vec<(Rect, usize)> { - let title = format!(" {} ", dialog.target_name.to_string_lossy()); - let widest_label = dialog.entries.iter().map(|e| display_width(e.label())).max().unwrap_or(0); - let preferred_inner_w = (widest_label + 2).max(display_width(&title)).clamp(MIN_INNER_W, MAX_INNER_W); - let content_rows = dialog.entries.len() as u16; - let r = overlay_rect((preferred_inner_w as u16 + 2, content_rows + 2), (MIN_INNER_W as u16 + 2, 3), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2); - let visible_rows = box_h.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; +/// Paint the menu's finished frame from an already computed [`layout`]; +/// `dialog` supplies the highlight and its settle stamp. Callers animate it +/// through `unfold` themselves, as [`render_file_action_menu`] does. +pub fn draw_file_action_menu(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, dialog: &FileActionMenuState, now: Option, l: &DialogLayout) { + let body = role_style(theme, Role::DialogPrimary, depth); + let settling = matches!((now, dialog.selected_at_ms), (Some(now), Some(at)) if now.saturating_sub(at) < anim::HIGHLIGHT_SETTLE_MS); + let highlight = role_style(theme, if settling { Role::MenuHotkey } else { Role::MenuHighlight }, depth); + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); + let Some((title, entries)) = l.lines.split_first() else { return }; + + buf.set_string(x, y, format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)), body); + let title_x = x + 1 + ((inner_w.saturating_sub(display_width(title))) / 2) as u16; + buf.set_string(title_x, y, title, body); - let mut out = Vec::with_capacity(dialog.entries.len().min(visible_rows)); - for i in 0..dialog.entries.len().min(visible_rows) { - let ry = y + 1 + i as u16; - out.push((Rect { x: x + 1, y: ry, width: inner_w, height: 1 }, i)); + for ((row, i), text) in l.rows.iter().zip(entries) { + let style = if *i == dialog.cursor { highlight } else { body }; + buf.set_string(x, row.y, "\u{2551}", body); + buf.set_string(row.x, row.y, text, style); + buf.set_string(x + 1 + inner_w as u16, row.y, "\u{2551}", body); } - out + + buf.set_string(x, y + l.rect.height - 1, format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)), body); } #[cfg(test)] @@ -82,14 +87,15 @@ mod tests { fn render(dialog: &FileActionMenuState) -> Vec { let area = Rect { x: 0, y: 0, width: 80, height: 24 }; let mut buf = Buffer::empty(area); - render_file_action_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, dialog); + render_file_action_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, dialog, None); (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect()).collect() } #[test] - fn non_executable_lists_view_first_without_run() { + fn non_executable_lists_open_first_without_run() { let dialog = FileActionMenuState::new(OsString::from("notes.txt"), false); let rows = render(&dialog).join("\n"); + assert!(rows.contains("Open")); assert!(rows.contains("View")); assert!(rows.contains("Edit")); assert!(rows.contains("Copy")); @@ -97,18 +103,22 @@ mod tests { assert!(rows.contains("Move")); assert!(rows.contains("Delete")); assert!(!rows.contains("Run"), "non-executable target must not list Run"); + let open_pos = rows.find("Open").unwrap(); let view_pos = rows.find("View").unwrap(); let edit_pos = rows.find("Edit").unwrap(); + assert!(open_pos < view_pos, "Open must render first (open-with-associated-app design D2)"); assert!(view_pos < edit_pos, "entries must render in menu order"); } #[test] - fn executable_lists_run_first() { + fn executable_lists_run_then_open_first() { let dialog = FileActionMenuState::new(OsString::from("setup.exe"), true); let rows = render(&dialog).join("\n"); let run_pos = rows.find("Run").unwrap(); + let open_pos = rows.find("Open").unwrap(); let view_pos = rows.find("View").unwrap(); - assert!(run_pos < view_pos, "Run must render before View when the target is executable"); + assert!(run_pos < open_pos, "Run must render before Open when the target is executable"); + assert!(open_pos < view_pos, "Open must render before View"); } #[test] @@ -117,7 +127,7 @@ mod tests { dialog.cursor = 1; let area = Rect { x: 0, y: 0, width: 80, height: 24 }; let mut buf = Buffer::empty(area); - render_file_action_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &dialog); + render_file_action_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &dialog, None); let theme = Theme::classic(); let highlight = role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16); let expected_label = dialog.entries[1].label(); diff --git a/crates/filecommand-tui/src/views/find_file.rs b/crates/filecommand-tui/src/views/find_file.rs index f0464dd..92fbdb8 100644 --- a/crates/filecommand-tui/src/views/find_file.rs +++ b/crates/filecommand-tui/src/views/find_file.rs @@ -19,7 +19,7 @@ const MAX_LIST_ROWS: usize = 10; /// + status row + bottom frame. const CHROME_ROWS: u16 = 4; -pub fn render_find_file(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &FindFileState) { +pub fn render_find_file(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &FindFileState) -> Option<(u16, u16)> { let body = role_style(theme, Role::DialogPrimary, depth); let field_style = role_style(theme, Role::DialogInput, depth); let highlight_style = role_style(theme, Role::MenuHighlight, depth); @@ -47,16 +47,16 @@ pub fn render_find_file(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Colo // Bracket-and-dots pattern field (§4.4), fixed once a search has begun. let field_w = FIELD_WIDTH.min(inner_w.saturating_sub(2)); - let mut field_text = dialog.pattern.clone(); - if display_width(&field_text) > field_w { - let excess = display_width(&field_text) - field_w; - field_text = field_text.chars().skip(excess).collect(); - } - let pad = field_w.saturating_sub(display_width(&field_text)); - let field = format!("[{field_text}{}]", ".".repeat(pad)); buf.set_string(x, y + 1, "\u{2551}", body); - buf.set_string(x + 1, y + 1, pad_to_width(&field, inner_w), field_style); + buf.set_string(x + 1, y + 1, "[", field_style); + let caret_x = crate::views::draw_text_field(buf, x + 2, y + 1, field_w, &dialog.pattern, field_style); + let close = format!("]{}", " ".repeat(inner_w.saturating_sub(field_w + 2))); + buf.set_string(x + 2 + field_w as u16, y + 1, close, field_style); buf.set_string(x + 1 + inner_w as u16, y + 1, "\u{2551}", body); + // The terminal cursor sits at the pattern's caret only while the field + // is still editable — a submitted search fixes the pattern (find-file + // "Find-file invocation"), so there is no focused field to point at. + let caret = (!showing_results).then_some((caret_x, y + 1)); // Static-text progress/status line — never a spinner (§4.10; find-file // "Progress is shown as static text"). @@ -86,6 +86,7 @@ pub fn render_find_file(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Colo } buf.set_string(x, y + box_h - 1, format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)), body); + caret } #[cfg(test)] @@ -103,10 +104,7 @@ mod tests { } fn a_match(rel: &str) -> FindMatch { - FindMatch { - relative_path: PathBuf::from(rel), - entry: Entry { name: OsString::from(rel), kind: EntryKind::File, size: 0, modified: None }, - } + FindMatch { relative_path: PathBuf::from(rel), entry: Entry::new(OsString::from(rel), EntryKind::File, 0, None, false, false) } } #[test] @@ -137,7 +135,7 @@ mod tests { #[test] fn only_cp437_heritage_glyphs_and_typed_text_are_emitted() { let mut d = FindFileState::new(PathBuf::from("/root")); - d.push('r'); + d.apply(&filecommand_core::line_edit::TextEditOp::Insert('r')); let rows = render(&d).join(""); for ch in rows.chars() { assert!(ch.is_ascii() || "\u{2554}\u{2557}\u{255A}\u{255D}\u{2551}\u{2550}".contains(ch), "non-CP437 glyph `{ch}`"); diff --git a/crates/filecommand-tui/src/views/fuzzy_jump.rs b/crates/filecommand-tui/src/views/fuzzy_jump.rs index 688dbad..6876590 100644 --- a/crates/filecommand-tui/src/views/fuzzy_jump.rs +++ b/crates/filecommand-tui/src/views/fuzzy_jump.rs @@ -19,12 +19,20 @@ const MAX_LIST_ROWS: usize = 10; /// + separator + bottom frame. const CHROME_ROWS: u16 = 4; -pub fn render_fuzzy_jump(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &FuzzyJumpState, history: &[FrecencyEntry], now_ms: u64) { +pub fn render_fuzzy_jump( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + dialog: &FuzzyJumpState, + history: &[FrecencyEntry], + now_ms: u64, +) -> Option<(u16, u16)> { let body_style = role_style(theme, Role::DialogPrimary, depth); let input_style = role_style(theme, Role::DialogInput, depth); let highlight_style = role_style(theme, Role::MenuHighlight, depth); - let ranked = rank_directories(history, &dialog.pattern, now_ms); + let ranked = rank_directories(history, dialog.pattern.text(), now_ms); let preferred_list_rows = ranked.len().clamp(1, MAX_LIST_ROWS); let preferred_inner_w = MAX_INNER_W.max(display_width(TITLE)); let preferred_h = CHROME_ROWS + preferred_list_rows as u16; @@ -42,12 +50,11 @@ pub fn render_fuzzy_jump(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Col let title_x = x + 1 + ((inner_w.saturating_sub(display_width(TITLE))) / 2) as u16; buf.set_string(title_x, y, truncate_with_ellipsis(TITLE, inner_w), body_style); - // Pattern input row, an empty-caret dot when nothing has been typed yet - // — matching the bracket-and-dots idiom used elsewhere for an empty - // single-line field. - let input_text = if dialog.pattern.is_empty() { ".".repeat(inner_w) } else { pad_to_width(&dialog.pattern, inner_w) }; + // The pattern input row: a shared text field per text-field-editing, + // dot-padded out to the field width (the bracket-and-dots idiom), with + // the terminal cursor at its caret. buf.set_string(x, y + 1, "\u{2551}", body_style); - buf.set_string(x + 1, y + 1, &input_text, input_style); + let caret_x = crate::views::draw_text_field(buf, x + 1, y + 1, inner_w, &dialog.pattern, input_style); buf.set_string(x + 1 + inner_w as u16, y + 1, "\u{2551}", body_style); buf.set_string(x, y + 2, format!("\u{2560}{}\u{2563}", "\u{2500}".repeat(inner_w)), body_style); @@ -65,6 +72,7 @@ pub fn render_fuzzy_jump(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Col } buf.set_string(x, y + box_h - 1, format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)), body_style); + Some((caret_x, y + 1)) } #[cfg(test)] @@ -122,7 +130,7 @@ mod tests { #[test] fn typed_pattern_appears_in_the_input_row() { let mut dialog = FuzzyJumpState::new(); - dialog.pattern = "docu".to_string(); + dialog.pattern = filecommand_core::line_edit::LineEdit::from("docu"); let rows = render(&dialog, &[]).join("\n"); assert!(rows.contains("docu")); } diff --git a/crates/filecommand-tui/src/views/history_dialog.rs b/crates/filecommand-tui/src/views/history_dialog.rs new file mode 100644 index 0000000..16505b6 --- /dev/null +++ b/crates/filecommand-tui/src/views/history_dialog.rs @@ -0,0 +1,151 @@ +//! The Alt+F8 command-history dialog: a primary-style modal list of the +//! persisted command history (`State::history`), rendered newest first, in +//! the user menu's geometry (command-line "Command history dialog (Alt+F8)"; +//! pulldown-menu-completion design D2). + +use filecommand_core::anim; +use filecommand_core::dialogs::{overlay_rect, HistoryDialogState}; +use filecommand_core::listing::{display_width, pad_to_width, truncate_with_ellipsis}; +use filecommand_core::theme::{ColorDepth, Role, Theme}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +use crate::style::role_style; + +const TITLE: &str = " History "; +const MIN_INNER_W: usize = 20; +const MAX_INNER_W: usize = 50; +const EMPTY_PLACEHOLDER: &str = "(no history)"; + +/// Renders the Alt+F8 history dialog: `history` (stored oldest first) listed +/// newest first, with `dialog.cursor` counting display rows from the top (0 = +/// the newest entry). An empty history shows a single `(no history)` row that +/// never highlights. +/// +/// `now` is the reducer clock when animations are on (`State::anim_now`) and +/// `None` for the final frame: the box unfolds top-down on open and a newly +/// highlighted row flashes in the hotkey accent before settling +/// (ui-animations "Pop-up menus unfold and highlights settle"). +pub fn render_history_dialog( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + dialog: &HistoryDialogState, + history: &[String], + now: Option, +) { + super::unfold(buf, now, dialog.opened_at_ms, |b| draw_history_dialog(b, area, theme, depth, dialog, history, now)); +} + +fn draw_history_dialog( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + dialog: &HistoryDialogState, + history: &[String], + now: Option, +) { + let body = role_style(theme, Role::DialogPrimary, depth); + let settling = matches!((now, dialog.selected_at_ms), (Some(now), Some(at)) if now.saturating_sub(at) < anim::HIGHLIGHT_SETTLE_MS); + let highlight = role_style(theme, if settling { Role::MenuHotkey } else { Role::MenuHighlight }, depth); + + let widest_entry = history.iter().map(|entry| display_width(entry)).max().unwrap_or(display_width(EMPTY_PLACEHOLDER)); + let preferred_inner_w = (widest_entry + 2).clamp(MIN_INNER_W, MAX_INNER_W).max(display_width(TITLE)); + let content_rows = history.len().max(1) as u16; + let r = overlay_rect((preferred_inner_w as u16 + 2, content_rows + 2), (MIN_INNER_W as u16 + 2, 3), (area.width, area.height)); + let inner_w = r.width.saturating_sub(2) as usize; + let visible_rows = r.height.saturating_sub(2) as usize; + let x = area.x + r.x; + let y = area.y + r.y; + + buf.set_string(x, y, format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)), body); + let title_x = x + 1 + ((inner_w.saturating_sub(display_width(TITLE))) / 2) as u16; + buf.set_string(title_x, y, truncate_with_ellipsis(TITLE, inner_w), body); + + if history.is_empty() { + buf.set_string(x, y + 1, "\u{2551}", body); + buf.set_string(x + 1, y + 1, pad_to_width(&truncate_with_ellipsis(EMPTY_PLACEHOLDER, inner_w), inner_w), body); + buf.set_string(x + 1 + inner_w as u16, y + 1, "\u{2551}", body); + } else { + // A 200-entry history can be taller than the box: keep the cursor's + // display row inside the visible window (same minimal-shift rule as + // the panel viewport). + let scroll = if visible_rows == 0 { 0 } else { dialog.cursor.saturating_sub(visible_rows - 1) }; + for i in 0..visible_rows { + let row = scroll + i; + // Display row 0 is the newest entry: `history[len - 1 - row]`. + let Some(entry) = history.len().checked_sub(row + 1).map(|idx| &history[idx]) else { break }; + let ry = y + 1 + i as u16; + let style = if row == dialog.cursor { highlight } else { body }; + buf.set_string(x, ry, "\u{2551}", body); + buf.set_string(x + 1, ry, pad_to_width(&truncate_with_ellipsis(&format!(" {entry}"), inner_w), inner_w), style); + buf.set_string(x + 1 + inner_w as u16, ry, "\u{2551}", body); + } + } + + buf.set_string(x, y + r.height - 1, format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)), body); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(dialog: &HistoryDialogState, history: &[String]) -> Vec { + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let mut buf = Buffer::empty(area); + render_history_dialog(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, dialog, history, None); + (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect()).collect() + } + + #[test] + fn lists_history_newest_first() { + let history = vec!["cd C:\\work".to_string(), "cd D:\\".to_string()]; + let rows = render(&HistoryDialogState::new(), &history).join("\n"); + let newest = rows.find("cd D:\\").unwrap(); + let older = rows.find("cd C:\\work").unwrap(); + assert!(newest < older, "the newest entry renders above the older one"); + assert!(rows.contains("History"), "the dialog is titled"); + } + + #[test] + fn empty_history_shows_the_no_history_row() { + let rows = render(&HistoryDialogState::new(), &[]).join("\n"); + assert!(rows.contains("(no history)")); + } + + #[test] + fn the_highlighted_row_uses_the_menu_highlight_style() { + let history = vec!["one".to_string(), "two".to_string()]; + let mut dialog = HistoryDialogState::new(); + dialog.cursor = 1; // "one" (the older entry, on the second row) + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let mut buf = Buffer::empty(area); + render_history_dialog(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &dialog, &history, None); + let theme = Theme::classic(); + let highlight = role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16); + for y in 0..area.height { + let row: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect(); + if row.contains(" one ") { + let x = row.find("one").unwrap() as u16; + assert_eq!(buf[(x, y)].style().fg, highlight.fg, "the cursor row highlights"); + } + if row.contains(" two ") { + let x = row.find("two").unwrap() as u16; + assert_ne!(buf[(x, y)].style().fg, highlight.fg, "other rows do not"); + } + } + } + + #[test] + fn a_history_taller_than_the_box_scrolls_to_keep_the_cursor_visible() { + let history: Vec = (0..50).map(|i| format!("cmd{i:02}")).collect(); + let mut dialog = HistoryDialogState::new(); + dialog.cursor = 49; // the oldest entry + let rows = render(&dialog, &history); + let joined = rows.join("\n"); + assert!(joined.contains("cmd00"), "the oldest entry is shown once the cursor reaches it:\n{joined}"); + assert!(!joined.contains("cmd49"), "the newest entries scrolled out of the window"); + } +} diff --git a/crates/filecommand-tui/src/views/info_panel.rs b/crates/filecommand-tui/src/views/info_panel.rs index 1278d0d..d21b731 100644 --- a/crates/filecommand-tui/src/views/info_panel.rs +++ b/crates/filecommand-tui/src/views/info_panel.rs @@ -17,15 +17,7 @@ use crate::style::role_style; /// Render the Info body into `area`, which is the panel's interior (inside /// its double-line border, below the header row). -pub fn render_info( - buf: &mut Buffer, - area: Rect, - theme: &Theme, - depth: ColorDepth, - values: &InfoValues, - cwd: &Path, - identity_lines: &[String; 4], -) { +pub fn render_info(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, values: &InfoValues, cwd: &Path, identity_lines: &[String; 4]) { if area.width < 6 || area.height == 0 { return; } @@ -207,10 +199,7 @@ mod tests { let area = Rect { x: 0, y: 0, width: 46, height: 22 }; let mut buf = Buffer::empty(area); render_info(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, values, Path::new(cwd), &identity()); - (0..area.height) - .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()) - .collect::>() - .join("\n") + (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()).collect::>().join("\n") } #[test] @@ -347,10 +336,7 @@ mod tests { let mut buf = Buffer::empty(area); let values = InfoValues { volume_label: Some("A very long volume label indeed".to_string()), ..InfoValues::default() }; render_info(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &values, Path::new(r"C:\"), &identity()); - let text: String = (0..area.height) - .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()) - .collect::>() - .join("\n"); + let text: String = (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()).collect::>().join("\n"); assert!(text.contains("Volume label"), "the label is not eaten by the truncated value:\n{text}"); assert!(text.contains('\u{2026}'), "the overlong value is truncated with an ellipsis:\n{text}"); assert!(!text.contains("A very long volume label indeed"), "the full untruncated value must not appear:\n{text}"); @@ -385,6 +371,9 @@ mod tests { return; } } - panic!("Volume label row not found:\n{}", (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()).collect::>().join("\n")); + panic!( + "Volume label row not found:\n{}", + (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect::()).collect::>().join("\n") + ); } } diff --git a/crates/filecommand-tui/src/views/keybar.rs b/crates/filecommand-tui/src/views/keybar.rs index 3a243a0..81dbd51 100644 --- a/crates/filecommand-tui/src/views/keybar.rs +++ b/crates/filecommand-tui/src/views/keybar.rs @@ -34,34 +34,14 @@ const KEYS_FULL: &[(&str, &str)] = &[ /// The main panel bar's short form: `1Hlp 2Mnu 3Vew 4Edt 5Cpy 6Ren 7Mkd /// 8Del 9Pdn 10Qit` (50 columns). -const KEYS_SHORT: &[(&str, &str)] = &[ - ("1", "Hlp"), - ("2", "Mnu"), - ("3", "Vew"), - ("4", "Edt"), - ("5", "Cpy"), - ("6", "Ren"), - ("7", "Mkd"), - ("8", "Del"), - ("9", "Pdn"), - ("10", "Qit"), -]; +const KEYS_SHORT: &[(&str, &str)] = + &[("1", "Hlp"), ("2", "Mnu"), ("3", "Vew"), ("4", "Edt"), ("5", "Cpy"), ("6", "Ren"), ("7", "Mkd"), ("8", "Del"), ("9", "Pdn"), ("10", "Qit")]; /// The main panel bar's numbers-only form: `1 2 3 4 5 6 7 8 9 10` (20 /// columns) — defensive headroom below the 60-column floor, where the /// short form already fits. -const KEYS_NUMBERS_ONLY: &[(&str, &str)] = &[ - ("1", ""), - ("2", ""), - ("3", ""), - ("4", ""), - ("5", ""), - ("6", ""), - ("7", ""), - ("8", ""), - ("9", ""), - ("10", ""), -]; +const KEYS_NUMBERS_ONLY: &[(&str, &str)] = + &[("1", ""), ("2", ""), ("3", ""), ("4", ""), ("5", ""), ("6", ""), ("7", ""), ("8", ""), ("9", ""), ("10", "")]; /// The display-column width a bar built from `pairs` would occupy: every /// slot's number plus label, plus one separating space between slots (none diff --git a/crates/filecommand-tui/src/views/menubar.rs b/crates/filecommand-tui/src/views/menubar.rs index 0e0cff8..6fc8dc0 100644 --- a/crates/filecommand-tui/src/views/menubar.rs +++ b/crates/filecommand-tui/src/views/menubar.rs @@ -4,6 +4,7 @@ //! panels' top borders and the clock while it is open. Pull-downs are //! single-line CP437-framed boxes hanging directly below their title. +use filecommand_core::anim; use filecommand_core::dialogs::clamp_overlay_dim; use filecommand_core::listing::{display_width, pad_to_width, truncate_with_ellipsis}; use filecommand_core::menu::{entries, MenuEntry, MenuId, MenuState, ALL_MENUS}; @@ -11,6 +12,7 @@ use filecommand_core::theme::{ColorDepth, Role, Theme}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::DialogLayout; use crate::style::role_style; /// Columns before the first title and between titles, matching the NC bar @@ -40,38 +42,130 @@ pub fn title_positions() -> Vec<(MenuId, u16)> { out } -pub fn render_menu_bar(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, menu: &MenuState) { +/// The F9 bar's geometry for a frame (reducer-overlay-structure design D8; +/// mouse-input "Click targets match the rendered geometry"): the bar row, +/// each title's cells, and — while a pull-down is open — the pull-down box +/// with its item rows. One computation feeds both the renderer and the hit +/// map. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MenuLayout { + /// The full-width bar row across the top of the area. + pub bar: Rect, + /// Each drawn menu title's cells and which menu it opens, in bar order; + /// a title that would start past the right edge is not listed. + pub titles: Vec<(Rect, MenuId)>, + /// The open pull-down: its box (`rect`), its item rows keyed by position + /// in `menu::entries(active)` — separators simply have no row — and its + /// text, one entry per position with an empty string for a separator. + /// `None` while the pull-down is closed, or when there is no room for + /// even an empty framed box under the bar. + pub pulldown: Option, +} + +/// The bar's and open pull-down's geometry at `area`. An empty layout for a +/// zero-sized area. +pub fn layout(area: Rect, menu: &MenuState) -> MenuLayout { if area.height == 0 || area.width == 0 { + return MenuLayout::default(); + } + let bar = Rect { x: area.x, y: area.y, width: area.width, height: 1 }; + let mut titles = Vec::with_capacity(ALL_MENUS.len()); + for (id, x) in title_positions() { + let x = area.x + x; + if x >= area.x + area.width { + break; + } + let w = (id.title().chars().count() as u16).min(area.x + area.width - x); + titles.push((Rect { x, y: area.y, width: w, height: 1 }, id)); + } + MenuLayout { bar, titles, pulldown: menu.pulldown_open.then(|| pulldown_layout(area, menu.active)).flatten() } +} + +/// The open pull-down's box and rows. Width and height each clamp +/// independently via the unified overlay rule's per-dimension helper +/// (responsive-layout "Unified overlay geometry"); rows available is the +/// terminal height minus the bar's own row. The box hangs under its title, +/// shifted one column left so the title sits over the box's interior rather +/// than its frame — and shifted further left just enough to stay on screen, +/// rather than centering (responsive-layout "Unified overlay geometry": +/// "Pull-down box shifts left instead of centering"). `None` when there is +/// no room for even an empty framed box. +fn pulldown_layout(area: Rect, id: MenuId) -> Option { + let content_rows = entries(id).len() as u16; + let avail_h = area.height.saturating_sub(1); + let box_w = clamp_overlay_dim(pulldown_inner_width(id) as u16 + 2, MIN_INNER_W + 2, area.width); + let box_h = clamp_overlay_dim(content_rows + 2, MIN_BOX_H, avail_h); + // Frame top + frame bottom take two of the rows we have; if there + // isn't room for even an empty framed box, draw nothing rather than a + // single overlapping border row. + if box_h < 2 { + return None; + } + let inner_w = box_w.saturating_sub(2) as usize; + let visible = box_h.saturating_sub(2) as usize; + + let title_x = title_positions().iter().find(|(m, _)| *m == id).map(|(_, x)| *x).unwrap_or(LEAD); + let mut x = area.x + title_x.saturating_sub(1); + let right_edge = area.x + area.width; + if x + box_w > right_edge { + x = right_edge.saturating_sub(box_w); + } + let y = area.y + 1; + + let rows = entries(id) + .iter() + .take(visible) + .enumerate() + .filter(|(_, entry)| matches!(entry, MenuEntry::Item(_))) + .map(|(i, _)| (Rect { x: x + 1, y: y + 1 + i as u16, width: inner_w as u16, height: 1 }, i)) + .collect(); + let lines = item_rows(id, inner_w).into_iter().take(visible).map(Option::unwrap_or_default).collect(); + Some(DialogLayout { rect: Rect { x, y, width: box_w, height: 2 + visible as u16 }, buttons: Vec::new(), rows, lines }) +} + +/// Draws the F9 bar and its open pull-down. `now` is the reducer clock when +/// animations are on (`State::anim_now`) and `None` for the final frame: the +/// bar sweeps in left-to-right and the pull-down unfolds top-down, both +/// interpolated from the stamps on `menu` (pulldown-menus "Menu bar entrance +/// sweep"; ui-animations "Pop-up menus unfold and highlights settle"). +pub fn render_menu_bar(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, menu: &MenuState, now: Option) { + draw_menu_bar(buf, theme, depth, menu, now, &layout(area, menu)); +} + +/// Draw the bar and pull-down from an already computed [`layout`]. +pub fn draw_menu_bar(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, menu: &MenuState, now: Option, l: &MenuLayout) { + if l.bar.height == 0 || l.bar.width == 0 { return; } + super::sweep(buf, now, menu.opened_at_ms, |b| render_bar_row(b, theme, depth, menu, l)); + if let Some(pulldown) = &l.pulldown { + super::unfold(buf, now, menu.pulldown_opened_at_ms, |b| render_pulldown(b, theme, depth, menu, now, pulldown)); + } +} + +/// The bar row itself: the full-width bar with every title, the active one +/// highlighted. +fn render_bar_row(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, menu: &MenuState, l: &MenuLayout) { let bar_style = role_style(theme, Role::MenuBar, depth); let highlight_style = role_style(theme, Role::MenuHighlight, depth); let hotkey_style = role_style(theme, Role::MenuHotkey, depth); - let y = area.y; - buf.set_string(area.x, y, " ".repeat(area.width as usize), bar_style); + let y = l.bar.y; + buf.set_string(l.bar.x, y, " ".repeat(l.bar.width as usize), bar_style); - for (id, x) in title_positions() { + for (rect, id) in &l.titles { let title = id.title(); - let x = area.x + x; - if x >= area.x + area.width { - break; - } - let active = id == menu.active; + let active = *id == menu.active; let style = if active { highlight_style } else { bar_style }; - buf.set_string(x, y, clip(title, (area.x + area.width - x) as usize), style); + buf.set_string(rect.x, y, clip(title, rect.width as usize), style); // The hotkey letter stays bright-yellow on the cyan bar, but an // active title is a solid white-on-black block: recoloring one // letter inside it would break the highlight. if !active { let letter = &title[..id.hotkey().len_utf8()]; - buf.set_string(x, y, letter, hotkey_style); + buf.set_string(rect.x, y, letter, hotkey_style); } } - - if menu.pulldown_open { - render_pulldown(buf, area, theme, depth, menu); - } } /// One rendered pull-down row: its text and which style it takes. When @@ -116,61 +210,49 @@ fn pulldown_inner_width(id: MenuId) -> usize { .unwrap_or(4) } -fn render_pulldown(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, menu: &MenuState) { - let body_style = role_style(theme, Role::MenuBody, depth); - let highlight_style = role_style(theme, Role::MenuHighlight, depth); - let disabled_style = role_style(theme, Role::MenuDisabled, depth); - - let id = menu.active; - let content_rows = entries(id).len() as u16; - // Width and height each clamp independently via the unified overlay - // rule's per-dimension helper (responsive-layout "Unified overlay - // geometry"); rows available is the terminal height minus the bar's - // own row. - let avail_h = area.height.saturating_sub(1); - let box_w = clamp_overlay_dim(pulldown_inner_width(id) as u16 + 2, MIN_INNER_W + 2, area.width); - let box_h = clamp_overlay_dim(content_rows + 2, MIN_BOX_H, avail_h); - let inner_w = box_w.saturating_sub(2) as usize; - // Frame top + frame bottom take two of the rows we have; if there - // isn't room for even an empty framed box, draw nothing rather than a - // single overlapping border row. - if box_h < 2 { - return; +/// The role one pull-down row takes (pulldown-menus "Pull-down visuals with +/// separators and disabled items"): the highlight only when both selected +/// and enabled — a disabled row is unselectable, so it never highlights — +/// the hotkey accent while a just-moved highlight settles, and the greyed +/// disabled role otherwise. A free function so the disabled rule stays +/// pinned by a local-fixture test now that no shipped item is disabled +/// (pulldown-menu-completion design D5). +fn item_role(selected: bool, enabled: bool, settling: bool) -> Role { + if !enabled { + Role::MenuDisabled + } else if selected && settling { + Role::MenuHotkey + } else if selected { + Role::MenuHighlight + } else { + Role::MenuBody } - let visible = box_h.saturating_sub(2) as usize; +} - let title_x = title_positions().iter().find(|(m, _)| *m == id).map(|(_, x)| *x).unwrap_or(LEAD); - // Hang the box under its title, shifted one column left so the title - // sits over the box's interior rather than its frame — and shifted - // further left just enough to stay on screen, rather than centering - // (responsive-layout "Unified overlay geometry": "Pull-down box shifts - // left instead of centering"). - let mut x = area.x + title_x.saturating_sub(1); - let right_edge = area.x + area.width; - if x + box_w > right_edge { - x = right_edge.saturating_sub(box_w); - } - let y = area.y + 1; +/// Paint the open pull-down from its layout: the frame, a `├──┤` rule for +/// each separator, and each item row on the cells its layout row names. +fn render_pulldown(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, menu: &MenuState, now: Option, l: &DialogLayout) { + let body_style = role_style(theme, Role::MenuBody, depth); + // A newly highlighted row renders in the hotkey accent for + // `HIGHLIGHT_SETTLE_MS`, then settles to the normal highlight + // (ui-animations "Highlight settles after moving"). + let settling = matches!((now, menu.selected_at_ms), (Some(now), Some(at)) if now.saturating_sub(at) < anim::HIGHLIGHT_SETTLE_MS); - let rows = item_rows(id, inner_w); + let id = menu.active; + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); + let visible = l.lines.len(); buf.set_string(x, y, format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner_w)), body_style); - for (i, row) in rows.iter().take(visible).enumerate() { + for (i, (entry, text)) in entries(id).iter().zip(&l.lines).enumerate() { let ry = y + 1 + i as u16; - match row { - None => { + match entry { + MenuEntry::Separator => { buf.set_string(x, ry, format!("\u{251C}{}\u{2524}", "\u{2500}".repeat(inner_w)), body_style); } - Some(text) => { + MenuEntry::Item(item) => { buf.set_string(x, ry, "\u{2502}", body_style); - let enabled = matches!(entries(id).get(i), Some(MenuEntry::Item(item)) if item.is_enabled()); - let style = if i == menu.selected && enabled { - highlight_style - } else if enabled { - body_style - } else { - disabled_style - }; + let style = role_style(theme, item_role(i == menu.selected, item.is_enabled(), settling), depth); buf.set_string(x + 1, ry, text, style); buf.set_string(x + 1 + inner_w as u16, ry, "\u{2502}", body_style); } @@ -179,64 +261,19 @@ fn render_pulldown(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDept buf.set_string(x, y + 1 + visible as u16, format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner_w)), body_style); } -/// The clickable menu-title rects `render_menu_bar` currently draws across -/// the top row of `area` (mouse-input "Key bar, menu bar, pull-down items, -/// and dialog buttons are clickable"). Mirrors `title_positions`' exact -/// placement, same as the renderer itself does. +/// The clickable menu-title rects `render_menu_bar` draws across the top row +/// of `area` (mouse-input "Key bar, menu bar, pull-down items, and dialog +/// buttons are clickable") — [`layout`]'s titles. pub fn hit_titles(area: Rect) -> Vec<(Rect, MenuId)> { - if area.height == 0 || area.width == 0 { - return vec![]; - } - let mut out = Vec::with_capacity(ALL_MENUS.len()); - for (id, x) in title_positions() { - let x = area.x + x; - if x >= area.x + area.width { - break; - } - let w = (id.title().chars().count() as u16).min(area.x + area.width - x); - out.push((Rect { x, y: area.y, width: w, height: 1 }, id)); - } - out + layout(area, &MenuState::opened()).titles } -/// The open pull-down's clickable item rects at `area` (the same full- -/// screen `area` `render_menu_bar`/`render_pulldown` are given), indexed by -/// position in `menu::entries(menu.active)` so the index matches exactly -/// what `MenuState::selected` would land on — separators simply have no -/// entry here. Mirrors `render_pulldown`'s box-geometry math (`box_w`, -/// `box_h`, `x`, `y`, `visible`) so an item rect can never land somewhere -/// the box isn't actually drawn. Empty when the pull-down is closed. +/// The open pull-down's clickable item rects at `area`, indexed by position +/// in `menu::entries(menu.active)` so the index matches exactly what +/// `MenuState::selected` would land on — separators simply have no entry +/// here. [`layout`]'s pull-down rows; empty when the pull-down is closed. pub fn hit_items(area: Rect, menu: &MenuState) -> Vec<(Rect, usize)> { - if !menu.pulldown_open { - return vec![]; - } - let id = menu.active; - let content_rows = entries(id).len() as u16; - let avail_h = area.height.saturating_sub(1); - let box_w = clamp_overlay_dim(pulldown_inner_width(id) as u16 + 2, MIN_INNER_W + 2, area.width); - let box_h = clamp_overlay_dim(content_rows + 2, MIN_BOX_H, avail_h); - if box_h < 2 { - return vec![]; - } - let inner_w = box_w.saturating_sub(2); - let visible = box_h.saturating_sub(2) as usize; - - let title_x = title_positions().iter().find(|(m, _)| *m == id).map(|(_, x)| *x).unwrap_or(LEAD); - let mut x = area.x + title_x.saturating_sub(1); - let right_edge = area.x + area.width; - if x + box_w > right_edge { - x = right_edge.saturating_sub(box_w); - } - let y = area.y + 1; - - let mut out = Vec::new(); - for (i, entry) in entries(id).iter().take(visible).enumerate() { - if matches!(entry, MenuEntry::Item(_)) { - let ry = y + 1 + i as u16; - out.push((Rect { x: x + 1, y: ry, width: inner_w, height: 1 }, i)); - } - } - out + layout(area, menu).pulldown.map(|p| p.rows).unwrap_or_default() } fn clip(s: &str, max_w: usize) -> String { @@ -260,7 +297,7 @@ mod tests { fn render(menu: &MenuState, width: u16, height: u16) -> Vec { let area = Rect { x: 0, y: 0, width, height }; let mut buf = Buffer::empty(area); - render_menu_bar(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, menu); + render_menu_bar(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, menu, None); (0..height).map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect()).collect() } @@ -309,23 +346,34 @@ mod tests { #[test] fn separator_rows_are_drawn_with_the_horizontal_rule_glyph() { let rows = render(&MenuState::for_menu(MenuId::Files), 80, 24); - // FILES_MENU puts a separator after "Attributes" (row index 7). - assert!( - rows.iter().any(|r| r.contains('\u{251C}') && r.contains('\u{2524}')), - "expected a `├──┤` separator row in:\n{}", - rows.join("\n") - ); + // FILES_MENU puts a separator after "Delete" (and two more below). + assert!(rows.iter().any(|r| r.contains('\u{251C}') && r.contains('\u{2524}')), "expected a `├──┤` separator row in:\n{}", rows.join("\n")); } #[test] fn every_item_and_shortcut_is_listed() { let rows = render(&MenuState::for_menu(MenuId::Files), 80, 24).join("\n"); - for label in ["View", "Edit", "Copy", "Rename/Move", "Make directory", "Delete", "Attributes", "Quit"] { + for label in ["View", "Edit", "Copy", "Rename/Move", "Make directory", "Delete", "Select group", "Quit"] { assert!(rows.contains(label), "`{label}` missing from the Files pull-down"); } assert!(rows.contains("F5"), "shortcut hints are rendered"); } + /// pulldown-menus "Pull-down visuals with separators and disabled items": + /// the disabled rule stays available for future items, but no shipped + /// item uses it — pin it against the style-rule function with a local + /// fixture rather than a shipped row (pulldown-menu-completion design + /// D5). + #[test] + fn a_disabled_row_uses_the_disabled_role_and_never_the_highlight() { + assert_eq!(item_role(false, false, false), Role::MenuDisabled, "disabled rows render greyed"); + assert_eq!(item_role(true, false, false), Role::MenuDisabled, "a disabled row is unselectable, so it never highlights"); + assert_eq!(item_role(true, false, true), Role::MenuDisabled); + assert_eq!(item_role(false, true, false), Role::MenuBody); + assert_eq!(item_role(true, true, false), Role::MenuHighlight); + assert_eq!(item_role(true, true, true), Role::MenuHotkey, "a just-moved highlight flashes the hotkey accent"); + } + #[test] fn only_cp437_heritage_glyphs_are_emitted() { let rows = render(&MenuState::for_menu(MenuId::Left), 80, 24).join(""); @@ -382,7 +430,7 @@ mod tests { #[test] fn pulldown_width_fits_the_widest_item() { let inner = pulldown_inner_width(MenuId::Commands); - let widest = display_width("Compare directories") + ITEM_PAD * 2; - assert!(inner >= widest, "inner width {inner} cannot fit `Compare directories` ({widest})"); + let widest = display_width("Menu file edit") + ITEM_PAD * 2; + assert!(inner >= widest, "inner width {inner} cannot fit `Menu file edit` ({widest})"); } } diff --git a/crates/filecommand-tui/src/views/mod.rs b/crates/filecommand-tui/src/views/mod.rs index f159876..b391cf2 100644 --- a/crates/filecommand-tui/src/views/mod.rs +++ b/crates/filecommand-tui/src/views/mod.rs @@ -11,6 +11,7 @@ pub mod find_file; pub mod fuzzy_jump; pub mod header; pub mod help; +pub mod history_dialog; pub mod info_panel; pub mod keybar; pub mod menubar; @@ -26,17 +27,250 @@ pub mod theme_picker; pub mod user_menu; pub mod viewer; +use filecommand_core::anim; +use filecommand_core::dialogs::OverlayRect; use filecommand_core::fs_ops::dialog::RunningDialog; +use filecommand_core::line_edit::LineEdit; use filecommand_core::listing::display_width; use filecommand_core::panel::PanelState; -use filecommand_core::theme::ColorDepth; +use filecommand_core::theme::{ColorDepth, Role}; +use filecommand_core::update::ButtonId; use filecommand_core::viewer::ByteSource; -use filecommand_core::{PanelSide, State, UiPhase}; -use ratatui::buffer::Buffer; +use filecommand_core::{Overlay, PanelSide, State, UiPhase}; +use ratatui::buffer::{Buffer, Cell}; use ratatui::layout::Rect; use crate::hitmap::HitMap; use crate::layout; +use crate::style::role_style; + +// --------------------------------------------------------------------- +// Dialog geometry shared by the renderer and the hit map +// (reducer-overlay-structure design D8) +// --------------------------------------------------------------------- + +/// One dialog's or menu's geometry for a frame: computed once by the +/// module's `layout` function, then both painted from (`draw_*`) and +/// recorded into the [`HitMap`] by `render`'s dispatch — so the cells a +/// click maps to a button or row are, by construction, exactly the cells +/// where that button's label or that row is drawn (mouse-input "Click +/// targets match the rendered geometry"). The rectangle itself still comes +/// from the unified overlay geometry rule (`filecommand_core::dialogs:: +/// overlay_rect`; responsive-layout "Unified overlay geometry"). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DialogLayout { + /// The dialog's rectangle on screen, frame included. + pub rect: Rect, + /// Each clickable button's cells — exactly the cells its label occupies + /// — and what it activates. + pub buttons: Vec<(Rect, ButtonId)>, + /// Each clickable row (menus), keyed by its position in the model's + /// entry list. + pub rows: Vec<(Rect, usize)>, + /// The content strings the renderer paints, built once here so a + /// button's hit rectangle and its painted label come from the same text. + /// What each entry is depends on the dialog; see its `layout`. + pub lines: Vec, +} + +/// The cells each `label` occupies inside `text`, drawn starting at `(x, +/// y)`: the button span the hit map records for it. A label the row's +/// truncation dropped gets no span, so a clipped dialog never offers a +/// button it does not show. +pub fn label_spans(text: &str, x: u16, y: u16, labels: &[(&str, T)]) -> Vec<(Rect, T)> { + labels + .iter() + .filter_map(|(label, id)| { + let byte_idx = text.find(label)?; + let col = display_width(&text[..byte_idx]) as u16; + Some((Rect { x: x + col, y, width: display_width(label) as u16, height: 1 }, *id)) + }) + .collect() +} + +// --------------------------------------------------------------------- +// Shared single-line text field (text-field-editing; design D5) +// --------------------------------------------------------------------- + +/// Draw one [`LineEdit`]'s visible window at `(x, y)`: `window(width)` +/// chars of text followed by dot padding out to `width` cells — the +/// bracket-and-dots field body used by the destination/name input, the +/// find-file pattern, and the fuzzy-jump pattern (callers draw any `[` `]` +/// brackets themselves). Returns the screen column the terminal cursor +/// should sit on: the caret's column within the window, clamped onto the +/// last cell when the caret is at the end of a full window (`window`'s +/// `caret_col == width` case — the cursor can never land past the field). +pub fn draw_text_field(buf: &mut Buffer, x: u16, y: u16, width: usize, edit: &LineEdit, style: ratatui::style::Style) -> u16 { + if width == 0 { + return x; + } + let (start, caret_col) = edit.window(width); + let slice: String = edit.text().chars().skip(start).take(width).collect(); + let pad = width.saturating_sub(display_width(&slice)); + buf.set_string(x, y, format!("{slice}{}", ".".repeat(pad)), style); + // `caret_col` counts window chars; the cursor column is the display + // width of the chars before it (the two differ only for wide chars). + let caret_cells = display_width(&slice.chars().take(caret_col).collect::()); + x + caret_cells.min(width - 1) as u16 +} + +// --------------------------------------------------------------------- +// Reveal helpers (ui-animations; design D3) +// +// Every animated surface is drawn *finished* by its ordinary view function; +// these helpers only decide how much of that finished drawing is on screen +// this frame. The surface is rendered into a scratch buffer pre-filled with +// a sentinel cell, so the cells the view actually touched — and therefore +// the surface's exact bounding rectangle — are known without any per-view +// geometry code, and only the revealed part is copied onto the real buffer. +// Content under the window is the real finished surface, never placeholder +// art, and hit-maps (built from the final layouts in `render`) are +// unaffected. With `now == None` (animations off) or once the duration has +// elapsed, the view draws straight onto the buffer: byte-identical to the +// pre-animation rendering. +// --------------------------------------------------------------------- + +/// The marker a scratch buffer starts filled with: a cell still equal to it +/// after a view ran was never touched. No view draws U+0001. +fn sentinel() -> Cell { + Cell::new("\u{1}") +} + +/// The bounding rectangle of the cells `scratch` holds that differ from the +/// sentinel — i.e. everything the view drew — or `None` if it drew nothing. +fn touched_bounds(scratch: &Buffer) -> Option { + let sentinel = sentinel(); + let area = *scratch.area(); + let (mut min_x, mut min_y, mut max_x, mut max_y) = (u16::MAX, u16::MAX, 0u16, 0u16); + let mut any = false; + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + if scratch[(x, y)] != sentinel { + any = true; + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + } + any.then(|| Rect { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 }) +} + +/// Copy the cells of `src` inside `rect` that the view touched onto `dst`. +fn blit(dst: &mut Buffer, src: &Buffer, rect: Rect) { + let sentinel = sentinel(); + let rect = rect.intersection(*dst.area()); + for y in rect.top()..rect.bottom() { + for x in rect.left()..rect.right() { + let cell = &src[(x, y)]; + if *cell != sentinel { + dst[(x, y)] = cell.clone(); + } + } + } +} + +/// Run `draw` for a surface stamped at `stamp` and last-ticked at `now`. +/// While the animation is in flight, `draw` renders into a scratch buffer +/// and `reveal` copies the visible part onto `buf`, given the surface's +/// bounding rectangle and the per-mille progress; otherwise `draw` paints +/// `buf` directly. +fn staged( + buf: &mut Buffer, + now: Option, + stamp: u64, + duration_ms: u64, + draw: impl FnOnce(&mut Buffer), + reveal: impl FnOnce(&mut Buffer, &Buffer, Rect, u16), +) { + let Some(now) = now else { return draw(buf) }; + let elapsed = now.saturating_sub(stamp); + if elapsed >= duration_ms { + return draw(buf); + } + let mut scratch = Buffer::filled(*buf.area(), sentinel()); + draw(&mut scratch); + if let Some(bounds) = touched_bounds(&scratch) { + reveal(buf, &scratch, bounds, anim::progress(elapsed, duration_ms)); + } +} + +/// Modal dialog entrance: a centered window grows from 2x1 to the dialog's +/// full rectangle over `DIALOG_IRIS_MS`, showing the finished dialog through +/// it (ui-animations "Modal dialogs open with an iris"). +fn iris(buf: &mut Buffer, now: Option, stamp: u64, draw: impl FnOnce(&mut Buffer)) { + staged(buf, now, stamp, anim::DIALOG_IRIS_MS, draw, |dst, src, bounds, progress| { + let finish = OverlayRect { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + let w = anim::iris_rect(finish, progress); + blit(dst, src, Rect { x: w.x, y: w.y, width: w.width, height: w.height }); + }); +} + +/// Pop-up menu entrance: the frame's top row first, item rows added +/// downward, the bottom frame edge tracking the last revealed row so the box +/// is never taller than its final rectangle (ui-animations "Pop-up menus +/// unfold and highlights settle"). +fn unfold(buf: &mut Buffer, now: Option, stamp: u64, draw: impl FnOnce(&mut Buffer)) { + staged(buf, now, stamp, anim::POPUP_UNFOLD_MS, draw, |dst, src, bounds, progress| { + if bounds.height < 3 { + return blit(dst, src, bounds); + } + let interior = (bounds.height - 2) as usize; + let shown = anim::reveal_count(progress, interior) as u16; + // Top frame row plus the first `shown` item rows, in place. + blit(dst, src, Rect { height: shown + 1, ..bounds }); + // The bottom frame edge, moved up to sit just under the last row. + let src_y = bounds.y + bounds.height - 1; + let dst_y = bounds.y + 1 + shown; + let sentinel = sentinel(); + for x in bounds.left()..bounds.right() { + let cell = &src[(x, src_y)]; + if *cell != sentinel { + dst[(x, dst_y)] = cell.clone(); + } + } + }); +} + +/// F9 bar entrance: columns of the bar row are painted from the left edge +/// (pulldown-menus "Menu bar entrance sweep"). +fn sweep(buf: &mut Buffer, now: Option, stamp: u64, draw: impl FnOnce(&mut Buffer)) { + staged(buf, now, stamp, anim::MENU_BAR_SWEEP_MS, draw, |dst, src, bounds, progress| { + let columns = anim::reveal_count(progress, bounds.width as usize) as u16; + blit(dst, src, Rect { width: columns, ..bounds }); + }); +} + +/// Elapsed time since `stamp` while animations are on, else `None` (render +/// the final frame). Used by the surfaces that animate per-element rather +/// than through a reveal window (the splash entrance). +fn elapsed_since(now: Option, stamp: u64) -> Option { + now.map(|n| n.saturating_sub(stamp)) +} + +/// Post-splash panel entrance: the finished screen is revealed top-to-bottom +/// over `PANEL_WIPE_MS`, unrevealed rows drawn in `screen.backdrop`. +/// `stamp == None` means the splash was skipped, so there is no wipe. +fn wipe( + buf: &mut Buffer, + now: Option, + stamp: Option, + theme: &filecommand_core::theme::Theme, + depth: ColorDepth, + draw: impl FnOnce(&mut Buffer), +) { + let Some(stamp) = stamp else { return draw(buf) }; + let area = *buf.area(); + staged(buf, now, stamp, anim::PANEL_WIPE_MS, draw, |dst, src, _bounds, progress| { + let rows = anim::reveal_count(progress, area.height as usize) as u16; + let backdrop = role_style(theme, Role::ScreenBackdrop, depth); + for y in area.top() + rows..area.bottom() { + dst.set_string(area.x, y, " ".repeat(area.width as usize), backdrop); + } + blit(dst, src, Rect { height: rows, ..area }); + }); +} /// `views::render`'s full result: the terminal-cursor position it already /// returned, plus the [`HitMap`] `input::map_mouse` needs for the *next* @@ -46,9 +280,10 @@ use crate::layout; #[derive(Debug, Clone, Default)] pub struct RenderOutput { /// The real terminal cursor's `(x, y)` position when the current phase - /// wants one shown (only `UiPhase::Editor`, for the caret) — `None` - /// otherwise. See `render`'s own doc comment for why a stale position - /// must not linger. + /// wants one shown (`UiPhase::Editor`, for its caret, or any focused + /// single-line text field per text-field-editing — the command line + /// included) — `None` otherwise. See `render`'s own doc comment for why + /// a stale position must not linger. pub cursor: Option<(u16, u16)>, pub hitmap: HitMap, } @@ -62,14 +297,23 @@ pub struct RenderOutput { /// `ByteSource`, `core::State` never does); it is ignored in every other /// phase. /// +/// The frame is drawn in the layers of the single precedence order +/// (`filecommand_core::input_layer`; reducer-overlay-structure design D2/D8), +/// bottom-up: the active screen — the phase, with the overlay in +/// `State::overlay` above it, or the "terminal too small" placeholder in +/// their place below the floor — then the quit-confirmation dialog, then the +/// startup warning, which stays visible over whatever is showing, the splash +/// and the placeholder included. Each layer's arm computes its geometry once +/// (`DialogLayout`), draws from it, and records its clickable cells into the +/// [`HitMap`], so the hit map can never disagree with what is on screen. +/// /// Returns [`RenderOutput`]: the real terminal cursor's `(x, y)` position -/// when the current phase wants one shown (only `UiPhase::Editor`, for the -/// caret) — `None` otherwise, which the caller must treat as "leave the -/// cursor hidden" since a stale position from a previous frame's phase -/// would otherwise linger — alongside the [`HitMap`] `input::map_mouse` -/// needs for the next frame's mouse events (design D2; this is the same -/// return value the M4 caret position already was, just extended rather -/// than replaced). +/// when the topmost layer wants one shown (`UiPhase::Editor`, for the caret, +/// or a focused single-line text field per text-field-editing) — `None` +/// otherwise, which the caller must treat as "leave the cursor hidden" since +/// a stale position from a previous frame's phase would otherwise linger — +/// alongside the [`HitMap`] `input::map_mouse` needs for the next frame's +/// mouse events (design D2). pub fn render( buf: &mut Buffer, area: Rect, @@ -86,89 +330,66 @@ pub fn render( // above it — styles itself from this single value so the whole screen // repaints consistently as the highlight moves. let render_theme = state.render_theme(); - let cursor = render_phase(buf, area, state, &render_theme, depth, identity_lines, clock_text, viewer_source); - // The quit-confirmation dialog is drawn above whatever the current phase + let theme = &render_theme; + let now = state.anim_now(); + let mut hm = HitMap::default(); + + // Below the 60x16 floor the "terminal too small" placeholder is drawn in + // place of whatever screen is active, which stays intact underneath it + // and is redrawn as-is once the terminal is enlarged (application-shell + // "Resize handling with 60x16 hard floor and placeholder"; resize- + // preserves-session-state design D5). No phase view ever lays out at a + // sub-floor size, no text field owns the cursor, and nothing of the + // hidden screen is recorded in the hit map — only the quit dialog's + // buttons, added below, are clickable there. + let mut cursor = if state.undersized { + placeholder::render_placeholder(buf, area, theme, depth); + None + } else { + render_screen(buf, area, state, theme, depth, identity_lines, clock_text, viewer_source, &mut hm) + }; + + // The quit-confirmation dialog is drawn above whatever the screen // painted — panels, the viewer, an open menu, or any other modal // dialog/overlay — since it lives beside the phase rather than inside // it and can open over any of them (application-shell "Quit request - // keys and confirmation"; design D5). + // keys and confirmation"; design D5). Its iris keys on its own stamp, so + // it opens over a dialog without making that dialog re-animate + // (ui-animations "Modal dialogs open with an iris"). if state.quit_confirm { - quit_dialog::render_quit_dialog(buf, area, &render_theme, depth); + let l = quit_dialog::layout(area); + iris(buf, now, state.quit_opened_at_ms, |b| quit_dialog::draw_quit_dialog(b, theme, depth, &l)); + hm.dialog_buttons.extend(l.buttons); + cursor = None; } - // The startup-warning modal is drawn last, over whatever the current - // phase already painted — it can only ever be raised at the very start - // of a session (currently: a malformed `usermenu.toml`), so it must stay - // visible regardless of which phase (even the splash screen) happens to - // be on screen underneath it (user-menu "Malformed file warns and falls - // back without overwriting"). - if let Some(message) = &state.startup_warning { - startup_warning::render_startup_warning(buf, area, &render_theme, depth, message); + // The startup-warning modal is drawn last, over whatever is already + // painted — it is raised at the very start of a session or after the + // user-menu file was edited, so it must stay visible regardless of which + // phase (even the splash screen, or the placeholder) happens to be on + // screen underneath it (user-menu "Malformed file warns and falls back + // without overwriting"). The mouse is ignored while it is up, so it + // records no hits. + if let Some(Overlay::StartupWarning(message)) = state.overlay() { + iris(buf, now, state.dialog_opened_at_ms, |b| startup_warning::render_startup_warning(b, area, theme, depth, message)); + cursor = None; } - RenderOutput { cursor, hitmap: build_hitmap(area, state) } + // While either of those topmost modals is up, it — not any text field + // underneath — owns the keyboard, so the terminal cursor hides + // (text-field-editing "Field rendering keeps the caret visible": the + // cursor shows only while a field is focused). + RenderOutput { cursor, hitmap: hm } } -/// Builds the frame's [`HitMap`] by asking each relevant view module for the -/// clickable regions it just drew — `panel::hit_test`, `keybar::hit_slots`, -/// `menubar::hit_titles`/`hit_items`, and each open dialog's own -/// `hit_buttons` — mirroring `render_phase`'s own "what's open" dispatch so -/// the two can never disagree about what's actually on screen this frame -/// (mouse-input "Hit-testing stays in the TUI"; design D2). Never draws -/// anything itself. -fn build_hitmap(area: Rect, state: &State) -> HitMap { - let mut hm = HitMap::default(); - match &state.phase { - UiPhase::Panels | UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_) => { - let l = layout::compute((area.width, area.height), state.split_percent); - *hm.panel_mut(PanelSide::Left) = panel::hit_test(l.left, &state.left); - *hm.panel_mut(PanelSide::Right) = panel::hit_test(l.right, &state.right); - hm.cmdline = l.cmdline; - hm.keybar = keybar::hit_slots(l.keybar); - if let Some(menu) = &state.menu { - hm.menu_titles = menubar::hit_titles(area); - hm.menu_items = menubar::hit_items(area, menu); - } - if let Some(dialog) = &state.file_action_menu { - hm.file_action_menu_items = file_action_menu::hit_items(area, dialog); - } - match &state.phase { - UiPhase::FileOpSetup(setup) => { - // Exactly one of these is ever non-empty for a given - // `setup` — `delete_confirm::hit_buttons` no-ops outside - // `DeleteConfirm`, `destination_input::hit_buttons` - // outside a drop-initiated `DestinationInput` — so - // concatenating both is simply "whichever dialog is - // actually showing its buttons this frame." - hm.dialog_buttons = delete_confirm::hit_buttons(area, setup); - hm.dialog_buttons.extend(destination_input::hit_buttons(area, setup)); - } - UiPhase::FileOpRunning { dialog, .. } => { - hm.dialog_buttons = match dialog { - RunningDialog::Progress { .. } => progress_dialog::hit_buttons(area), - RunningDialog::Conflict { rename_input, .. } => conflict_dialog::hit_buttons(area, rename_input), - RunningDialog::Error { info, .. } => error_dialog::hit_buttons(area, info), - }; - } - UiPhase::FileOpSummary(skipped) => hm.dialog_buttons = skipped_summary::hit_buttons(area, skipped), - _ => {} - } - } - // Splash/Placeholder have nothing clickable; Viewer/Editor's wheel- - // only handling is dispatched directly by the event loop from - // `state.phase` (mirroring how it already calls `map_viewer_key`/ - // `map_editor_key` directly rather than through `map_key`), so - // neither needs a hit map at all. - _ => {} - } - // The quit-confirmation dialog is drawn above whatever the phase - // painted (see `render`, above) and is reachable from every context, so - // its buttons are recorded the same way, independent of the match above. - if state.quit_confirm { - hm.dialog_buttons.extend(quit_dialog::hit_buttons(area)); - } - hm -} - -fn render_phase( +// The screen renderer genuinely needs every one of these inputs (buffer, +// area, state, theme, depth, identity, clock, viewer source, hit map); +// bundling them into a struct would only move the same values one level +// down. +/// The active screen at full size: the phase, and — over the panels — the +/// modal overlay in `State::overlay` at its z-position, then the file-op +/// dialog. Records the clickable regions it draws into `hm`. Returns the +/// terminal cursor position the screen wants, if any. +#[allow(clippy::too_many_arguments)] +fn render_screen( buf: &mut Buffer, area: Rect, state: &State, @@ -177,121 +398,212 @@ fn render_phase( identity_lines: &[String; 4], clock_text: &str, viewer_source: Option<&ByteSource>, + hm: &mut HitMap, ) -> Option<(u16, u16)> { match &state.phase { - UiPhase::Splash { .. } => { - splash::render_splash(buf, area, theme, depth, identity_lines); - None - } - UiPhase::Placeholder => { - placeholder::render_placeholder(buf, area, theme, depth); + // Splash has nothing clickable. + UiPhase::Splash { started_at_ms } => { + let elapsed = elapsed_since(state.anim_now(), *started_at_ms); + splash::render_splash(buf, area, theme, depth, identity_lines, elapsed); None } + // Viewer/Editor's wheel-only handling is dispatched directly by the + // event loop from `state.phase` (mirroring how it already calls + // `map_viewer_key`/`map_editor_key` directly rather than through + // `map_key`), so neither needs a hit map at all. UiPhase::Viewer(v) => { viewer::render_viewer(buf, area, v, theme, depth, viewer_source); None } UiPhase::Editor(e) => editor::render_editor(buf, area, e, theme, depth), - UiPhase::Panels - | UiPhase::FileOpSetup(_) - | UiPhase::FileOpRunning { .. } - | UiPhase::FileOpSummary(_) => { + UiPhase::Panels | UiPhase::FileOpSetup(_) | UiPhase::FileOpRunning { .. } | UiPhase::FileOpSummary(_) => { let l = layout::compute((area.width, area.height), state.split_percent); let left_type_ahead = (state.active == PanelSide::Left).then_some(state.quick_search.as_deref()).flatten(); let right_type_ahead = (state.active == PanelSide::Right).then_some(state.quick_search.as_deref()).flatten(); - panel::render_panel( - buf, - l.left, - &state.left, - theme, - depth, - state.active == PanelSide::Left, - identity_lines, - &state.right, - left_type_ahead, - PanelSide::Left, - state.drag.as_ref(), - ); - panel::render_panel( - buf, - l.right, - &state.right, - theme, - depth, - state.active == PanelSide::Right, - identity_lines, - &state.left, - right_type_ahead, - PanelSide::Right, - state.drag.as_ref(), - ); - // Drawn unconditionally, before the F9 overlay below — the menu - // bar (when open) paints over the whole top row including this, - // which is what "hides" it; closing the bar simply stops that - // overwrite from happening on the next frame. The clock itself - // is skipped entirely (never partially drawn) when it would - // collide with the right panel's centered path title - // (responsive-layout "Chrome degradation"). - if clock_fits_without_colliding(l.right, &state.right, state.active == PanelSide::Right, clock_text) { - clock::render_clock(buf, l.right, theme, depth, clock_text); - } - command_line::render_command_line(buf, l.cmdline, theme, depth, &state.prompt(), &state.command_line); - keybar::render_keybar(buf, l.keybar, theme, depth, state.drag.is_some()); + // The base screen (panels, clock, command line, key bar) is the + // surface the post-splash wipe reveals; every overlay below is + // drawn over it directly (ui-animations "Panels wipe in after + // the splash"). + let now = state.anim_now(); + // The terminal cursor shows whenever a single-line text field + // owns the keyboard (text-field-editing "Field rendering keeps + // the caret visible"): the command line by default, or a + // dialog's field while one is open. Each text-field view + // reports its caret position as it draws; the overlays below + // override the command line's, and an overlay with no field + // hides the cursor again. + let mut cmdline_caret = None; + wipe(buf, now, state.panels_revealed_at_ms, theme, depth, |b| { + panel::render_panel( + b, + l.left, + &state.left, + theme, + depth, + state.active == PanelSide::Left, + identity_lines, + &state.right, + left_type_ahead, + PanelSide::Left, + state.drag.as_ref(), + ); + panel::render_panel( + b, + l.right, + &state.right, + theme, + depth, + state.active == PanelSide::Right, + identity_lines, + &state.left, + right_type_ahead, + PanelSide::Right, + state.drag.as_ref(), + ); + // Drawn unconditionally, before the F9 overlay below — the + // menu bar (when open) paints over the whole top row + // including this, which is what "hides" it; closing the bar + // simply stops that overwrite from happening on the next + // frame. The clock itself is skipped entirely (never + // partially drawn) when it would collide with the right + // panel's centered path title (responsive-layout "Chrome + // degradation"). + if clock_fits_without_colliding(l.right, &state.right, state.active == PanelSide::Right, clock_text) { + clock::render_clock(b, l.right, theme, depth, clock_text); + } + cmdline_caret = command_line::render_command_line(b, l.cmdline, theme, depth, &state.prompt(), &state.command_line); + keybar::render_keybar(b, l.keybar, theme, depth, state.drag.is_some()); + }); + // The panel, tab-strip, key-bar, and command-line regions come + // from the same `layout::compute` the panels were just drawn + // with (mouse-input "Hit-testing stays in the TUI"). + *hm.panel_mut(PanelSide::Left) = panel::hit_test(l.left, &state.left); + *hm.panel_mut(PanelSide::Right) = panel::hit_test(l.right, &state.right); + hm.cmdline = l.cmdline; + hm.keybar = keybar::hit_slots(l.keybar); + // The one modal overlay in the slot, drawn at its z-position + // over the base screen and under the file-op dialog (design D8). // The F9 bar overlays the panels' top borders (and the clock) - // rather than reserving a row of its own. - if let Some(menu) = &state.menu { - menubar::render_menu_bar(buf, area, theme, depth, menu); - } - if let Some(dialog) = &state.drive_select { - drive_select::render_drive_select(buf, area, theme, depth, dialog); - } - if let Some(dialog) = &state.fuzzy_jump { - fuzzy_jump::render_fuzzy_jump(buf, area, theme, depth, dialog, &state.dir_history, state.clock_ms); - } - if let Some(dialog) = &state.find_file { - find_file::render_find_file(buf, area, theme, depth, dialog); - } - if let Some(dialog) = &state.user_menu { - user_menu::render_user_menu(buf, area, theme, depth, dialog, &state.user_menu_entries); - } - if let Some(dialog) = &state.theme_picker { - // The dialog itself styles from the previewed `theme`, but - // its active-theme marker stays bound to `state.theme.name` - // — the applied theme — even while a different theme is - // being previewed (design D2; theme-selection "Live theme - // preview while the picker is open"). - theme_picker::render_theme_picker(buf, area, theme, depth, dialog, &state.theme.name); - } - if let Some(dialog) = &state.help { - help::render_help(buf, area, theme, depth, dialog, identity_lines); - } - if let Some(dialog) = &state.file_action_menu { - file_action_menu::render_file_action_menu(buf, area, theme, depth, dialog); + // rather than reserving a row of its own; the pop-up menus + // unfold on their own stamps; every modal dialog opens through + // the same iris, keyed on the single `dialog_opened_at_ms` + // stamp. An overlay with a text field hands the cursor to it; + // any other hides the cursor; the startup warning is drawn by + // `render` above everything else. + let opened = state.dialog_opened_at_ms; + let mut cursor = cmdline_caret; + match state.overlay() { + None => {} + Some(Overlay::Menu(menu)) => { + let ml = menubar::layout(area, menu); + menubar::draw_menu_bar(buf, theme, depth, menu, now, &ml); + hm.menu_titles = ml.titles; + hm.menu_items = ml.pulldown.map(|p| p.rows).unwrap_or_default(); + cursor = None; + } + Some(Overlay::DriveSelect(dialog)) => { + iris(buf, now, opened, |b| drive_select::render_drive_select(b, area, theme, depth, dialog)); + cursor = None; + } + Some(Overlay::FuzzyJump(dialog)) => { + // Ranked with the epoch clock, as the reducer's Enter and + // cursor handlers rank, so the drawn order is the + // acted-on order (history-persistence-hardening design + // D1). + let mut fuzzy_caret = None; + iris(buf, now, opened, |b| { + fuzzy_caret = fuzzy_jump::render_fuzzy_jump(b, area, theme, depth, dialog, &state.dir_history, state.epoch_ms) + }); + cursor = fuzzy_caret; + } + Some(Overlay::FindFile(dialog)) => { + let mut find_caret = None; + iris(buf, now, opened, |b| find_caret = find_file::render_find_file(b, area, theme, depth, dialog)); + cursor = find_caret; + } + Some(Overlay::UserMenu(dialog)) => { + user_menu::render_user_menu(buf, area, theme, depth, dialog, &state.user_menu_entries, now); + cursor = None; + } + Some(Overlay::HistoryDialog(dialog)) => { + history_dialog::render_history_dialog(buf, area, theme, depth, dialog, &state.history, now); + cursor = None; + } + Some(Overlay::ThemePicker(dialog)) => { + // The dialog itself styles from the previewed `theme`, + // but its active-theme marker stays bound to + // `state.theme.name` — the applied theme — even while a + // different theme is being previewed (design D2; + // theme-selection "Live theme preview while the picker + // is open"). + iris(buf, now, opened, |b| theme_picker::render_theme_picker(b, area, theme, depth, dialog, &state.theme.name)); + cursor = None; + } + Some(Overlay::Help(dialog)) => { + iris(buf, now, opened, |b| help::render_help(b, area, theme, depth, dialog, identity_lines)); + cursor = None; + } + Some(Overlay::FileActionMenu(dialog)) => { + let fl = file_action_menu::layout(area, dialog); + unfold(buf, now, dialog.opened_at_ms, |b| file_action_menu::draw_file_action_menu(b, theme, depth, dialog, now, &fl)); + hm.file_action_menu_items = fl.rows; + cursor = None; + } + Some(Overlay::StartupWarning(_)) => cursor = None, } + // The file-operation dialog, above the panels and any overlay. + // Each arm computes its layout once, draws from it, and records + // its buttons (design D8). match &state.phase { UiPhase::FileOpSetup(setup) => { - destination_input::render_destination_input(buf, area, theme, depth, setup); - delete_confirm::render_delete_confirm(buf, area, theme, depth, setup); + // Exactly one of these two is ever non-empty for a given + // `setup`: `delete_confirm::layout` is empty outside + // `DeleteConfirm`, `destination_input::layout` outside + // the other two variants, and only a drop-initiated + // `DestinationInput` carries buttons at all. + let dl = delete_confirm::layout(area, setup); + let il = destination_input::layout(area, setup); + let mut file_op_caret = None; + iris(buf, now, opened, |b| { + file_op_caret = destination_input::draw_destination_input(b, theme, depth, setup, &il); + delete_confirm::draw_delete_confirm(b, theme, depth, &dl); + }); + hm.dialog_buttons = dl.buttons; + hm.dialog_buttons.extend(il.buttons); + cursor = file_op_caret; } UiPhase::FileOpRunning { dialog, .. } => match dialog { RunningDialog::Progress { kind, progress } => { - progress_dialog::render_progress(buf, area, theme, depth, *kind, progress); + let pl = progress_dialog::layout(area, *kind, progress); + iris(buf, now, opened, |b| progress_dialog::draw_progress(b, theme, depth, progress, &pl)); + hm.dialog_buttons = pl.buttons; + cursor = None; } RunningDialog::Conflict { info, rename_input, .. } => { - conflict_dialog::render_conflict(buf, area, theme, depth, info, rename_input); + let cl = conflict_dialog::layout(area, info, rename_input); + let mut file_op_caret = None; + iris(buf, now, opened, |b| file_op_caret = conflict_dialog::draw_conflict(b, theme, depth, rename_input, &cl)); + hm.dialog_buttons = cl.buttons; + cursor = file_op_caret; } RunningDialog::Error { info, .. } => { - error_dialog::render_error(buf, area, theme, depth, info); + let el = error_dialog::layout(area, info); + iris(buf, now, opened, |b| error_dialog::draw_error(b, theme, depth, &el)); + hm.dialog_buttons = el.buttons; + cursor = None; } }, UiPhase::FileOpSummary(skipped) => { - skipped_summary::render_skipped_summary(buf, area, theme, depth, skipped); + let sl = skipped_summary::layout(area, skipped); + iris(buf, now, opened, |b| skipped_summary::draw_skipped_summary(b, theme, depth, &sl)); + hm.dialog_buttons = sl.buttons; + cursor = None; } _ => {} } - None + cursor } } } @@ -317,3 +629,6 @@ fn clock_fits_without_colliding(right_area: Rect, right_panel: &PanelState, acti let clock_end = right_area.x + right_area.width; !(title_x < clock_end && clock_start < title_end) } + +#[cfg(test)] +mod tests; diff --git a/crates/filecommand-tui/src/views/panel.rs b/crates/filecommand-tui/src/views/panel.rs index 3610f4f..6f36c94 100644 --- a/crates/filecommand-tui/src/views/panel.rs +++ b/crates/filecommand-tui/src/views/panel.rs @@ -36,6 +36,14 @@ const SIZE_COL_W: usize = 9; const DATE_COL_W: usize = 8; const TIME_COL_W: usize = 5; +/// The rendered text widths inside those reserved cells — exactly the +/// content's own widths (`▶SUB-DIR◀`/`▶UP--DIR◀` are 9 cells, `MM-DD-YY` +/// is 8, `HH:MM` is 5), so no Full-mode Size/Date/Time value ever renders +/// truncated (full-mode-column-widths; design D1). +const SIZE_TEXT_W: usize = 9; +const DATE_TEXT_W: usize = 8; +const TIME_TEXT_W: usize = 5; + /// The Name column never shrinks below this many display cells in Full /// mode — the anchor `MIN_NAME_W` for the column ladder (design D3). const MIN_NAME_W: usize = 12; @@ -58,10 +66,11 @@ impl ColumnSet { /// Display cells consumed by the columns after Name (including the /// separator pipe and inter-column spaces), or 0 when only Name renders /// — in which case Name spans the full interior with no pipe or slack. - /// `SIZE_COL_W`/`DATE_COL_W`/`TIME_COL_W` are unchanged from the fixed - /// 80x24 layout, so the reserved width (and therefore the rendered - /// output) at the nominal size is byte-identical to before this change - /// (design D3's anchor). + /// The reserved widths (25 / 20 / 12 / 0) are unchanged from the fixed + /// 80x24 layout, so `choose_columns`, the Name-column ladder, and every + /// breakpoint are untouched by full-mode-column-widths — only + /// `format_full_row`'s text widths grew to fill the reservation exactly + /// (design D1). fn reserved_w(self) -> usize { if !self.size { return 0; @@ -106,25 +115,44 @@ fn choose_columns(interior_w: usize, marker_w: usize) -> ColumnSet { /// Assembles a Full-mode row (or header row) string from its four fields, /// appending only the blocks `cols` selects — the same function backs both /// the header row and every entry row, so "the header row shows exactly the -/// columns currently rendered" holds by construction. +/// columns currently rendered" holds by construction. With all four columns +/// present the row is exactly `name_w + 25` cells — the full interior — and +/// every Size/Date/Time value renders at its content width, untruncated, +/// with no slack left at the row's right edge; for the reduced column sets +/// the row comes out shorter than the reservation and the difference stays +/// trailing padding, as before (panel-navigation "Full display mode +/// layout"; design D1). `size` arrives already right-aligned by the caller +/// for entry rows, so header labels keep their left alignment here (design +/// D2). fn format_full_row(name_w: usize, cols: ColumnSet, name: &str, size: &str, date: &str, time: &str) -> String { let mut s = pad_to_width(name, name_w); if cols.size { s.push('\u{2502}'); - s.push(' '); - s.push_str(&pad_to_width(size, SIZE_COL_W - 2)); + s.push_str(&pad_to_width(size, SIZE_TEXT_W)); } if cols.date { s.push(' '); - s.push_str(&pad_to_width(date, DATE_COL_W - 1)); + s.push_str(&pad_to_width(date, DATE_TEXT_W)); } if cols.time { s.push(' '); - s.push_str(&pad_to_width(time, TIME_COL_W - 1)); + s.push_str(&pad_to_width(time, TIME_TEXT_W)); } s } +/// Left-pads `s` to exactly `width` display columns so numeric values +/// right-align in their column (the NC convention for Size; design D2). +/// Values already at or over `width` pass through untouched — +/// `▶SUB-DIR◀`/`▶UP--DIR◀` fill the Size column exactly. +fn pad_left_to_width(s: &str, width: usize) -> String { + let w = display_width(s); + if w >= width { + return s.to_string(); + } + format!("{}{}", " ".repeat(width - w), s) +} + /// A column header label, with the sort arrow appended when this column is /// the active sort key. `Unsorted` marks no column, so no header shows one. fn header_label(base: &str, column: SortColumn, panel: &PanelState) -> String { @@ -260,7 +288,9 @@ fn drag_feedback_for(panel: &PanelState, side: PanelSide, drag: &DragState) -> O }; DragFeedback::Valid { row: Some(RowHighlight::Row(name.clone())), dir } } - Some(DropTarget::TreeNode { side: s, path }) if *s == side => DragFeedback::Valid { row: Some(RowHighlight::TreeNode(path.clone())), dir: path.clone() }, + Some(DropTarget::TreeNode { side: s, path }) if *s == side => { + DragFeedback::Valid { row: Some(RowHighlight::TreeNode(path.clone())), dir: path.clone() } + } Some(DropTarget::Tab { side: s, index }) if *s == side => { let dir = panel.tab_dirs().get(*index).cloned()?; DragFeedback::Valid { row: Some(RowHighlight::Tab(*index)), dir } @@ -354,10 +384,12 @@ pub fn hit_test(area: Rect, panel: &PanelState) -> crate::hitmap::PanelHits { DisplayMode::Full => { let rows_start = body_y0 + 1; let rows_h = body_h.saturating_sub(1); - let visible = panel.visible_indices(); - for row in 0..rows_h { - let y = rows_start + row; - let Some(entry) = visible.get(panel.scroll_offset + row as usize).and_then(|&i| panel.entries.get(i)) else { continue }; + // Only the rows on screen, through the cached visible window — + // no per-frame allocation of every visible index (streaming- + // listing-performance design D5). + for (row, i) in panel.visible_window(panel.scroll_offset, rows_h as usize).enumerate() { + let y = rows_start + row as u16; + let Some(entry) = panel.entries.get(i) else { continue }; let rect = Rect { x: x0 + 1, y, width: right_x.saturating_sub(x0 + 1), height: 1 }; hits.rows.push((rect, entry.name.clone())); } @@ -429,6 +461,8 @@ pub fn render_panel( let header_style = role_style(theme, Role::PanelHeader, depth); let file_style = role_style(theme, Role::PanelFile, depth); let dir_style = role_style(theme, Role::PanelDirectory, depth); + let hidden_style = role_style(theme, Role::PanelHidden, depth); + let selected_style = role_style(theme, Role::PanelSelected, depth); let cursor_style = role_style(theme, Role::PanelCursor, depth); let button_focused_style = role_style(theme, Role::ButtonFocused, depth); // A clipboard action's feedback (clipboard-export "Clipboard feedback") @@ -498,7 +532,14 @@ pub fn render_panel( draw_side_borders(buf, x0, body_y0, right_x, body_h, frame_style); let inner = Rect { x: x0 + 1, y: body_y0, width: area.width - 2, height: body_h }; info_panel::render_info(buf, inner, theme, depth, &panel.info, &panel.cwd, identity_lines); - render_bottom_border(buf, area, panel, frame_style, ministatus_style, drag_status.clone().or_else(|| clipboard_override.clone()).or_else(|| input_override.clone())); + render_bottom_border( + buf, + area, + panel, + frame_style, + ministatus_style, + drag_status.clone().or_else(|| clipboard_override.clone()).or_else(|| input_override.clone()), + ); return; } @@ -507,7 +548,14 @@ pub fn render_panel( if panel.display_mode == DisplayMode::Brief { draw_side_borders(buf, x0, body_y0, right_x, body_h, frame_style); render_brief_body(buf, x0, body_y0, right_x, w, body_h, panel, theme, depth, active, &row_highlight, button_focused_style); - render_bottom_border(buf, area, panel, frame_style, ministatus_style, drag_status.clone().or_else(|| clipboard_override.clone()).or_else(|| input_override.clone())); + render_bottom_border( + buf, + area, + panel, + frame_style, + ministatus_style, + drag_status.clone().or_else(|| clipboard_override.clone()).or_else(|| input_override.clone()), + ); return; } @@ -515,7 +563,8 @@ pub fn render_panel( // driving the opposite panel as the cursor moves (additional-panel- // modes "Tree display mode structure and rendering"). if panel.display_mode == DisplayMode::Tree { - let mini_status = render_tree_body(buf, x0, body_y0, right_x, w, body_h, panel, theme, depth, active, frame_style, &row_highlight, button_focused_style); + let mini_status = + render_tree_body(buf, x0, body_y0, right_x, w, body_h, panel, theme, depth, active, frame_style, &row_highlight, button_focused_style); render_bottom_border( buf, area, @@ -573,41 +622,56 @@ pub fn render_panel( buf.set_string(right_x, body_y0, "\u{2551}", frame_style); // Entry rows. When a quick filter is active the body is narrowed to - // `visible_indices()` (`..` plus name-matching entries) rather than the - // full `entries` list, matching `move_cursor`'s cursor-restriction and - // the spec's "the panel body is narrowed to entries whose displayed - // name contains the pattern" requirement (quick-filter "Substring - // narrowing as the pattern is typed"). - let visible = panel.visible_indices(); + // the panel's cached visible list (`..` plus name-matching entries) + // rather than the full `entries` list, matching `move_cursor`'s + // cursor-restriction and the spec's "the panel body is narrowed to + // entries whose displayed name contains the pattern" requirement + // (quick-filter "Substring narrowing as the pattern is typed"). Only + // the window of rows on screen is walked, so a frame costs the rows + // shown rather than the directory's size (streaming-listing-performance + // design D5). let rows_start = body_y0 + 1; let rows_h = body_h.saturating_sub(1); // header row let scrollbar_style = role_style(theme, Role::PanelScrollbar, depth); - let thumb = scrollbar_thumb(rows_h as usize, rows_h as usize, visible.len(), panel.scroll_offset); + let thumb = scrollbar_thumb(rows_h as usize, rows_h as usize, panel.visible_len(), panel.scroll_offset); + let mut window = panel.visible_window(panel.scroll_offset, rows_h as usize); for row in 0..rows_h { let y = rows_start + row; buf.set_string(x0, y, "\u{2551}", frame_style); draw_scrollbar_cell(buf, right_x, y, row as usize, thumb, frame_style, scrollbar_style); - if let Some((entry, is_selected)) = visible.get(panel.scroll_offset + row as usize).and_then(|&i| panel.entries.get(i).map(|e| (e, active && i == panel.cursor))) { + if let Some((entry, is_cursor)) = window.next().and_then(|i| panel.entries.get(i).map(|e| (e, active && i == panel.cursor))) { // The drag's target row (mouse-panel-drag "Drag feedback") // outranks the ordinary selected/dir/file styling — but never a // *source* row, since a dragged item's own directory/descendant // is already rejected as a target by core (design D7), so this // can never coincide with one of the rows the drag is dragging. let is_drag_target = is_target_row(&row_highlight, &entry.name); + // Entry style precedence (panel-navigation "Entry row + // rendering"; hidden-entry-styling design D3): drag-target + // highlight, then the cursor bar, then Ins-selection, then the + // hidden dimming (which covers hidden directories too), then the + // ordinary directory/file styles. let style = if is_drag_target { button_focused_style - } else if is_selected { + } else if is_cursor { cursor_style + } else if entry.kind != EntryKind::ParentDir && panel.selected.contains(&entry.name) { + selected_style + } else if entry.hidden { + hidden_style } else if entry.is_dir_like() { dir_style } else { file_style }; let name = display_name_lossy(entry); + // Numeric sizes right-align in the 9-cell Size column (the NC + // convention; design D2); the `..`/directory markers are + // already 9 cells wide and fill it. let size_col = match entry.kind { EntryKind::ParentDir => "\u{25B6}UP--DIR\u{25C4}".to_string(), EntryKind::Directory => "\u{25B6}SUB-DIR\u{25C4}".to_string(), - EntryKind::File => format_size(entry.size), + EntryKind::File => pad_left_to_width(&format_size(entry.size), SIZE_TEXT_W), }; let (date_col, time_col) = match entry.modified { Some(dt) => (format_date(dt), format_time(dt)), @@ -618,7 +682,7 @@ pub fn render_panel( let marker = git_marker(panel, entry); let marker_style = if is_drag_target { button_focused_style - } else if is_selected { + } else if is_cursor { cursor_style } else if let Some((_, role)) = marker { role_style(theme, role, depth) @@ -651,8 +715,10 @@ fn draw_side_borders(buf: &mut Buffer, x0: u16, body_y0: u16, right_x: u16, body /// Brief mode's body: entry names only, laid out across three columns in /// column-major order, using `unicode-width` for alignment and preserving -/// the standard directory/file/selected/cursor styling and the `..` -/// up-dir marker (additional-panel-modes "Brief display mode"). +/// the standard directory/file/hidden/selected/cursor styling and the `..` +/// up-dir marker, with the same style precedence as Full mode +/// (additional-panel-modes "Brief display mode"; hidden-entry-styling +/// design D3). #[allow(clippy::too_many_arguments)] fn render_brief_body( buf: &mut Buffer, @@ -670,6 +736,7 @@ fn render_brief_body( ) { let dir_style = role_style(theme, Role::PanelDirectory, depth); let file_style = role_style(theme, Role::PanelFile, depth); + let hidden_style = role_style(theme, Role::PanelHidden, depth); let cursor_style = role_style(theme, Role::PanelCursor, depth); let selected_style = role_style(theme, Role::PanelSelected, depth); let scrollbar_style = role_style(theme, Role::PanelScrollbar, depth); @@ -688,15 +755,16 @@ fn render_brief_body( *last += inner_w - base_w * n_cols; } let rows_h = body_h as usize; - // Narrow to the active quick filter's `visible_indices()` (`..` plus + // Narrow to the active quick filter's cached visible list (`..` plus // name-matching entries), exactly like Full mode above (quick-filter - // "Substring narrowing as the pattern is typed"). - let visible = panel.visible_indices(); + // "Substring narrowing as the pattern is typed"); each cell is one O(1) + // `visible_at` lookup, column-major (streaming-listing-performance + // design D5). // The overflow-only scrollbar reflects the cursor window's linear // position through the whole visible list, not one column's share of it // — window_capacity is every position the `n_cols`-wide window shows at // once (additional-panel-modes "Brief mode column scrolling"). - let thumb = scrollbar_thumb(rows_h, n_cols * rows_h, visible.len(), panel.scroll_offset); + let thumb = scrollbar_thumb(rows_h, n_cols * rows_h, panel.visible_len(), panel.scroll_offset); for row in 0..rows_h { let y = body_y0 + row as u16; @@ -704,14 +772,19 @@ fn render_brief_body( let mut x = x0 + 1; for (c, &cw) in col_widths.iter().enumerate() { let pos = panel.scroll_offset + c * rows_h + row; - if let Some((entry, idx)) = visible.get(pos).and_then(|&i| panel.entries.get(i).map(|e| (e, i))) { + if let Some((entry, idx)) = panel.visible_at(pos).and_then(|i| panel.entries.get(i).map(|e| (e, i))) { let is_cursor = active && idx == panel.cursor; + // Same precedence as Full mode (hidden-entry-styling design + // D3): drag target, cursor bar, Ins-selection, hidden + // dimming, then directory/file. let style = if is_target_row(row_highlight, &entry.name) { button_focused_style } else if is_cursor { cursor_style } else if entry.kind != EntryKind::ParentDir && panel.selected.contains(&entry.name) { selected_style + } else if entry.hidden { + hidden_style } else if entry.is_dir_like() { dir_style } else { @@ -779,7 +852,13 @@ fn render_tree_body( Some(node) if node.depth == 0 => { let is_target = is_target_tree_node(row_highlight, &node.path); let text = node.path.display().to_string(); - let style = if is_target { button_focused_style } else if is_cursor { cursor_style } else { dir_style }; + let style = if is_target { + button_focused_style + } else if is_cursor { + cursor_style + } else { + dir_style + }; buf.set_string(x0 + 1, y, pad_to_width(&text, inner_w), style); } Some(node) => { @@ -810,7 +889,16 @@ fn render_tree_body( /// directory indicator"; design D7). Returns the mini-status override — the /// previewed file's name and size. #[allow(clippy::too_many_arguments)] -fn render_quick_view_body(buf: &mut Buffer, x0: u16, body_y0: u16, w: usize, body_h: u16, opposite: &PanelState, theme: &Theme, depth: ColorDepth) -> Option { +fn render_quick_view_body( + buf: &mut Buffer, + x0: u16, + body_y0: u16, + w: usize, + body_h: u16, + opposite: &PanelState, + theme: &Theme, + depth: ColorDepth, +) -> Option { let text_style = role_style(theme, Role::ViewerText, depth); let dir_style = role_style(theme, Role::PanelDirectory, depth); let inner_w = w.saturating_sub(2); @@ -855,18 +943,23 @@ fn render_bottom_border( // `bracketed` below), so the content itself has 2 fewer cells to work // with than the interior. let status_budget = inner_w.saturating_sub(2); - // An inline error (a failed listing, a failed F3/F4 dispatch — §7 "panel - // shows an inline error state") takes over the mini-status line until - // the next successful operation clears it (`begin_new_listing`). The - // multi-select summary takes precedence over the per-entry status while - // one or more entries are selected (selection "Selection count and size - // summary"); both drop fields/truncate with `…` in ladder order as the - // panel narrows (responsive-layout "Chrome degradation"). + // A pending note — `Checking …` while a command-line target probe + // runs, `Opening …` while an F3/F4 open runs — ranks below the + // override and above the error, in the normal ministatus style + // (offload-blocking-io design D3). An inline error (a failed listing, a + // failed F3/F4 dispatch — §7 "panel shows an inline error state") takes + // over the mini-status line until the next successful operation clears + // it (`begin_new_listing`). The multi-select summary takes precedence + // over the per-entry status while one or more entries are selected + // (selection "Selection count and size summary"); both drop fields/ + // truncate with `…` in ladder order as the panel narrows + // (responsive-layout "Chrome degradation"). let status_text = match status_override { Some(text) => text, - None => match &panel.last_error { - Some(message) => message.clone(), - None => match panel.progress { + None => match (&panel.pending_note, &panel.last_error) { + (Some(note), _) => note.clone(), + (None, Some(message)) => message.clone(), + (None, None) => match panel.progress { ListingProgress::Streaming { count } => reading_status(count), ListingProgress::Complete { .. } => match panel.selection_status() { Some(summary) => truncate_with_ellipsis(&summary, status_budget), @@ -881,12 +974,7 @@ fn render_bottom_border( let fill_total = inner_w.saturating_sub(display_width(&bracketed)); let left_fill = fill_total / 2; let right_fill = fill_total - left_fill; - let bottom = format!( - "\u{255A}{}{}{}\u{255D}", - "\u{2550}".repeat(left_fill), - bracketed, - "\u{2550}".repeat(right_fill), - ); + let bottom = format!("\u{255A}{}{}{}\u{255D}", "\u{2550}".repeat(left_fill), bracketed, "\u{2550}".repeat(right_fill),); buf.set_string(area.x, bottom_y, &bottom, frame_style); if !bracketed.is_empty() { buf.set_string(area.x + 1 + left_fill as u16, bottom_y, &bracketed, ministatus_style); @@ -972,7 +1060,7 @@ mod tests { use super::*; use filecommand_core::fs_ops::SourceItem; - use filecommand_core::listing::Entry; + use filecommand_core::listing::{DateTime, Entry}; use filecommand_core::panel::TreeState; // ----------------------------------------------------------------- @@ -1001,8 +1089,8 @@ mod tests { let mut left = PanelState::new(PathBuf::from(r"C:\left")); left.entries = vec![ Entry::parent_dir(), - Entry { name: OsString::from("src"), kind: EntryKind::Directory, size: 0, modified: None }, - Entry { name: OsString::from("a.txt"), kind: EntryKind::File, size: 10, modified: None }, + Entry::new(OsString::from("src"), EntryKind::Directory, 0, None, false, false), + Entry::new(OsString::from("a.txt"), EntryKind::File, 10, None, false, false), ]; left.progress = ListingProgress::Complete { count: left.entries.len() }; let mut right = PanelState::new(PathBuf::from(r"C:\right")); @@ -1052,7 +1140,11 @@ mod tests { render_panel(&mut with_drag, area, &left, &theme, ColorDepth::Ansi16, true, &fixed_identity(), &right, None, PanelSide::Left, Some(&drag)); let mut without_drag = Buffer::empty(area); render_panel(&mut without_drag, area, &left, &theme, ColorDepth::Ansi16, true, &fixed_identity(), &right, None, PanelSide::Left, None); - assert_eq!(buffer_to_text(&with_drag, area), buffer_to_text(&without_drag, area), "source rows/frame must render unchanged while a drag targets the other panel"); + assert_eq!( + buffer_to_text(&with_drag, area), + buffer_to_text(&without_drag, area), + "source rows/frame must render unchanged while a drag targets the other panel" + ); } #[test] @@ -1176,7 +1268,9 @@ mod tests { // Narrow just enough that Name+Size+Date+Time would leave Name // under 12, but Name+Size+Date still fits at >= 12. let full_name_w = |w: usize| w.saturating_sub(ColumnSet::FULL.reserved_w()); - let w = (0usize..40).find(|&w| full_name_w(w) < MIN_NAME_W && w.saturating_sub(ColumnSet { time: false, ..ColumnSet::FULL }.reserved_w()) >= MIN_NAME_W).unwrap(); + let w = (0usize..40) + .find(|&w| full_name_w(w) < MIN_NAME_W && w.saturating_sub(ColumnSet { time: false, ..ColumnSet::FULL }.reserved_w()) >= MIN_NAME_W) + .unwrap(); let cols = choose_columns(w, 0); assert_eq!(cols, ColumnSet { size: true, date: true, time: false }); } @@ -1185,7 +1279,10 @@ mod tests { fn choose_columns_drops_date_next() { let two_col = ColumnSet { size: true, date: false, time: false }; let w = (0usize..40) - .find(|&w| w.saturating_sub(ColumnSet { time: false, ..ColumnSet::FULL }.reserved_w()) < MIN_NAME_W && w.saturating_sub(two_col.reserved_w()) >= MIN_NAME_W) + .find(|&w| { + w.saturating_sub(ColumnSet { time: false, ..ColumnSet::FULL }.reserved_w()) < MIN_NAME_W + && w.saturating_sub(two_col.reserved_w()) >= MIN_NAME_W + }) .unwrap(); let cols = choose_columns(w, 0); assert_eq!(cols, two_col); @@ -1210,6 +1307,156 @@ mod tests { assert!(with_marker != ColumnSet::FULL || without_marker == ColumnSet::FULL); } + // ----------------------------------------------------------------- + // full-mode-column-widths: untruncated values, right-aligned sizes + // (tasks.md 2.1, 2.2). + // ----------------------------------------------------------------- + + /// A `C:\left` panel holding `entries`, rendered Full-mode and active at + /// 40x10 (the 80x24 default-split panel width: a 38-cell interior), + /// returned as plain-text rows. + fn render_left_panel_rows(entries: Vec) -> String { + let mut left = PanelState::new(PathBuf::from(r"C:\left")); + left.progress = ListingProgress::Complete { count: entries.len() }; + left.entries = entries; + let right = PanelState::new(PathBuf::from(r"C:\right")); + let theme = Theme::classic(); + let area = Rect { x: 0, y: 0, width: 40, height: 10 }; + let mut buf = Buffer::empty(area); + render_panel(&mut buf, area, &left, &theme, ColorDepth::Ansi16, true, &fixed_identity(), &right, None, PanelSide::Left, None); + buffer_to_text(&buf, area) + } + + #[test] + fn full_mode_row_renders_size_date_and_time_untruncated_and_fills_the_interior() { + // panel-navigation "Values render untruncated at the nominal size": + // a directory modified 2026-06-27 23:30 renders `▶SUB-DIR◀`, + // `06-27-26`, and `23:30` complete, with no blank cells between the + // Time value and the right border. + let dt = DateTime { year: 2026, month: 6, day: 27, hour: 23, minute: 30 }; + let text = + render_left_panel_rows(vec![Entry::parent_dir(), Entry::new(OsString::from("docs"), EntryKind::Directory, 0, Some(dt), false, false)]); + // Rows: 0 = top border, 1 = header, 2 = `..`, 3 = `docs`. + let header_interior: String = text.lines().nth(1).unwrap().chars().skip(1).take(38).collect(); + assert!(header_interior.contains("\u{2502}Size"), "header labels stay left-aligned after the separator: `{header_interior}`"); + + let up_interior: String = text.lines().nth(2).unwrap().chars().skip(1).take(38).collect(); + assert!(up_interior.contains("\u{25B6}UP--DIR\u{25C4}"), "`▶UP--DIR◀` renders complete: `{up_interior}`"); + + let row = text.lines().nth(3).unwrap(); + assert_eq!(row.chars().count(), 40, "the row spans the panel frame: `{row}`"); + let interior: String = row.chars().skip(1).take(38).collect(); + assert!(interior.contains("\u{25B6}SUB-DIR\u{25C4}"), "`▶SUB-DIR◀` renders complete: `{interior}`"); + assert!(interior.contains("06-27-26"), "the date renders in full: `{interior}`"); + assert!(interior.ends_with("23:30"), "the time renders in full and the row fills the interior exactly: `{interior}`"); + } + + #[test] + fn full_mode_file_sizes_are_right_aligned_in_the_size_column() { + // panel-navigation "Sizes are right-aligned": `87` and `35K` share + // their last-digit cell column. + let text = render_left_panel_rows(vec![ + Entry::parent_dir(), + Entry::new(OsString::from("a.txt"), EntryKind::File, 87, None, false, false), + Entry::new(OsString::from("b.txt"), EntryKind::File, 35 * 1024, None, false, false), + ]); + // Interior layout: 13-cell Name, `│`, then the 9-cell Size column — + // cells 15..24 of the 40-cell row. + let size_cells = |row: &str| row.chars().skip(15).take(9).collect::(); + let a_row = text.lines().nth(3).unwrap(); + let b_row = text.lines().nth(4).unwrap(); + assert_eq!(size_cells(a_row), " 87", "`87` right-aligns: `{a_row}`"); + assert_eq!(size_cells(b_row), " 35K", "`35K` right-aligns: `{b_row}`"); + } + + #[test] + fn pad_left_to_width_right_aligns_and_leaves_full_width_values_untouched() { + assert_eq!(pad_left_to_width("87", SIZE_TEXT_W), " 87"); + assert_eq!(pad_left_to_width("35K", SIZE_TEXT_W), " 35K"); + assert_eq!(pad_left_to_width("\u{25B6}SUB-DIR\u{25C4}", SIZE_TEXT_W), "\u{25B6}SUB-DIR\u{25C4}"); + } + + // ----------------------------------------------------------------- + // hidden-entry-styling: hidden dimming and its precedence (tasks.md + // 3.1). + // ----------------------------------------------------------------- + + /// The hidden-styling fixture: `..`, a hidden directory (`.git`, under + /// the cursor), a plain directory, a hidden file, a *selected* hidden + /// file, and a plain file — one row per precedence tier of design D3. + fn hidden_fixture_panel() -> PanelState { + let mut left = PanelState::new(PathBuf::from(r"C:\left")); + left.entries = vec![ + Entry::parent_dir(), + Entry::new(OsString::from(".git"), EntryKind::Directory, 0, None, true, false), + Entry::new(OsString::from("docs"), EntryKind::Directory, 0, None, false, false), + Entry::new(OsString::from("desktop.ini"), EntryKind::File, 0, None, true, false), + Entry::new(OsString::from("secret.txt"), EntryKind::File, 0, None, true, false), + Entry::new(OsString::from("readme.txt"), EntryKind::File, 0, None, false, false), + ]; + left.progress = ListingProgress::Complete { count: left.entries.len() }; + left.cursor = 1; // on the hidden `.git` directory + left.selected.insert(OsString::from("secret.txt")); + left + } + + /// Renders `panel` active at 40x10 and returns the raw buffer (for style + /// assertions) plus the plain-text grid. + fn render_fixture(panel: &PanelState) -> (Buffer, String) { + let right = PanelState::new(PathBuf::from(r"C:\right")); + let theme = Theme::classic(); + let area = Rect { x: 0, y: 0, width: 40, height: 10 }; + let mut buf = Buffer::empty(area); + render_panel(&mut buf, area, panel, &theme, ColorDepth::Ansi16, true, &fixed_identity(), &right, None, PanelSide::Left, None); + let text = buffer_to_text(&buf, area); + (buf, text) + } + + #[test] + fn full_mode_styles_hidden_entries_with_cursor_and_selection_precedence() { + let theme = Theme::classic(); + let style_of = |role: Role| role_style(&theme, role, ColorDepth::Ansi16); + let (buf, text) = render_fixture(&hidden_fixture_panel()); + + // Rows: 0 = border, 1 = header, then `..`, `.git`, `docs`, + // `desktop.ini`, `secret.txt`, `readme.txt`. + let cell = |y: u16| buf[(1, y)].style(); + assert_eq!(cell(2).fg, style_of(Role::PanelDirectory).fg, "`..` keeps the directory style"); + assert_eq!(cell(3).fg, style_of(Role::PanelCursor).fg, "cursor bar wins over hidden (scenario \"Cursor bar wins\")"); + assert_eq!(cell(3).bg, style_of(Role::PanelCursor).bg, "the cursor bar spans the hidden row"); + assert_eq!(cell(4).fg, style_of(Role::PanelDirectory).fg, "a plain directory is unchanged"); + assert_eq!(cell(5).fg, style_of(Role::PanelHidden).fg, "a hidden file renders dimmed (scenario \"Hidden entries render dimmed\")"); + assert_eq!(cell(5).bg, style_of(Role::PanelHidden).bg); + assert_eq!(cell(6).fg, style_of(Role::PanelSelected).fg, "selected beats hidden (scenario \"Selected hidden entry shows selected style\")"); + assert_eq!(cell(6).bg, style_of(Role::PanelSelected).bg); + assert_eq!(cell(7).fg, style_of(Role::PanelFile).fg, "a plain file is unchanged"); + + // The hidden directory keeps its directory marker in the Size + // column even though its name is dimmed. + let git_row = text.lines().nth(3).unwrap(); + assert!(git_row.contains("\u{25B6}SUB-DIR\u{25C4}"), "`.git` still shows ▶SUB-DIR◀: `{git_row}`"); + } + + #[test] + fn brief_mode_dims_hidden_entries_with_the_same_precedence() { + // additional-panel-modes "Brief mode dims hidden entries". + let mut panel = hidden_fixture_panel(); + panel.display_mode = DisplayMode::Brief; + let theme = Theme::classic(); + let style_of = |role: Role| role_style(&theme, role, ColorDepth::Ansi16); + let (buf, _) = render_fixture(&panel); + + // Six entries fit in the first of the three 12/12/14 columns + // (rows_h = 8), at rows 1..6, x = 1. + let cell = |y: u16| buf[(1, y)].style(); + assert_eq!(cell(1).fg, style_of(Role::PanelDirectory).fg, "`..` keeps the directory style"); + assert_eq!(cell(2).fg, style_of(Role::PanelCursor).fg, "cursor bar wins (.git)"); + assert_eq!(cell(3).fg, style_of(Role::PanelDirectory).fg, "docs"); + assert_eq!(cell(4).fg, style_of(Role::PanelHidden).fg, "desktop.ini dimmed"); + assert_eq!(cell(5).fg, style_of(Role::PanelSelected).fg, "secret.txt: selected beats hidden"); + assert_eq!(cell(6).fg, style_of(Role::PanelFile).fg, "readme.txt plain"); + } + #[test] fn fit_mini_status_shows_all_fields_when_it_fits() { let parts = ("a.txt".to_string(), Some("100".to_string()), Some("01-02-26".to_string()), Some("03:04".to_string())); diff --git a/crates/filecommand-tui/src/views/progress_dialog.rs b/crates/filecommand-tui/src/views/progress_dialog.rs index dfa8ad6..fab96a5 100644 --- a/crates/filecommand-tui/src/views/progress_dialog.rs +++ b/crates/filecommand-tui/src/views/progress_dialog.rs @@ -3,16 +3,18 @@ use filecommand_core::dialogs::overlay_rect; use filecommand_core::fs_ops::{JobKind, ProgressInfo}; -use filecommand_core::listing::{display_width, format_count, truncate_with_ellipsis}; +use filecommand_core::listing::{display_width, format_count, pad_to_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::DialogLayout; use crate::style::role_style; const GAUGE_WIDTH: usize = 40; const BOX_INNER_W: usize = GAUGE_WIDTH + 2; +const CANCEL: &str = "[ Cancel ]"; fn title_for(kind: JobKind) -> &'static str { match kind { @@ -24,35 +26,62 @@ fn title_for(kind: JobKind) -> &'static str { } } +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle; its text rows — the title, the file counts, the current +/// file, the byte line, and the `[ Cancel ]` row — each already clamped to +/// the inner width; and the `[ Cancel ]` button span, the only mouse target +/// a running job's progress dialog offers (mouse-input "Running job accepts +/// Cancel only"). +pub fn layout(area: Rect, kind: JobKind, progress: &ProgressInfo) -> DialogLayout { + let preferred = (BOX_INNER_W as u16 + 2, 7); + let r = overlay_rect(preferred, preferred, (area.width, area.height)); + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + + let title = format!(" {} ", title_for(kind)); + let counts = format!("Files: {} of {}", format_count(progress.files_done), format_count(progress.files_total)); + let current = progress.current_file.to_string_lossy(); + let bytes_line = format!("{} / {} bytes", format_count(progress.bytes_done as usize), format_count(progress.bytes_total as usize)); + let lines = vec![ + title, + pad_to_width(&truncate_with_ellipsis(&counts, inner_w), inner_w), + pad_to_width(&truncate_with_ellipsis(¤t, inner_w), inner_w), + pad_to_width(&truncate_with_ellipsis(&bytes_line, inner_w), inner_w), + pad_to_width(CANCEL, inner_w), + ]; + let buttons = vec![(Rect { x: rect.x + 1, y: rect.y + 5, width: display_width(CANCEL) as u16, height: 1 }, ButtonId::ProgressCancel)]; + DialogLayout { rect, buttons, rows: Vec::new(), lines } +} + pub fn render_progress(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, kind: JobKind, progress: &ProgressInfo) { + draw_progress(buf, theme, depth, progress, &layout(area, kind, progress)); +} + +/// Paint the dialog from an already computed [`layout`]; `progress` supplies +/// the byte ratio the gauge is drawn from. +pub fn draw_progress(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, progress: &ProgressInfo, l: &DialogLayout) { let body = role_style(theme, Role::DialogPrimary, depth); let filled_style = role_style(theme, Role::DialogGaugeFilled, depth); let empty_style = role_style(theme, Role::DialogGaugeEmpty, depth); - let preferred = (BOX_INNER_W as u16 + 2, 7); - let r = overlay_rect(preferred, preferred, (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; + let inner_w = l.rect.width.saturating_sub(2) as usize; let gauge_width = inner_w.saturating_sub(2).min(GAUGE_WIDTH); - let x = area.x + r.x; - let y = area.y + r.y; + let (x, y) = (l.rect.x, l.rect.y); + let [title, counts, current, bytes_line, cancel] = &l.lines[..] else { return }; - let title = format!(" {} ", title_for(kind)); let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); let bottom = format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)); buf.set_string(x, y, &top, body); - let title_x = x + 1 + ((inner_w.saturating_sub(display_width(&title))) / 2) as u16; - buf.set_string(title_x, y, &title, body); - - let counts = format!("Files: {} of {}", format_count(progress.files_done), format_count(progress.files_total)); - buf.set_string(x, y + 1, "\u{2551}", body); - buf.set_string(x + 1, y + 1, filecommand_core::listing::pad_to_width(&truncate_with_ellipsis(&counts, inner_w), inner_w), body); - buf.set_string(x + 1 + inner_w as u16, y + 1, "\u{2551}", body); + let title_x = x + 1 + ((inner_w.saturating_sub(display_width(title))) / 2) as u16; + buf.set_string(title_x, y, title, body); - let current = progress.current_file.to_string_lossy(); - buf.set_string(x, y + 2, "\u{2551}", body); - buf.set_string(x + 1, y + 2, filecommand_core::listing::pad_to_width(&truncate_with_ellipsis(¤t, inner_w), inner_w), body); - buf.set_string(x + 1 + inner_w as u16, y + 2, "\u{2551}", body); + let row = |buf: &mut Buffer, dy: u16, text: &str| { + buf.set_string(x, y + dy, "\u{2551}", body); + buf.set_string(x + 1, y + dy, text, body); + buf.set_string(x + 1 + inner_w as u16, y + dy, "\u{2551}", body); + }; + row(buf, 1, counts); + row(buf, 2, current); // Byte gauge. let ratio = if progress.bytes_total == 0 { 0.0 } else { progress.bytes_done as f64 / progress.bytes_total as f64 }; @@ -63,27 +92,8 @@ pub fn render_progress(buf: &mut Buffer, area: Rect, theme: &Theme, depth: Color buf.set_string(x + 2 + filled as u16, y + 3, "\u{2591}".repeat(gauge_width - filled), empty_style); buf.set_string(x + 1 + inner_w as u16, y + 3, "\u{2551}", body); - let bytes_line = format!("{} / {} bytes", format_count(progress.bytes_done as usize), format_count(progress.bytes_total as usize)); - buf.set_string(x, y + 4, "\u{2551}", body); - buf.set_string(x + 1, y + 4, filecommand_core::listing::pad_to_width(&truncate_with_ellipsis(&bytes_line, inner_w), inner_w), body); - buf.set_string(x + 1 + inner_w as u16, y + 4, "\u{2551}", body); + row(buf, 4, bytes_line); + row(buf, 5, cancel); - buf.set_string(x, y + 5, "\u{2551}", body); - buf.set_string(x + 1, y + 5, filecommand_core::listing::pad_to_width("[ Cancel ]", inner_w), body); - buf.set_string(x + 1 + inner_w as u16, y + 5, "\u{2551}", body); - - buf.set_string(x, y + box_h - 1, &bottom, body); -} - -/// The `[ Cancel ]` button rect `render_progress` currently draws at -/// `area` — the only mouse target a running job's progress dialog offers -/// (mouse-input "Running job accepts Cancel only"). Mirrors the renderer's -/// own `overlay_rect` geometry exactly. -pub fn hit_buttons(area: Rect) -> Vec<(Rect, ButtonId)> { - let preferred = (BOX_INNER_W as u16 + 2, 7); - let r = overlay_rect(preferred, preferred, (area.width, area.height)); - let x = area.x + r.x; - let y = area.y + r.y; - let label = "[ Cancel ]"; - vec![(Rect { x: x + 1, y: y + 5, width: display_width(label) as u16, height: 1 }, ButtonId::ProgressCancel)] + buf.set_string(x, y + l.rect.height - 1, &bottom, body); } diff --git a/crates/filecommand-tui/src/views/quit_dialog.rs b/crates/filecommand-tui/src/views/quit_dialog.rs index 5745319..1dc5d32 100644 --- a/crates/filecommand-tui/src/views/quit_dialog.rs +++ b/crates/filecommand-tui/src/views/quit_dialog.rs @@ -7,54 +7,45 @@ use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::{label_spans, DialogLayout}; use crate::style::role_style; const MESSAGE: &str = " Quit FileCommand? (Y/N) "; -pub fn render_quit_dialog(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth) { - let style = role_style(theme, Role::DialogPrimary, depth); +/// The dialog's geometry at `area`: its rectangle, the one message line, and +/// the `Y`/`N` character spans recorded as button hits (design D2: a +/// buttonless dialog's hotkey spans count as buttons). The renderer and the +/// hit map both draw from this (mouse-input "Click targets match the +/// rendered geometry"). +pub fn layout(area: Rect) -> DialogLayout { // Fixed content, so preferred and minimum coincide — this dialog is // never wider than it needs to be, only ever clamped by a terminal // smaller than its own message (responsive-layout "Unified overlay // geometry"). let preferred_w = display_width(MESSAGE) as u16 + 2; let r = overlay_rect((preferred_w, 3), (preferred_w, 3), (area.width, area.height)); - let box_w = r.width; - let box_h = r.height; - let inner_w = box_w.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; + let message = pad_to_width(&truncate_with_ellipsis(MESSAGE, inner_w), inner_w); + let buttons = label_spans(&message, rect.x + 1, rect.y + 1, &[("Y", ButtonId::QuitYes), ("N", ButtonId::QuitNo)]); + DialogLayout { rect, buttons, rows: Vec::new(), lines: vec![message] } +} + +pub fn render_quit_dialog(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth) { + draw_quit_dialog(buf, theme, depth, &layout(area)); +} + +/// Paint the dialog from an already computed [`layout`]. +pub fn draw_quit_dialog(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, l: &DialogLayout) { + let style = role_style(theme, Role::DialogPrimary, depth); + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); let top = format!("\u{250C}{}\u{2510}", "\u{2500}".repeat(inner_w)); let bottom = format!("\u{2514}{}\u{2518}", "\u{2500}".repeat(inner_w)); - let message = pad_to_width(&truncate_with_ellipsis(MESSAGE, inner_w), inner_w); - let mid = format!("\u{2502}{message}\u{2502}"); + let mid = format!("\u{2502}{}\u{2502}", l.lines[0]); buf.set_string(x, y, &top, style); buf.set_string(x, y + 1, &mid, style); - buf.set_string(x, y + box_h - 1, &bottom, style); -} - -/// The `Y`/`N` character spans inside `render_quit_dialog`'s message at -/// `area`, recorded as button hits (design D2: a buttonless dialog's hotkey -/// spans count as buttons). Mirrors the renderer's own `overlay_rect` -/// geometry exactly. -pub fn hit_buttons(area: Rect) -> Vec<(Rect, ButtonId)> { - let preferred_w = display_width(MESSAGE) as u16 + 2; - let r = overlay_rect((preferred_w, 3), (preferred_w, 3), (area.width, area.height)); - let inner_w = r.width.saturating_sub(2) as usize; - let x = area.x + r.x; - let y = area.y + r.y; - let message = pad_to_width(&truncate_with_ellipsis(MESSAGE, inner_w), inner_w); - let base_x = x + 1; - let row_y = y + 1; - - let mut out = Vec::new(); - for (ch, id) in [('Y', ButtonId::QuitYes), ('N', ButtonId::QuitNo)] { - if let Some(byte_idx) = message.find(ch) { - let col = message[..byte_idx].chars().count() as u16; - out.push((Rect { x: base_x + col, y: row_y, width: 1, height: 1 }, id)); - } - } - out + buf.set_string(x, y + l.rect.height - 1, &bottom, style); } diff --git a/crates/filecommand-tui/src/views/skipped_summary.rs b/crates/filecommand-tui/src/views/skipped_summary.rs index bb399a7..1b15808 100644 --- a/crates/filecommand-tui/src/views/skipped_summary.rs +++ b/crates/filecommand-tui/src/views/skipped_summary.rs @@ -1,13 +1,17 @@ -//! End-of-job skipped-files summary: shown only when 1+ items were skipped. +//! End-of-job skipped-files summary: shown when a job finished with 1+ +//! skipped items and/or 1+ metadata-restore warnings (error-diagnostics; +//! operation-dialogs "End-of-job skipped-files summary", "Metadata-restore +//! failures are reported in the end-of-job summary"). use filecommand_core::dialogs::overlay_rect; -use filecommand_core::fs_ops::SkippedItem; +use filecommand_core::fs_ops::{ItemKind, SkippedItem}; use filecommand_core::listing::{display_width, pad_to_width, truncate_with_ellipsis}; use filecommand_core::theme::{ColorDepth, Role, Theme}; use filecommand_core::update::ButtonId; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use super::DialogLayout; use crate::style::role_style; const MAX_ROWS: usize = 8; @@ -15,60 +19,80 @@ const MIN_INNER_W: usize = 32; /// Chrome rows other than the (variable) item list: top+bottom frame, /// footer row. const CHROME_ROWS: u16 = 3; +const FOOTER: &str = "Press any key to continue"; -pub fn render_skipped_summary(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, skipped: &[SkippedItem]) { - let body = role_style(theme, Role::DialogPrimary, depth); +/// The dialog's title and body rows (design D5): skipped items first, each +/// `{path}: {reason}`, then a `Warnings` group — a header row followed by +/// the warning rows in the same format. The title carries both counts: +/// `Skipped N item(s)`, `N warning(s)`, or `Skipped N item(s), M +/// warning(s)`. +fn rows_and_title(items: &[SkippedItem]) -> (String, Vec) { + let skipped_count = items.iter().filter(|i| i.kind == ItemKind::Skipped).count(); + let warning_count = items.len() - skipped_count; + let title = match (skipped_count, warning_count) { + (_, 0) => format!(" Skipped {skipped_count} item(s) "), + (0, _) => format!(" {warning_count} warning(s) "), + _ => format!(" Skipped {skipped_count} item(s), {warning_count} warning(s) "), + }; + let mut rows: Vec = Vec::new(); + rows.extend(items.iter().filter(|i| i.kind == ItemKind::Skipped).map(|s| format!("{}: {}", s.path.display(), s.reason))); + if warning_count > 0 { + rows.push("Warnings".to_string()); + rows.extend(items.iter().filter(|i| i.kind == ItemKind::Warning).map(|s| format!("{}: {}", s.path.display(), s.reason))); + } + (title, rows) +} - let title = format!(" Skipped {} item(s) ", skipped.len()); - let all_rows: Vec = skipped.iter().take(MAX_ROWS).map(|s| format!("{}: {}", s.path.display(), s.reason)).collect(); +/// The dialog's geometry at `area` (reducer-overlay-structure design D8): +/// its rectangle; its text — the title first, then the item rows that fit +/// the clamped height, then the `Press any key to continue` footer, each +/// clamped to the inner width; and the footer row recorded as one clickable +/// span. This dialog has no literal button glyph, but design D5 gates it as +/// buttons-only, so the whole footer stands in for "any key" (mirroring +/// `Command::FileOpConfirm`, which is what any key already does here). +pub fn layout(area: Rect, skipped: &[SkippedItem]) -> DialogLayout { + let (title, rows) = rows_and_title(skipped); + let all_rows: Vec = rows.into_iter().take(MAX_ROWS).collect(); let preferred_inner_w = all_rows.iter().map(|r| display_width(r)).max().unwrap_or(0).max(display_width(&title)).max(30) + 2; let preferred_h = CHROME_ROWS + all_rows.len() as u16; let r = overlay_rect((preferred_inner_w as u16 + 2, preferred_h), (MIN_INNER_W as u16, 5), (area.width, area.height)); - let box_h = r.height; - let inner_w = r.width.saturating_sub(2) as usize; + let rect = Rect { x: area.x + r.x, y: area.y + r.y, width: r.width, height: r.height }; + let inner_w = rect.width.saturating_sub(2) as usize; // The clamped height may show fewer rows than were computed above. - let visible_rows = box_h.saturating_sub(CHROME_ROWS) as usize; - let rows = &all_rows[..all_rows.len().min(visible_rows)]; - let x = area.x + r.x; - let y = area.y + r.y; + let visible_rows = rect.height.saturating_sub(CHROME_ROWS) as usize; + let shown = all_rows.len().min(visible_rows); + + let mut lines = vec![truncate_with_ellipsis(&title, inner_w)]; + lines.extend(all_rows[..shown].iter().map(|row| pad_to_width(&truncate_with_ellipsis(row, inner_w), inner_w))); + lines.push(pad_to_width(&truncate_with_ellipsis(FOOTER, inner_w), inner_w)); + let footer_y = rect.y + 1 + shown as u16; + let buttons = vec![(Rect { x: rect.x + 1, y: footer_y, width: inner_w as u16, height: 1 }, ButtonId::SummaryContinue)]; + DialogLayout { rect, buttons, rows: Vec::new(), lines } +} + +pub fn render_skipped_summary(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, skipped: &[SkippedItem]) { + draw_skipped_summary(buf, theme, depth, &layout(area, skipped)); +} + +/// Paint the dialog from an already computed [`layout`]. +pub fn draw_skipped_summary(buf: &mut Buffer, theme: &Theme, depth: ColorDepth, l: &DialogLayout) { + let body = role_style(theme, Role::DialogPrimary, depth); + let inner_w = l.rect.width.saturating_sub(2) as usize; + let (x, y) = (l.rect.x, l.rect.y); + let Some((title, rows)) = l.lines.split_first() else { return }; let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); let bottom = format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)); buf.set_string(x, y, &top, body); - let title_x = x + 1 + ((inner_w.saturating_sub(display_width(&title))) / 2) as u16; - buf.set_string(title_x, y, truncate_with_ellipsis(&title, inner_w), body); + let title_x = x + 1 + ((inner_w.saturating_sub(display_width(title))) / 2) as u16; + buf.set_string(title_x, y, title, body); + // The item rows, then the footer, each on its own framed row. for (i, row) in rows.iter().enumerate() { let dy = 1 + i as u16; buf.set_string(x, y + dy, "\u{2551}", body); - buf.set_string(x + 1, y + dy, pad_to_width(&truncate_with_ellipsis(row, inner_w), inner_w), body); + buf.set_string(x + 1, y + dy, row, body); buf.set_string(x + 1 + inner_w as u16, y + dy, "\u{2551}", body); } - let footer_y = y + 1 + rows.len() as u16; - buf.set_string(x, footer_y, "\u{2551}", body); - buf.set_string(x + 1, footer_y, pad_to_width(&truncate_with_ellipsis("Press any key to continue", inner_w), inner_w), body); - buf.set_string(x + 1 + inner_w as u16, footer_y, "\u{2551}", body); - buf.set_string(x, footer_y + 1, &bottom, body); -} - -/// The "Press any key to continue" footer row `render_skipped_summary` -/// currently draws at `area`, recorded as one clickable span — this dialog -/// has no literal button glyph, but design D5 gates it as buttons-only, so -/// the whole footer stands in for "any key" (mirroring -/// `Command::FileOpConfirm`, which is what any key already does here). -/// Mirrors the renderer's own row-count/`overlay_rect` geometry exactly. -pub fn hit_buttons(area: Rect, skipped: &[SkippedItem]) -> Vec<(Rect, ButtonId)> { - let title = format!(" Skipped {} item(s) ", skipped.len()); - let all_rows: Vec = skipped.iter().take(MAX_ROWS).map(|s| format!("{}: {}", s.path.display(), s.reason)).collect(); - let preferred_inner_w = all_rows.iter().map(|r| display_width(r)).max().unwrap_or(0).max(display_width(&title)).max(30) + 2; - let preferred_h = CHROME_ROWS + all_rows.len() as u16; - let r = overlay_rect((preferred_inner_w as u16 + 2, preferred_h), (MIN_INNER_W as u16, 5), (area.width, area.height)); - let inner_w = r.width.saturating_sub(2); - let box_h = r.height; - let visible_rows = box_h.saturating_sub(CHROME_ROWS) as usize; - let rows_len = all_rows.len().min(visible_rows); - let x = area.x + r.x; - let y = area.y + r.y; - let footer_y = y + 1 + rows_len as u16; - vec![(Rect { x: x + 1, y: footer_y, width: inner_w, height: 1 }, ButtonId::SummaryContinue)] + buf.set_string(x, y + 1 + rows.len() as u16, &bottom, body); } diff --git a/crates/filecommand-tui/src/views/splash.rs b/crates/filecommand-tui/src/views/splash.rs index 614aed8..dd9bb64 100644 --- a/crates/filecommand-tui/src/views/splash.rs +++ b/crates/filecommand-tui/src/views/splash.rs @@ -1,21 +1,33 @@ //! Startup splash: solid backdrop plus a centered double-line box with the -//! product identity lines. +//! product identity lines — and, when animations are enabled, the entrance +//! that builds that box inside the 800 ms minimum hold (startup-splash +//! "Splash entrance animation"). +use filecommand_core::anim; use filecommand_core::dialogs::overlay_rect; use filecommand_core::listing::display_width; use filecommand_core::theme::{ColorDepth, Role, Theme}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::style::Style; use crate::style::role_style; const BOX_W: u16 = 48; const BOX_H: u16 = 9; +/// The box rows the four identity lines sit on, in line order. +const LINE_ROWS: [u16; 4] = [2, 3, 5, 6]; + /// The splash box's geometry, via the unified overlay rule. Preferred and /// minimum coincide (48×9) — the box never clamps at or above the 60×16 /// floor (startup-splash "Splash renders in the degraded band"; design D1). -pub fn render_splash(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, identity_lines: &[String; 4]) { +/// +/// `elapsed` is the time since the splash started on the reducer clock when +/// animations are on, or `None` for the finished box. From +/// `SPLASH_ENTRANCE_MS` on, the animated splash is identical to the static +/// one, so it is the same code path. +pub fn render_splash(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, identity_lines: &[String; 4], elapsed: Option) { let backdrop = role_style(theme, Role::ScreenBackdrop, depth); for y in area.y..area.y + area.height { buf.set_string(area.x, y, " ".repeat(area.width as usize), backdrop); @@ -36,6 +48,13 @@ pub fn render_splash(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDe let box_x = area.x + r.x; let box_y = area.y + r.y; + if let Some(elapsed) = elapsed.filter(|e| *e < anim::SPLASH_ENTRANCE_MS) { + let geometry = Geometry { box_x, box_y, box_w, box_h, inner_w }; + let styles = [title_style, version_style, text_style, text_style]; + draw_entrance(buf, &geometry, identity_lines, frame_style, version_style, styles, elapsed); + return; + } + let top = format!("\u{2554}{}\u{2557}", "\u{2550}".repeat(inner_w)); let bottom = format!("\u{255A}{}\u{255D}", "\u{2550}".repeat(inner_w)); let blank = format!("\u{2551}{}\u{2551}", " ".repeat(inner_w)); @@ -52,7 +71,15 @@ pub fn render_splash(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDe } #[allow(clippy::too_many_arguments)] -fn write_row(buf: &mut Buffer, box_x: u16, y: u16, inner_w: usize, text: &str, text_style: ratatui::style::Style, frame_style: ratatui::style::Style) { +fn write_row( + buf: &mut Buffer, + box_x: u16, + y: u16, + inner_w: usize, + text: &str, + text_style: ratatui::style::Style, + frame_style: ratatui::style::Style, +) { buf.set_string(box_x, y, "\u{2551}", frame_style); let pad = inner_w.saturating_sub(display_width(text)); let left = pad / 2; @@ -61,3 +88,282 @@ fn write_row(buf: &mut Buffer, box_x: u16, y: u16, inner_w: usize, text: &str, t buf.set_string(box_x + 1, y, &line, text_style); buf.set_string(box_x + 1 + inner_w as u16, y, "\u{2551}", frame_style); } + +/// Where the box sits on screen. +struct Geometry { + box_x: u16, + box_y: u16, + box_w: u16, + box_h: u16, + inner_w: usize, +} + +/// The frame glyph at box-relative `(dx, dy)` on the perimeter. +fn frame_glyph(g: &Geometry, dx: u16, dy: u16) -> char { + let (right, bottom) = (g.box_w - 1, g.box_h - 1); + match (dx, dy) { + (0, 0) => '\u{2554}', + (x, 0) if x == right => '\u{2557}', + (0, y) if y == bottom => '\u{255A}', + (x, y) if x == right && y == bottom => '\u{255D}', + (_, 0) => '\u{2550}', + (_, y) if y == bottom => '\u{2550}', + _ => '\u{2551}', + } +} + +/// The perimeter cells in clockwise order from the top-left corner: the top +/// row left→right, the right column down, the bottom row right→left, the +/// left column up. `2W + 2H − 4` cells, each exactly once. +fn perimeter(g: &Geometry) -> Vec<(u16, u16)> { + let (w, h) = (g.box_w, g.box_h); + let mut cells = Vec::with_capacity(2 * (w as usize + h as usize) - 4); + cells.extend((0..w).map(|x| (x, 0))); + cells.extend((1..h).map(|y| (w - 1, y))); + cells.extend((0..w - 1).rev().map(|x| (x, h - 1))); + cells.extend((1..h - 1).rev().map(|y| (0, y))); + cells +} + +/// One identity character with the display column it starts at, relative to +/// the line's first cell. +struct LineCell { + ch: char, + col: u16, +} + +fn line_cells(text: &str) -> Vec { + let mut col = 0u16; + text.chars() + .map(|ch| { + let cell = LineCell { ch, col }; + col += display_width(&ch.to_string()) as u16; + cell + }) + .collect() +} + +/// Draw the splash entrance at `elapsed` ms (< `SPLASH_ENTRANCE_MS`): the +/// frame traces in clockwise, each identity line reveals outward from its +/// center in `splash.frame` before settling to its role, and one +/// `splash.version` window sweeps across the product name. Everything is a +/// pure function of `elapsed`. +fn draw_entrance( + buf: &mut Buffer, + g: &Geometry, + identity_lines: &[String; 4], + frame_style: Style, + version_style: Style, + line_styles: [Style; 4], + elapsed: u64, +) { + // Frame trace: the first N perimeter cells, clockwise from the top-left. + let ring = perimeter(g); + let traced = anim::reveal_count(anim::progress(elapsed, anim::SPLASH_TRACE_MS), ring.len()); + for &(dx, dy) in ring.iter().take(traced) { + buf.set_string(g.box_x + dx, g.box_y + dy, frame_glyph(g, dx, dy).to_string(), frame_style); + } + // The interior joins once the frame is complete, so before that only the + // trace is on the backdrop and no identity text can show. + if elapsed < anim::SPLASH_TRACE_MS { + return; + } + for dy in 1..g.box_h - 1 { + buf.set_string(g.box_x + 1, g.box_y + dy, " ".repeat(g.inner_w), frame_style); + } + + for (i, text) in identity_lines.iter().enumerate() { + let line_start = anim::SPLASH_IDENTITY_START_MS + anim::SPLASH_IDENTITY_STAGGER_MS * i as u64; + if elapsed < line_start { + continue; + } + let cells = line_cells(text); + let left = g.inner_w.saturating_sub(display_width(text)) / 2; + let x0 = g.box_x + 1 + left as u16; + let y = g.box_y + LINE_ROWS[i]; + // Characters appear in order of distance from the line's center, so + // the line opens outward; `rank` runs from 0 at the center to + // `n - 1` at the ends and is scaled onto the line's reveal duration, + // so the outermost characters land exactly as the line completes. + let n = cells.len(); + let span = n.saturating_sub(1).max(1) as u64; + for (j, cell) in cells.iter().enumerate() { + let rank = (2 * j as i64 - (n as i64 - 1)).unsigned_abs(); + let appears = line_start + anim::SPLASH_IDENTITY_LINE_MS * rank / span; + if elapsed < appears { + continue; + } + let style = if elapsed - appears < anim::SPLASH_IDENTITY_FLASH_MS { frame_style } else { line_styles[i] }; + buf.set_string(x0 + cell.col, y, cell.ch.to_string(), style); + } + } + + // Title sweep: a 3-cell `splash.version` window crosses the product name + // once, restyling the name's own characters as it passes. + if elapsed >= anim::SPLASH_SWEEP_START_MS { + let name = &identity_lines[0]; + let cells = line_cells(name); + let name_w = display_width(name) as u16; + let left = g.inner_w.saturating_sub(name_w as usize) / 2; + let x0 = g.box_x + 1 + left as u16; + let p = anim::progress(elapsed - anim::SPLASH_SWEEP_START_MS, anim::SPLASH_SWEEP_END_MS - anim::SPLASH_SWEEP_START_MS); + let pos = anim::sweep_pos(p, name_w, anim::SPLASH_SWEEP_WINDOW); + for cell in &cells { + let col = cell.col as i32; + if col >= pos && col < pos + anim::SPLASH_SWEEP_WINDOW as i32 { + buf.set_string(x0 + cell.col, g.box_y + LINE_ROWS[0], cell.ch.to_string(), version_style); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lines() -> [String; 4] { + [ + "FileCommand".to_string(), + "Version 0.1.0".to_string(), + "Copyright (C) 2026 The FileCommand Authors".to_string(), + "Inspired by the Norton Commander, 1986-1998".to_string(), + ] + } + + fn render(elapsed: Option) -> Buffer { + let area = Rect { x: 0, y: 0, width: 80, height: 24 }; + let mut buf = Buffer::empty(area); + render_splash(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &lines(), elapsed); + buf + } + + fn text(buf: &Buffer) -> String { + let a = buf.area; + (0..a.height).map(|y| (0..a.width).map(|x| buf[(x, y)].symbol()).collect::()).collect::>().join("\n") + } + + /// Frame glyphs (the double-line set) present in `buf`. + fn frame_cells(buf: &Buffer) -> usize { + text(buf).chars().filter(|c| "\u{2554}\u{2557}\u{255A}\u{255D}\u{2550}\u{2551}".contains(*c)).count() + } + + #[test] + fn finished_splash_is_identical_with_animations_off_or_after_the_entrance() { + let still = render(None); + assert_eq!(render(Some(anim::SPLASH_ENTRANCE_MS)), still); + assert_eq!(render(Some(800)), still); + assert_eq!(render(Some(60_000)), still, "stays final forever after"); + } + + #[test] + fn entrance_starts_on_a_bare_backdrop() { + let first = render(Some(0)); + assert_eq!(frame_cells(&first), 0, "frame 1 draws no box yet"); + assert!(!text(&first).contains("FileCommand")); + // ...but the blue backdrop is already solid. + let backdrop = role_style(&Theme::classic(), Role::ScreenBackdrop, ColorDepth::Ansi16); + assert_eq!(first[(0, 0)].bg, backdrop.bg.unwrap()); + } + + #[test] + fn frame_traces_clockwise_from_the_top_left_corner() { + let perimeter_cells = 2 * (BOX_W as usize + BOX_H as usize) - 4; + // 100 ms is half the 200 ms trace: half the perimeter, no text yet. + let half = render(Some(100)); + assert_eq!(frame_cells(&half), perimeter_cells / 2); + let t = text(&half); + assert!(!t.contains("FileCommand") && !t.contains("Version"), "no identity text during the trace"); + + // The very first cells are the top-left corner and the top edge. + let r = overlay_rect((BOX_W, BOX_H), (BOX_W, BOX_H), (80, 24)); + let (bx, by) = (r.x, r.y); + assert_eq!(half[(bx, by)].symbol(), "\u{2554}", "the trace starts at the top-left corner"); + assert_eq!(half[(bx + 1, by)].symbol(), "\u{2550}"); + // Clockwise: the top edge is complete before the left column starts. + assert_eq!(half[(bx, by + 1)].symbol(), " ", "the left column is traced last"); + assert_eq!(half[(bx + BOX_W - 1, by)].symbol(), "\u{2557}", "the top-right corner is reached next"); + } + + #[test] + fn frame_is_complete_when_the_trace_ends() { + let done = render(Some(anim::SPLASH_TRACE_MS)); + let perimeter_cells = 2 * (BOX_W as usize + BOX_H as usize) - 4; + assert_eq!(frame_cells(&done), perimeter_cells); + } + + #[test] + fn identity_lines_reveal_outward_from_their_centers() { + let t = text(&render(Some(400))); + assert!(t.contains("FileCommand"), "product name fully revealed by 400 ms"); + assert!(t.contains("Version 0.1.0"), "version line fully revealed by 400 ms"); + assert!(!t.contains("Copyright (C) 2026 The FileCommand Authors"), "copyright still opening"); + assert!(t.contains("Copyright") || t.contains("(C) 2026") || t.contains("The File"), "...but its middle is already showing:\n{t}"); + assert!(!t.contains("Inspired"), "tribute line has not started at 400 ms"); + + // The ends of the copyright line are the last to appear: its first + // character is still missing while a central one is present. + let r = overlay_rect((BOX_W, BOX_H), (BOX_W, BOX_H), (80, 24)); + let row: String = (r.x + 1..r.x + BOX_W - 1).map(|x| render(Some(400))[(x, r.y + LINE_ROWS[2])].symbol().to_string()).collect(); + assert!(!row.trim_start().starts_with('C'), "left end not yet revealed: `{row}`"); + assert!(!row.trim_end().ends_with('s'), "right end not yet revealed: `{row}`"); + } + + #[test] + fn newly_revealed_characters_flash_in_the_frame_color_then_settle() { + let theme = Theme::classic(); + let frame = role_style(&theme, Role::SplashFrame, ColorDepth::Ansi16); + let title = role_style(&theme, Role::SplashTitle, ColorDepth::Ansi16); + let r = overlay_rect((BOX_W, BOX_H), (BOX_W, BOX_H), (80, 24)); + let y = r.y + LINE_ROWS[0]; + // "FileCommand" (11 cells) centered in the 46-cell interior. + let center_x = r.x + 1 + (46 - 11) / 2 + 5; + + // The center character appears at the line's start (200 ms) in the + // frame role... + let appearing = render(Some(210)); + assert_eq!(appearing[(center_x, y)].symbol(), "o", "the middle of `FileCommand`"); + assert_eq!(appearing[(center_x, y)].fg, frame.fg.unwrap()); + // ...and has settled to the title role once 66 ms have passed. + let settled = render(Some(200 + anim::SPLASH_IDENTITY_FLASH_MS + 20)); + assert_eq!(settled[(center_x, y)].fg, title.fg.unwrap()); + } + + #[test] + fn title_sweep_crosses_the_product_name_in_the_version_color() { + let theme = Theme::classic(); + let version = role_style(&theme, Role::SplashVersion, ColorDepth::Ansi16); + let title = role_style(&theme, Role::SplashTitle, ColorDepth::Ansi16); + assert_ne!(version.fg, title.fg, "the sweep is only visible if the two roles differ"); + let r = overlay_rect((BOX_W, BOX_H), (BOX_W, BOX_H), (80, 24)); + let y = r.y + LINE_ROWS[0]; + let name_x = r.x + 1 + (46 - 11) / 2; + + let swept = |at: u64| -> Vec { + let buf = render(Some(at)); + (0..11).filter(|i| buf[(name_x + *i as u16, y)].fg == version.fg.unwrap()).collect() + }; + assert!(swept(560).is_empty(), "the window starts off the left edge"); + let mid = swept(650); + assert_eq!(mid.len(), anim::SPLASH_SWEEP_WINDOW as usize, "a 3-cell window part-way across: {mid:?}"); + assert!(mid.windows(2).all(|w| w[1] == w[0] + 1), "contiguous: {mid:?}"); + assert!(swept(759).len() <= 1, "only the window's trailing cell is left over the name by 760 ms"); + } + + #[test] + fn entrance_never_draws_outside_the_box() { + let r = overlay_rect((BOX_W, BOX_H), (BOX_W, BOX_H), (80, 24)); + let backdrop = role_style(&Theme::classic(), Role::ScreenBackdrop, ColorDepth::Ansi16); + for at in (0..anim::SPLASH_ENTRANCE_MS).step_by(20) { + let buf = render(Some(at)); + for y in 0..24u16 { + for x in 0..80u16 { + let inside = x >= r.x && x < r.x + BOX_W && y >= r.y && y < r.y + BOX_H; + if !inside { + assert_eq!(buf[(x, y)].symbol(), " ", "stray glyph at ({x},{y}) at {at} ms"); + assert_eq!(buf[(x, y)].bg, backdrop.bg.unwrap()); + } + } + } + } + } +} diff --git a/crates/filecommand-tui/src/views/tests.rs b/crates/filecommand-tui/src/views/tests.rs new file mode 100644 index 0000000..25dea20 --- /dev/null +++ b/crates/filecommand-tui/src/views/tests.rs @@ -0,0 +1,259 @@ +//! Geometry agreement between what each dialog and menu draws and what the +//! hit map records for it (reducer-overlay-structure design D8; mouse-input +//! "Click targets match the rendered geometry", "Geometry agrees at every +//! size"). + +use std::ffi::OsString; +use std::path::PathBuf; + +use filecommand_core::dialogs::FileActionMenuState; +use filecommand_core::fs_ops::dialog::{DropButtons, FileOpSetup, RunningDialog}; +use filecommand_core::fs_ops::{ConflictInfo, ErrorInfo, JobKind, ProgressInfo, SkippedItem, SourceItem}; +use filecommand_core::line_edit::LineEdit; +use filecommand_core::listing::{display_width, truncate_with_ellipsis}; +use filecommand_core::menu::{entries, MenuEntry, MenuId, MenuState}; +use filecommand_core::theme::Theme; +use filecommand_core::update::ButtonId; +use filecommand_core::{Overlay, State, UiPhase}; +use proptest::prelude::*; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; + +use super::*; + +fn identity() -> [String; 4] { + ["FileCommand".to_string(), "v0".to_string(), "test".to_string(), "fixture".to_string()] +} + +/// Render `state` at `size` and return the painted buffer with the frame's +/// hit map. +fn frame(state: &State, size: (u16, u16)) -> (Buffer, HitMap) { + let area = Rect { x: 0, y: 0, width: size.0, height: size.1 }; + let mut buf = Buffer::empty(area); + let out = render(&mut buf, area, state, ColorDepth::Ansi16, &identity(), "3:04 PM", None); + (buf, out.hitmap) +} + +/// The text painted on the cells of `rect`, row by row. +fn cells(buf: &Buffer, rect: Rect) -> String { + let mut out = String::new(); + for y in rect.top()..rect.bottom() { + for x in rect.left()..rect.right() { + out.push_str(buf[(x, y)].symbol()); + } + } + out +} + +/// What a button's cells must spell: the label the renderer paints for it. +fn label_of(id: ButtonId) -> &'static str { + match id { + ButtonId::DeleteConfirmYes => "(Y)es", + ButtonId::DeleteConfirmNo => "(N)o", + ButtonId::ConflictOverwrite => "(O)verwrite", + ButtonId::ConflictOverwriteAll => "Over(w)rite All", + ButtonId::ConflictSkip => "(S)kip", + ButtonId::ConflictSkipAll => "Skip (A)ll", + ButtonId::ConflictRename => "(R)ename", + ButtonId::ConflictCancel => "(C)ancel", + ButtonId::ErrorRetry => "(R)etry", + ButtonId::ErrorSkip => "(S)kip", + ButtonId::ErrorSkipAll => "Skip (A)ll", + ButtonId::ErrorAbort => "A(b)ort", + ButtonId::ProgressCancel => "[ Cancel ]", + ButtonId::SummaryContinue => "Press any key to continue", + ButtonId::QuitYes => "Y", + ButtonId::QuitNo => "N", + ButtonId::DropDialogCopy => "[ Copy ]", + ButtonId::DropDialogMove => "[ Move ]", + ButtonId::DropDialogCancel => "[ Cancel ]", + } +} + +fn state_at(size: (u16, u16), phase: UiPhase) -> State { + let mut state = State::empty(Theme::classic()); + state.term_size = size; + state.phase = phase; + state +} + +fn source(name: &str, is_dir: bool) -> SourceItem { + SourceItem { original_name: OsString::from(name), path: PathBuf::from("/left").join(name), is_dir } +} + +/// Every hit rectangle the frame records lies inside the screen and spells +/// exactly its button's label (the summary footer is one whole row, so its +/// cells spell the clamped footer text and nothing else). +fn assert_buttons_match_the_painted_labels(buf: &Buffer, hm: &HitMap, size: (u16, u16)) -> Result<(), TestCaseError> { + for (rect, id) in &hm.dialog_buttons { + prop_assert!(rect.height == 1 && rect.width > 0, "{id:?} at {rect:?}"); + prop_assert!(rect.right() <= size.0 && rect.bottom() <= size.1, "{id:?} at {rect:?} is off a {size:?} screen"); + let painted = cells(buf, *rect); + let label = label_of(*id); + if *id == ButtonId::SummaryContinue { + let clamped = truncate_with_ellipsis(label, rect.width as usize); + prop_assert_eq!(painted.trim_end(), clamped.as_str(), "the footer row"); + } else { + prop_assert_eq!(painted.as_str(), label, "{:?} at {:?}", id, rect); + prop_assert_eq!(rect.width as usize, display_width(label)); + } + } + Ok(()) +} + +/// Every row rectangle lies inside the screen, is one row tall, and spells +/// the row the renderer painted for that entry (its label, or the clamped +/// `…` form of it). +fn assert_rows_match_the_painted_rows(buf: &Buffer, rows: &[(Rect, usize)], labels: &[Option<&str>], size: (u16, u16)) -> Result<(), TestCaseError> { + for (rect, index) in rows { + prop_assert_eq!(rect.height, 1); + prop_assert!(rect.right() <= size.0 && rect.bottom() <= size.1, "row {index} at {rect:?} is off a {size:?} screen"); + let label = labels.get(*index).copied().flatten().unwrap_or_else(|| panic!("row {index} names an item")); + let painted = cells(buf, *rect); + prop_assert!(painted.contains(label) || painted.contains('\u{2026}'), "row {} at {:?} paints {:?}, not {:?}", index, rect, painted, label); + } + Ok(()) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(96))] + + #[test] + fn delete_confirm_buttons_are_exactly_their_painted_labels(w in 60u16..=200, h in 16u16..=60, name_len in 1usize..=200, second in any::(), hidden in 0usize..=2) { + let name = "n".repeat(name_len); + let setup = FileOpSetup::DeleteConfirm { + sources: vec![source(&name, second), source("b.txt", false), source("c.txt", false)], + source_dir: PathBuf::from("/left"), + needs_second_confirm: second, + confirmed_once: second, + hidden_by_filter: hidden, + }; + let state = state_at((w, h), UiPhase::FileOpSetup(setup)); + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.dialog_buttons.len(), 2, "both buttons are recorded at every size"); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn drop_dialog_buttons_are_exactly_their_painted_labels(w in 60u16..=200, h in 16u16..=60, focused_move in any::(), hidden in 0usize..=2, text_len in 0usize..=120) { + let setup = FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: vec![source("a.txt", false), source("b.txt", false)], + source_dir: PathBuf::from("/left"), + input: LineEdit::from("x".repeat(text_len)), + buttons: Some(DropButtons { focused: if focused_move { JobKind::Move } else { JobKind::Copy } }), + hidden_by_filter: hidden, + }; + let state = state_at((w, h), UiPhase::FileOpSetup(setup)); + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.dialog_buttons.len(), 3); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn quit_dialog_hotkeys_are_exactly_their_painted_cells(w in 60u16..=200, h in 16u16..=60, over_menu in any::()) { + let mut state = state_at((w, h), UiPhase::Panels); + if over_menu { + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); + } + state.quit_confirm = true; + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.dialog_buttons.len(), 2); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn progress_cancel_is_exactly_its_painted_label(w in 60u16..=200, h in 16u16..=60, done in 0u64..=100, name_len in 0usize..=120) { + let progress = ProgressInfo { files_done: 1, files_total: 9, bytes_done: done, bytes_total: 100, current_file: OsString::from("f".repeat(name_len)) }; + let phase = UiPhase::FileOpRunning { source_dir: PathBuf::from("/left"), dest_dir: PathBuf::from("/right"), dialog: RunningDialog::Progress { kind: JobKind::Copy, progress } }; + let state = state_at((w, h), phase); + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.dialog_buttons.len(), 1); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn conflict_dialog_hotkeys_are_exactly_their_painted_labels(w in 60u16..=200, h in 16u16..=60, renaming in any::(), name_len in 1usize..=120) { + let info = ConflictInfo { + source_name: OsString::from("s".repeat(name_len)), + source_size: 1234, + source_modified: None, + target_path: PathBuf::from("/right/t.txt"), + target_size: 99, + target_modified: None, + }; + let rename_input = renaming.then(|| LineEdit::from("new name")); + let phase = UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + dialog: RunningDialog::Conflict { kind: JobKind::Copy, info, progress: ProgressInfo::starting(3, 30), rename_input }, + }; + let state = state_at((w, h), phase); + let (buf, hm) = frame(&state, (w, h)); + if renaming { + prop_assert!(hm.dialog_buttons.is_empty(), "no hotkey rows while the rename field is open"); + } else { + prop_assert!(hm.dialog_buttons.iter().any(|(_, id)| *id == ButtonId::ConflictCancel), "the Cancel row is always whole"); + } + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn error_dialog_hotkeys_are_exactly_their_painted_labels(w in 60u16..=200, h in 16u16..=60, msg_len in 1usize..=160, path_len in 1usize..=160) { + let info = ErrorInfo { path: PathBuf::from("p".repeat(path_len)), message: "m".repeat(msg_len) }; + let phase = UiPhase::FileOpRunning { + source_dir: PathBuf::from("/left"), + dest_dir: PathBuf::from("/right"), + dialog: RunningDialog::Error { kind: JobKind::Move, info, progress: ProgressInfo::starting(3, 30) }, + }; + let state = state_at((w, h), phase); + let (buf, hm) = frame(&state, (w, h)); + prop_assert!(!hm.dialog_buttons.is_empty()); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn skipped_summary_footer_is_exactly_its_painted_row(w in 60u16..=200, h in 16u16..=60, skipped in 0usize..=10, warnings in 0usize..=10) { + let mut items: Vec = (0..skipped).map(|i| SkippedItem::skipped(PathBuf::from(format!("/left/s{i}.txt")), "exists".to_string())).collect(); + items.extend((0..warnings).map(|i| SkippedItem::warning(PathBuf::from(format!("/left/w{i}.txt")), "timestamp".to_string()))); + if items.is_empty() { + items.push(SkippedItem::skipped(PathBuf::from("/left/only.txt"), "exists".to_string())); + } + let state = state_at((w, h), UiPhase::FileOpSummary(items)); + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.dialog_buttons.len(), 1); + assert_buttons_match_the_painted_labels(&buf, &hm, (w, h))?; + } + + #[test] + fn file_action_menu_rows_are_exactly_the_painted_rows(w in 60u16..=200, h in 16u16..=60, executable in any::(), is_dir in any::(), name_len in 1usize..=80) { + let menu = FileActionMenuState::open(OsString::from("f".repeat(name_len)), is_dir, executable && !is_dir, false); + let labels: Vec> = menu.entries.iter().map(|e| Some(e.label())).collect(); + let mut state = state_at((w, h), UiPhase::Panels); + state.open_overlay(Overlay::FileActionMenu(menu)); + let (buf, hm) = frame(&state, (w, h)); + prop_assert_eq!(hm.file_action_menu_items.len(), labels.len(), "every entry has a row at every size from the floor up"); + assert_rows_match_the_painted_rows(&buf, &hm.file_action_menu_items, &labels, (w, h))?; + } + + #[test] + fn pulldown_rows_and_titles_are_exactly_the_painted_cells(w in 60u16..=200, h in 16u16..=60, which in 0usize..5) { + let id = filecommand_core::menu::ALL_MENUS[which]; + let labels: Vec> = entries(id).iter().map(|e| match e { MenuEntry::Item(item) => Some(item.label), MenuEntry::Separator => None }).collect(); + let mut state = state_at((w, h), UiPhase::Panels); + state.open_overlay(Overlay::Menu(MenuState::for_menu(id))); + let (buf, hm) = frame(&state, (w, h)); + // A short terminal clamps the pull-down (responsive-layout "Unified + // overlay geometry"), so only the rows that fit are drawn — and only + // those are recorded. + let items = labels.iter().filter(|l| l.is_some()).count(); + prop_assert!(!hm.menu_items.is_empty() && hm.menu_items.len() <= items, "{} rows recorded for {} items", hm.menu_items.len(), items); + assert_rows_match_the_painted_rows(&buf, &hm.menu_items, &labels, (w, h))?; + for (rect, title) in &hm.menu_titles { + prop_assert_eq!(rect.y, 0); + let painted = cells(&buf, *rect); + prop_assert_eq!(painted.as_str(), title.title(), "the title's cells"); + } + prop_assert_eq!(hm.menu_titles.len(), 5); + } +} diff --git a/crates/filecommand-tui/src/views/user_menu.rs b/crates/filecommand-tui/src/views/user_menu.rs index 2ca7fcd..f8774c6 100644 --- a/crates/filecommand-tui/src/views/user_menu.rs +++ b/crates/filecommand-tui/src/views/user_menu.rs @@ -3,6 +3,7 @@ //! a separator and a compiled-in `Themes` row that is always present //! (user-menu "Open the F2 user menu"). +use filecommand_core::anim; use filecommand_core::config::UserMenuEntry; use filecommand_core::dialogs::{overlay_rect, UserMenuState}; use filecommand_core::listing::{display_width, pad_to_width, truncate_with_ellipsis}; @@ -25,11 +26,38 @@ const THEMES_LABEL: &str = "Themes"; /// `Themes` row (user-menu "Open the F2 user menu"). `dialog.cursor == /// entries.len()` highlights the built-in row; the separator row is never /// highlighted (design D2/D3). -pub fn render_user_menu(buf: &mut Buffer, area: Rect, theme: &Theme, depth: ColorDepth, dialog: &UserMenuState, entries: &[UserMenuEntry]) { +/// +/// `now` is the reducer clock when animations are on (`State::anim_now`) and +/// `None` for the final frame: the box unfolds top-down on open and a newly +/// highlighted row flashes in the hotkey accent before settling +/// (ui-animations "Pop-up menus unfold and highlights settle"). +pub fn render_user_menu( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + dialog: &UserMenuState, + entries: &[UserMenuEntry], + now: Option, +) { + super::unfold(buf, now, dialog.opened_at_ms, |b| draw_user_menu(b, area, theme, depth, dialog, entries, now)); +} + +fn draw_user_menu( + buf: &mut Buffer, + area: Rect, + theme: &Theme, + depth: ColorDepth, + dialog: &UserMenuState, + entries: &[UserMenuEntry], + now: Option, +) { let body = role_style(theme, Role::DialogPrimary, depth); - let highlight = role_style(theme, Role::MenuHighlight, depth); + let settling = matches!((now, dialog.selected_at_ms), (Some(now), Some(at)) if now.saturating_sub(at) < anim::HIGHLIGHT_SETTLE_MS); + let highlight = role_style(theme, if settling { Role::MenuHotkey } else { Role::MenuHighlight }, depth); - let widest_label = entries.iter().map(|e| display_width(&e.label)).max().unwrap_or(display_width(EMPTY_PLACEHOLDER)).max(display_width(THEMES_LABEL)); + let widest_label = + entries.iter().map(|e| display_width(&e.label)).max().unwrap_or(display_width(EMPTY_PLACEHOLDER)).max(display_width(THEMES_LABEL)); let preferred_inner_w = (widest_label + 2).clamp(MIN_INNER_W, MAX_INNER_W).max(display_width(TITLE)); // User rows (or the one-row empty placeholder), plus a separator row and // the built-in Themes row. The minimum height (5 = 2 borders + 1 @@ -89,7 +117,7 @@ mod tests { fn render(dialog: &UserMenuState, entries: &[UserMenuEntry]) -> Vec { let area = Rect { x: 0, y: 0, width: 80, height: 24 }; let mut buf = Buffer::empty(area); - render_user_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, dialog, entries); + render_user_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, dialog, entries, None); (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect()).collect() } @@ -116,7 +144,7 @@ mod tests { dialog.cursor = 1; let area = Rect { x: 0, y: 0, width: 80, height: 24 }; let mut buf = Buffer::empty(area); - render_user_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &dialog, &entries); + render_user_menu(&mut buf, area, &Theme::classic(), ColorDepth::Ansi16, &dialog, &entries, None); let theme = Theme::classic(); let highlight = role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16); for y in 0..area.height { diff --git a/crates/filecommand-tui/src/views/viewer.rs b/crates/filecommand-tui/src/views/viewer.rs index 0cb30f0..230bb34 100644 --- a/crates/filecommand-tui/src/views/viewer.rs +++ b/crates/filecommand-tui/src/views/viewer.rs @@ -2,10 +2,10 @@ //! body, and its own F-key bar. A frame-less view that replaces the panels //! while open (viewer: Frame-less full-screen chrome), rendered as a pure //! function of `ViewerState` plus an already-open `ByteSource` window into -//! the file (design D1). The byte reads it performs are bounded lookups -//! into a memory map (or a small positioned read on the chunk-read -//! fallback) — never a whole-file scan, matching the "instant open, no line -//! index" contract the core `viewer` module implements. +//! the file (design D1). The byte reads it performs are bounded positioned +//! reads on the open handle — never a whole-file scan, matching the +//! "instant open, no line index" contract the core `viewer` module +//! implements. use filecommand_core::listing::{display_width, format_count, pad_to_width}; use filecommand_core::theme::{ColorDepth, Role, Theme}; @@ -74,11 +74,7 @@ pub fn render_viewer(buf: &mut Buffer, area: Rect, viewer: &ViewerState, theme: /// the file name visible last, left-truncated with `…` if even that alone /// does not fit (responsive-layout "Full-screen surface degradation"). fn render_header(buf: &mut Buffer, x: u16, y: u16, w: usize, viewer: &ViewerState, style: Style) { - let filename = viewer - .path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| viewer.path.display().to_string()); + let filename = viewer.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| viewer.path.display().to_string()); let pct = percent_through(viewer.top_offset, viewer.file_len); let pos = match viewer.mode { ViewMode::Text => format!("Col {}", viewer.h_scroll), diff --git a/crates/filecommand-tui/src/worker.rs b/crates/filecommand-tui/src/worker.rs index 3590fa9..f60ae33 100644 --- a/crates/filecommand-tui/src/worker.rs +++ b/crates/filecommand-tui/src/worker.rs @@ -5,26 +5,156 @@ //! additionally exposing a cancel flag and a reply channel so the main loop //! can answer conflict/error dialogs the worker thread blocks on. +use std::any::Any; +use std::io; +use std::panic::{self, AssertUnwindSafe}; use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, Sender}; -use std::time::Duration; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; use filecommand_core::drives; +use filecommand_core::editor::{EditorState, LoadResult}; +use filecommand_core::fs_ops::path::to_extended_path; use filecommand_core::fs_ops::{ - CancelFlag, ConflictChoice, ConflictInfo, ErrorChoice, ErrorInfo, Job, JobOutcome, JobSink, ProgressInfo, RealFs, + CancelFlag, ConflictChoice, ConflictInfo, ErrorChoice, ErrorInfo, ItemKind, Job, JobOutcome, JobSink, ProgressInfo, ProgressThrottle, RealFs, + SkippedItem, }; use filecommand_core::git_info::{self, GitInfo}; use filecommand_core::info::InfoValues; -use filecommand_core::listing::{find_in_subtree, list_dir_chunked, Entry, FsReader, StdFsReader}; +use filecommand_core::listing::{find_in_subtree, list_dir_streaming, BatchPolicy, Entry, FsReader, StdFsReader}; use filecommand_core::panel::parent_path; use filecommand_core::viewer::search::{find_forward, DEFAULT_CHUNK_SIZE}; use filecommand_core::viewer::ByteSource; -use filecommand_core::{Command, PanelSide}; +use filecommand_core::{Command, PanelSide, ProbeKind}; -const CHUNK_SIZE: usize = 256; +use crate::diag; -/// How long `spawn_git_info_query` waits for `git_info::query` before giving -/// up and reporting "no info" (git-info "Silent absence on timeout and +/// Send a worker result to the event loop, logging a failure rather than +/// dropping it silently (error-diagnostics design D3): the send can only +/// fail once the loop's receiver is gone, i.e. during shutdown. Returns +/// `false` on failure so the caller can stop early — there is no point +/// streaming more into the void. +pub(crate) fn deliver(tx: &Sender, kind: &str, cmd: Command) -> bool { + if tx.send(cmd).is_err() { + diag::log("channel", format_args!("{kind} not delivered: event loop gone")); + return false; + } + true +} + +/// The name a status-line error identifies its target by (error-diagnostics +/// design D4): `path.file_name()`, or the whole path when there is none +/// (`C:\`, `\\server\share`). +pub(crate) fn name_or_path(path: &Path) -> String { + path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()) +} + +/// Run `body` under `catch_unwind`, turning a panic into its message +/// (process-lifecycle-hardening design D6). Every worker body runs through +/// this so a panic becomes that worker's normal failure reply instead of a +/// `Reading… N` or a progress dialog that never closes: the panic hook +/// itself does nothing on a non-UI thread (design D4), so this is the only +/// place the payload is seen. +pub(crate) fn guarded(body: impl FnOnce() -> T) -> Result { + panic::catch_unwind(AssertUnwindSafe(body)).map_err(|payload| panic_message(payload.as_ref())) +} + +/// The text of a panic payload: the `&str` or `String` `panic!` was given, +/// or `"unknown panic"` for anything else. +fn panic_message(payload: &(dyn Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} + +/// Record a contained worker panic in the diagnostic log (error-diagnostics +/// coordination): with the hook silent off the UI thread, the log and the +/// failure reply are the only places the message appears. +fn log_worker_panic(kind: &str, message: &str) { + diag::log("worker", format_args!("{kind} worker panicked: {message}")); +} + +/// Spawn one worker thread that runs `body` under [`guarded`] and hands the +/// outcome — the body's value, or the panic message — to `reply`, which +/// sends the worker's result (or its defined failure reply, design D6) to +/// the event loop. The gauge token is held for the thread's whole lifetime, +/// so it is released after the reply's last send even when the body +/// panicked (idle-aware-event-loop design D3). +fn spawn_worker(token: WorkerToken, body: B, reply: R) +where + T: Send + 'static, + B: FnOnce() -> T + Send + 'static, + R: FnOnce(Result) + Send + 'static, +{ + std::thread::spawn(move || { + let _token = token; + reply(guarded(body)); + }); +} + +/// A live count of the worker threads that can still post to the event +/// loop's channel (idle-aware-event-loop design D3): while it is nonzero the +/// loop polls for terminal input at the usual ~33 ms cadence so results keep +/// today's latency; at zero the loop sleeps until the next time-driven +/// deadline instead. Every `spawn_*` moves a [`WorkerToken`] into its thread. +#[derive(Clone, Default)] +pub struct WorkerGauge { + live: Arc, +} + +impl WorkerGauge { + pub fn new() -> WorkerGauge { + WorkerGauge::default() + } + + /// The number of worker threads currently holding a token. + pub fn live(&self) -> usize { + self.live.load(Ordering::Acquire) + } + + /// A token keeping one worker counted until it is dropped. Created by the + /// caller *before* the thread starts — an increment from inside the + /// thread would leave a window where the loop sees zero live workers and + /// parks while the worker is about to post. + pub fn token(&self) -> WorkerToken { + self.live.fetch_add(1, Ordering::AcqRel); + WorkerToken { live: self.live.clone() } + } +} + +/// The RAII half of [`WorkerGauge`]: moved into the worker thread, so a +/// normal return *or* a panic decrements the count — always after the +/// thread's last channel send, with release ordering so a loop that reads +/// zero (acquire) then drains the channel once more is guaranteed to see +/// that send (idle-aware-event-loop design D3). +pub struct WorkerToken { + live: Arc, +} + +impl Drop for WorkerToken { + fn drop(&mut self) { + self.live.fetch_sub(1, Ordering::Release); + } +} + +/// The `ListingFailed` status-line message, `Cannot read : ` +/// (filesystem-error-handling "Error messages name the affected path"; +/// design D4): the name rather than the full path, because the panel's +/// mini-status is about half the terminal width and truncates from the +/// right — the panel title directly above already shows the directory. +fn listing_failed_message(path: &Path, err: &io::Error) -> String { + format!("Cannot read {}: {err}", name_or_path(path)) +} + +/// How long one git status query may run before its panel's slot reports +/// "no info" and moves on (git-info "Silent absence on timeout and /// stale-result discarding"; design D3). Generous enough that a normal /// local-repo status call never hits it, short enough that a stalled /// network share doesn't leave the branch suffix/marker column in limbo @@ -35,10 +165,23 @@ const GIT_INFO_TIMEOUT: Duration = Duration::from_secs(3); /// unreachable network shares block here, never in the render loop; the /// dialog shows the drive with a blank label meanwhile, and a result that /// arrives after the dialog closed is discarded by `core::update`. -pub fn spawn_drive_label(target: PanelSide, letter: char, generation: u64, tx: Sender) { - std::thread::spawn(move || { - let label = drives::volume_info(letter).map(|(label, _serial)| label); - let _ = tx.send(Command::DriveLabelResolved { target, letter, label, generation }); +pub fn spawn_drive_label(target: PanelSide, letter: char, generation: u64, tx: Sender, token: WorkerToken) { + spawn_drive_label_with(target, letter, generation, tx, token, move || drives::volume_info(letter).map(|(label, _serial)| label)); +} + +/// `spawn_drive_label` with the lookup injected (a test seam for the +/// panic reply, design D6): a panicked lookup resolves to no label, exactly +/// like absent media. +fn spawn_drive_label_with(target: PanelSide, letter: char, generation: u64, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce() -> Option + Send + 'static, +{ + spawn_worker(token, body, move |result| { + let label = result.unwrap_or_else(|msg| { + log_worker_panic("drive label", &msg); + None + }); + deliver(&tx, "drive label", Command::DriveLabelResolved { target, letter, label, generation }); }); } @@ -47,45 +190,254 @@ pub fn spawn_drive_label(target: PanelSide, letter: char, generation: u64, tx: S /// The whole set is sent as one result: these queries hit the same volume /// and complete together, and a single fill-in keeps the panel from /// repainting several times in a row. -pub fn spawn_info_query(panel: PanelSide, path: PathBuf, request: u64, tx: Sender) { - std::thread::spawn(move || { - let values = gather_info(&StdFsReader, &path); - let _ = tx.send(Command::InfoResolved { panel, path, request, values }); - }); +pub fn spawn_info_query(panel: PanelSide, path: PathBuf, request: u64, tx: Sender, token: WorkerToken) { + spawn_info_query_with(panel, path, request, tx, token, |path| gather_info(&StdFsReader, path)); } -/// Run `git_info::query` for `path` on its own dedicated worker thread — -/// never the shared Info worker, since a slow status call on one panel -/// must not head-of-line-block the other (design D3; git-info "Background -/// repository detection"). The result — resolved branch/statuses, or -/// `GitInfo::none()` for "outside any repository" — is reported back; -/// `core::update`'s generation-key guard (`PanelState::git_request`) is what -/// decides whether a stale reply (one for a directory/generation the panel -/// has since moved past) actually gets applied. -/// -/// If `git_info::query` (blocking and, being libgit2, not cancellable -/// mid-call) has not returned within `GIT_INFO_TIMEOUT`, this reports -/// `GitInfo::none()` immediately instead of waiting further and silently -/// discards whatever the query eventually returns — never forwarding a -/// second, late reply for the same `request` (git-info "Silent absence on -/// timeout and stale-result discarding": "leaving the uncancellable call to -/// finish" is exactly what `race_with_timeout` does by simply not waiting -/// on its inner thread any further, not by aborting it). -pub fn spawn_git_info_query(panel: PanelSide, path: PathBuf, request: u64, tx: Sender) { - std::thread::spawn(move || { - let inner_path = path.clone(); - let info = race_with_timeout(GIT_INFO_TIMEOUT, move || git_info::query(&inner_path)).unwrap_or_else(GitInfo::none); - let _ = tx.send(Command::GitInfoResolved { panel, path, request, info }); - }); +/// `spawn_info_query` with the gathering injected (design D6): a panic +/// resolves every value to empty or zero, as for an unreadable directory, +/// so no field is left showing `…` forever. +fn spawn_info_query_with(panel: PanelSide, path: PathBuf, request: u64, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path) -> InfoValues + Send + 'static, +{ + let body_path = path.clone(); + spawn_worker( + token, + move || body(&body_path), + move |result| { + let values = result.unwrap_or_else(|msg| { + log_worker_panic("info query", &msg); + unreadable_info() + }); + deliver(&tx, "info query", Command::InfoResolved { panel, path, request, values }); + }, + ); +} + +/// Every Info value resolved to empty or zero — what `gather_info` reports +/// for a directory it cannot read and no drive letter, and the panic reply +/// of the Info worker (design D6). +fn unreadable_info() -> InfoValues { + InfoValues { + memory_bytes: Some(0), + drive_total: Some(0), + drive_free: Some(0), + volume_label: Some(String::new()), + serial: Some(drives::format_serial(0)), + file_count: Some(0), + dir_count: Some(0), + } +} + +/// One panel's git-query slot: at most one status query runs per panel at a +/// time, so rapid navigation cannot pile up concurrent, uncancellable +/// libgit2 walks that compete for I/O (git-info "Rapid navigation does not +/// pile up queries"; git-info-timeout-memory design D3). A dedicated runner +/// per panel — never the shared Info worker — keeps a slow status call on +/// one panel from head-of-line-blocking the other (design D3; git-info +/// "Background repository detection"). The result — resolved +/// branch/statuses, or `GitInfo::none()` for "outside any repository" — is +/// reported back; `core::update`'s generation-key guard +/// (`PanelState::git_request`) is what decides whether a stale reply (one +/// for a directory/generation the panel has since moved past) actually gets +/// applied. +#[derive(Clone, Default)] +pub struct GitQuerySlot { + state: Arc>, +} + +#[derive(Default)] +struct GitQuerySlotState { + /// A runner thread is working through the queue. + running: bool, + /// The newest request that arrived while the runner was busy; an older + /// queued request is *replaced*, never queued — its id is already + /// superseded in `PanelState::git_request`, so its reply would be + /// dropped anyway. + pending: Option<(PathBuf, u64)>, +} + +impl GitQuerySlot { + pub fn new() -> GitQuerySlot { + GitQuerySlot::default() + } + + /// Route one `Effect::QueryGitInfo` through the slot: start the runner + /// when the slot is idle, or replace the pending request while a query + /// is running. + pub fn request(&self, panel: PanelSide, path: PathBuf, request: u64, tx: Sender, token: WorkerToken) { + self.request_with(panel, path, request, tx, token, GIT_INFO_TIMEOUT, discover_then_query); + } + + /// `request` with the timeout and the per-directory query injected, so + /// tests can drive the runner with a scripted fake instead of libgit2. + /// When a runner is already serving the slot, `query` is unused — the + /// runner's own query function drives the pending request too + /// (production always passes the same one). + #[allow(clippy::too_many_arguments)] + fn request_with(&self, panel: PanelSide, path: PathBuf, request: u64, tx: Sender, token: WorkerToken, timeout: Duration, query: F) + where + F: Fn(&Path, &PublishedRoot) -> GitInfo + Send + Sync + 'static, + { + let mut state = self.state.lock().unwrap(); + if state.running { + // The token stays with the already-running runner, which keeps + // the gauge honest for as long as queued requests can still be + // served; this request's own token is dropped (the decrement + // balances its `token()` call). + drop(token); + state.pending = Some((path, request)); + return; + } + state.running = true; + // A pending left over from a panicked runner is superseded by this + // fresher request, so it is dropped rather than started out of + // order. + state.pending = None; + let slot = self.state.clone(); + std::thread::spawn(move || run_git_query_slot(slot, panel, (path, request), tx, token, timeout, query)); + } +} + +/// Clears the slot's `running` flag if the runner thread unwinds before +/// marking the slot idle itself — otherwise git info for that panel would +/// silently stop for the rest of the session (design D3). The normal exit +/// path disarms the guard after clearing the flag under the lock, so the +/// guard cannot clear it *after* a successor runner has already started. +struct GitQuerySlotGuard { + state: Arc>, + disarmed: bool, +} + +impl GitQuerySlotGuard { + fn disarm(mut self) { + self.disarmed = true; + } +} + +impl Drop for GitQuerySlotGuard { + fn drop(&mut self) { + if self.disarmed { + return; + } + // The lock may be poisoned if the panic happened mid-update. + self.state.lock().unwrap_or_else(|e| e.into_inner()).running = false; + } +} + +/// The slot's runner: serve the first request, then each pending one in +/// turn, then mark the slot idle. One query at a time per panel; a libgit2 +/// walk that outlives its timeout is left running to completion (there is +/// no way to cancel it mid-walk) but its result is dropped — git-info +/// "Silent absence on timeout and stale-result discarding" — and the slot +/// is released when the timeout reply is *sent*, not when the orphaned walk +/// eventually returns, which may be minutes later (design D3). +fn run_git_query_slot( + slot: Arc>, + panel: PanelSide, + first: (PathBuf, u64), + tx: Sender, + token: WorkerToken, + timeout: Duration, + query: F, +) where + F: Fn(&Path, &PublishedRoot) -> GitInfo + Send + Sync + 'static, +{ + // Held for the runner's whole lifetime — including a panic unwind — so + // the gauge counts this slot until its last reply has been sent + // (idle-aware-event-loop design D3). + let _token = token; + let guard = GitQuerySlotGuard { state: slot.clone(), disarmed: false }; + let query = Arc::new(query); + let mut current = Some(first); + while let Some((path, request)) = current { + // The query itself already runs on `race_with_timeout`'s inner + // thread, whose panic surfaces as `Disconnected`; this guards the + // runner's own half so a panic there still answers "no info" + // (process-lifecycle-hardening design D6). + let (info, timed_out_root) = guarded(|| run_one_git_query(timeout, &path, &query)).unwrap_or_else(|msg| { + log_worker_panic("git info", &msg); + (GitInfo::none(), None) + }); + deliver(&tx, "git info", Command::GitInfoResolved { panel, path, request, info, timed_out_root: timed_out_root.clone() }); + + let mut state = slot.lock().unwrap(); + current = state.pending.take(); + // A request queued before this query timed out on root R must not + // start a second walk of the slow repository — exactly the pile-up + // this change removes. Its panel shows no git info there, which is + // what the reducer decides anyway once R is recorded (design D3). + if let (Some(root), Some((pending_path, _))) = (&timed_out_root, ¤t) { + if git_info::is_under_root(pending_path, root) { + current = None; + } + } + if current.is_none() { + state.running = false; + } + } + guard.disarm(); +} + +/// The cell a running query publishes its repository's working-directory +/// root into — *before* the potentially slow status walk starts — so a +/// timeout can report which repository is slow, not just that it is +/// (design D2). `None` while discovery has not finished. +type PublishedRoot = Arc>>; + +/// The real per-directory query the runner drives: repository discovery +/// publishes the workdir root (in the queried path's own spelling) before +/// the status walk begins, then branch + status resolve as one blocking +/// libgit2 call sequence (git-info "Background repository detection"). +fn discover_then_query(path: &Path, published: &PublishedRoot) -> GitInfo { + let Some((repo, depth)) = git_info::discover(path) else { + return GitInfo::none(); + }; + let root = git_info::root_in_dir_spelling(path, depth).unwrap_or_else(|| path.to_path_buf()); + *published.lock().unwrap_or_else(|e| e.into_inner()) = Some(root); + git_info::query_in_repo(&repo, path) +} + +/// Run one query under the timeout on a throwaway inner thread and turn the +/// outcome into the reply's `(info, timed_out_root)`: the resolved info when +/// it finished in time; on timeout `GitInfo::none()` plus the published root +/// (or the queried directory itself when discovery never finished — then +/// only that directory and its descendants are remembered, design D2); on a +/// panicked inner thread `GitInfo::none()` with `timed_out_root: None`, so a +/// one-off panic is not mistaken for a timeout and does not silence a +/// repository for the session. +fn run_one_git_query(timeout: Duration, path: &Path, query: &Arc) -> (GitInfo, Option) +where + F: Fn(&Path, &PublishedRoot) -> GitInfo + Send + Sync + 'static, +{ + let published: PublishedRoot = Arc::new(Mutex::new(None)); + let outcome = { + let published = published.clone(); + let path = path.to_path_buf(); + let query = query.clone(); + race_with_timeout(timeout, move || query(&path, &published)) + }; + match outcome { + Ok(info) => (info, None), + Err(RecvTimeoutError::Timeout) => { + let root = published.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| path.to_path_buf()); + (GitInfo::none(), Some(root)) + } + Err(RecvTimeoutError::Disconnected) => (GitInfo::none(), None), + } } /// Run `f` on a fresh thread and wait up to `timeout` for its result. -/// `Some(value)` if `f` finished in time; `None` on timeout — `f`'s thread -/// is simply left running to completion in the background (there is no way -/// to cancel an arbitrary blocking call), and its eventual result is -/// dropped rather than delivered late, since nothing is listening for it -/// past the timeout. -fn race_with_timeout(timeout: Duration, f: F) -> Option +/// `Ok(value)` if `f` finished in time; `Err(RecvTimeoutError::Timeout)` +/// when it did not — `f`'s thread is simply left running to completion in +/// the background (there is no way to cancel an arbitrary blocking call), +/// and its eventual result is dropped rather than delivered late, since +/// nothing is listening for it past the timeout; +/// `Err(RecvTimeoutError::Disconnected)` when `f`'s thread died without +/// sending (a panic), so callers can tell a crash apart from a genuine +/// timeout (git-info-timeout-memory design D2). +fn race_with_timeout(timeout: Duration, f: F) -> Result where T: Send + 'static, F: FnOnce() -> T + Send + 'static, @@ -94,7 +446,7 @@ where std::thread::spawn(move || { let _ = tx.send(f()); }); - rx.recv_timeout(timeout).ok() + rx.recv_timeout(timeout) } /// The blocking half of an Info query, factored out so it can be driven @@ -104,9 +456,12 @@ pub fn gather_info(reader: &dyn FsReader, path: &Path) -> InfoValues { let space = drive.and_then(drives::disk_space); let volume = drive.and_then(drives::volume_info); let (files, dirs) = match reader.read_dir(path) { - Ok(entries) => { - let dirs = entries.iter().filter(|e| e.is_dir).count(); - (entries.len() - dirs, dirs) + // Directory links count as directories, like the panel lists them; + // entries skipped as unreadable are not counted (link-aware-listing + // design D6). + Ok(listing) => { + let dirs = listing.entries.iter().filter(|e| e.is_dir).count(); + (listing.entries.len() - dirs, dirs) } // An unreadable directory still resolves — as zero counts rather // than a permanent `…` the user cannot clear. @@ -128,22 +483,324 @@ pub fn gather_info(reader: &dyn FsReader, path: &Path) -> InfoValues { /// unbounded in total wall time), so this must not block key handling the /// way `Effect::OpenViewer`'s O(1) open does (viewer: F7 streaming search — /// "Search is bounded and streaming"). Reopens the file rather than sharing -/// the render loop's cached `ByteSource` across threads — a fresh mmap open -/// is cheap and keeps the two paths independent. -pub fn spawn_viewer_search(path: PathBuf, start_offset: u64, pattern: Vec, request: u64, tx: Sender) { - std::thread::spawn(move || { - let result = match ByteSource::open(&path) { - Ok(source) => find_forward(&source, start_offset, &pattern, DEFAULT_CHUNK_SIZE), - Err(_) => None, - }; - let (offset, match_range) = match result { +/// the render loop's cached `ByteSource` across threads — a second +/// positioned-read open is cheap and keeps the two paths independent. The +/// source is a local, so the worker's handle is released when the search +/// returns (viewer: Instant open — "Closing the viewer releases the file"). +/// +/// `cancel` is the search's stop signal (viewer "Search stops when +/// superseded or when the viewer closes"; listing-request-guard design D5): +/// once set, `find_forward` returns at its next chunk boundary and no +/// `ViewerSearchResult` is sent — the reducer has already superseded this +/// id, so a reply would be dropped anyway. +pub fn spawn_viewer_search( + path: PathBuf, + start_offset: u64, + pattern: Vec, + request: u64, + cancel: CancelFlag, + tx: Sender, + token: WorkerToken, +) { + let scan_cancel = cancel.clone(); + spawn_viewer_search_with(request, cancel, tx, token, move || match ByteSource::open(&path) { + Ok(source) => find_forward(&source, start_offset, &pattern, DEFAULT_CHUNK_SIZE, &scan_cancel), + Err(_) => None, + }); +} + +/// `spawn_viewer_search` with the scan injected (process-lifecycle-hardening +/// design D6): a panicked scan reports "no match", the same reply as an +/// unopenable file. A cancelled search sends nothing at all. +fn spawn_viewer_search_with(request: u64, cancel: CancelFlag, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce() -> Option<(u64, u64)> + Send + 'static, +{ + spawn_worker(token, body, move |result| { + let found = result.unwrap_or_else(|msg| { + log_worker_panic("viewer search", &msg); + None + }); + if cancel.is_cancelled() { + return; + } + let (offset, match_range) = match found { Some((start, end)) => (Some(start), Some((start, end))), None => (None, None), }; - let _ = tx.send(Command::ViewerSearchResult { offset, match_range, request }); + deliver(&tx, "viewer search result", Command::ViewerSearchResult { offset, match_range, request }); + }); +} + +// --------------------------------------------------------------------- +// offload-blocking-io: the command-line target probe, the F3/F4 opens, the +// editor save and the associated-application launch all run here rather +// than on the event-loop thread (application-shell "The reducer performs no +// filesystem I/O", "Slow storage never blocks the event loop"). Each worker +// runs under `spawn_worker`'s `catch_unwind` and turns a panic into its own +// failure reply carrying the request id (design D8). +// --------------------------------------------------------------------- + +/// The status-line error forms of filesystem-error-handling "Error messages +/// name the affected path" (error-diagnostics design D4): `Cannot +/// : `, where the name is the file's own name — the +/// panel title directly above already shows the directory, and a full path +/// would push the reason off the truncated mini-status — or the whole path +/// when there is no file name (a drive or share root). +pub(crate) fn cannot_message(verb: &str, path: &Path, reason: impl std::fmt::Display) -> String { + format!("Cannot {verb} {}: {reason}", name_or_path(path)) +} + +/// `ByteSource` is created on the viewer-open worker and moved to the UI +/// thread through the hand-off channel (design D4). This fails to compile +/// should it ever stop being `Send`. +const _: () = { + fn assert_send() {} + let _ = assert_send::; +}; + +/// Check what exists at `path`, the resolved target of a command-line +/// `cd`/`del`/`rmdir` that is not listed in the panel, and reply with +/// `Command::PathProbed { request, kind }` (design D1/D2). The check goes +/// through the `\\?\` long-path abstraction, so a target longer than 260 +/// characters is found (command-line "cd to a path longer than 260 +/// characters"); an unreachable server blocks only this thread. +pub fn spawn_path_probe(request: u64, path: PathBuf, tx: Sender, token: WorkerToken) { + spawn_path_probe_with(request, tx, token, move || probe_kind(&path)); +} + +/// The probe itself: `metadata` through `to_extended_path`, links followed +/// (a junction to a directory is `Dir`), and any error at all is `Missing`, +/// exactly as the reducer's old synchronous `metadata(..).ok()?` was. +pub(crate) fn probe_kind(path: &Path) -> ProbeKind { + match std::fs::metadata(to_extended_path(path)) { + Ok(md) if md.is_dir() => ProbeKind::Dir, + Ok(_) => ProbeKind::File, + Err(_) => ProbeKind::Missing, + } +} + +/// `spawn_path_probe` with the check injected (design D8): a panicked probe +/// reports `Missing`, the reply the reducer turns into "not found". +fn spawn_path_probe_with(request: u64, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce() -> ProbeKind + Send + 'static, +{ + spawn_worker(token, body, move |result| { + let kind = result.unwrap_or_else(|msg| { + log_worker_panic("path probe", &msg); + ProbeKind::Missing + }); + deliver(&tx, "path probe", Command::PathProbed { request, kind }); }); } +/// What a viewer-open worker hands the UI thread before its `ViewerOpened` +/// reply: the request id, the path, and the open source (design D4). +/// `Command` derives `Clone`/`PartialEq`, so the source cannot travel in +/// the command itself; `Runtime` owns the channel. +pub type ViewerHandoff = (u64, PathBuf, ByteSource); + +/// Open `path` for the F3 viewer off the event loop (design D4/D5). On +/// success the source goes out on `handoff` *first*, then +/// `Command::ViewerOpened { request, .. }` on `tx`, so the source is waiting +/// by the time the command is applied; on failure only +/// `Command::ViewerOpenFailed { request, .. }` is sent. +pub fn spawn_viewer_open(request: u64, path: PathBuf, handoff: Sender, tx: Sender, token: WorkerToken) { + spawn_viewer_open_with(request, path, handoff, tx, token, ByteSource::open); +} + +/// `spawn_viewer_open` with the open injected (design D8): a panicked open +/// reports `ViewerOpenFailed`. +fn spawn_viewer_open_with(request: u64, path: PathBuf, handoff: Sender, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path) -> io::Result + Send + 'static, +{ + let body_path = path.clone(); + spawn_worker( + token, + move || body(&body_path), + move |result| { + let message = match result { + Ok(Ok(source)) => { + let file_len = source.len(); + if handoff.send((request, path.clone(), source)).is_err() { + // The runtime (and with it the event loop) is gone; + // the source is dropped here and nothing follows. + diag::log("channel", "viewer source not delivered: event loop gone"); + return; + } + deliver(&tx, "viewer opened", Command::ViewerOpened { request, path, file_len }); + return; + } + Ok(Err(e)) => cannot_message("open", &path, &e), + Err(msg) => { + log_worker_panic("viewer open", &msg); + format!("Internal error while opening {}: {msg}", name_or_path(&path)) + } + }; + deliver(&tx, "viewer open failure", Command::ViewerOpenFailed { request, message }); + }, + ); +} + +/// Open `path` for the F4 built-in editor off the event loop (design D4): +/// `EditorState::open` — the size cap, the whole-file read and the encoding +/// classification — with the same replies as before plus the request id. +pub fn spawn_editor_open(request: u64, path: PathBuf, tx: Sender, token: WorkerToken) { + spawn_editor_open_with(request, path, tx, token, EditorState::open); +} + +/// `spawn_editor_open` with the load injected (design D8): a panicked load +/// reports `EditorOpenFailed`. +fn spawn_editor_open_with(request: u64, path: PathBuf, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path) -> io::Result + Send + 'static, +{ + let body_path = path.clone(); + spawn_worker( + token, + move || body(&body_path), + move |result| { + let cmd = match result { + Ok(Ok(LoadResult::Loaded(editor))) => Command::EditorOpened { request, editor }, + Ok(Ok(LoadResult::TooLarge { size })) => Command::EditorTooLarge { request, path, size }, + Ok(Ok(LoadResult::NotEditable { reason })) => Command::EditorNotEditable { request, path, reason }, + Ok(Err(e)) => Command::EditorOpenFailed { request, message: cannot_message("open", &path, &e) }, + Err(msg) => { + log_worker_panic("editor open", &msg); + Command::EditorOpenFailed { request, message: format!("Internal error while opening {}: {msg}", name_or_path(&path)) } + } + }; + deliver(&tx, "editor open", cmd); + }, + ); +} + +/// Write the editor buffer `Effect::SaveEditor` carries off the event loop +/// (design D6): `EditorState::save` on the boxed copy, replying +/// `Command::EditorSaved` with the post-save state (and `then_quit` +/// unchanged) or `Command::EditorSaveFailed`. Only one save is ever in +/// flight — the reducer gates the editor on `save_pending` — so the reply +/// needs no request id. +pub fn spawn_editor_save(editor: Box, then_quit: bool, tx: Sender, token: WorkerToken) { + spawn_editor_save_with(editor, then_quit, tx, token, |editor| editor.save()); +} + +/// `spawn_editor_save` with the write injected (design D8): a panicked save +/// reports `EditorSaveFailed`, which also lifts the editor's gate. +fn spawn_editor_save_with(editor: Box, then_quit: bool, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&mut EditorState) -> io::Result<()> + Send + 'static, +{ + let path = editor.path.clone(); + spawn_worker( + token, + move || { + let mut editor = editor; + let outcome = body(&mut editor); + (editor, outcome) + }, + move |result| { + let cmd = match result { + Ok((editor, Ok(()))) => Command::EditorSaved { editor, then_quit }, + Ok((editor, Err(e))) => Command::EditorSaveFailed { message: cannot_message("save", &editor.path, &e) }, + Err(msg) => { + log_worker_panic("editor save", &msg); + Command::EditorSaveFailed { message: format!("Internal error while saving {}: {msg}", name_or_path(&path)) } + } + }; + deliver(&tx, "editor save", cmd); + }, + ); +} + +/// Launch `path` with its associated application off the event loop +/// (design D7): `app::open_with_association`, bracketed on Windows by +/// `CoInitializeEx(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)` and +/// `CoUninitialize`, since shell handlers may use COM. Fire-and-forget: a +/// success sends nothing; a failure sends `OpenWithAssociationFailed` for +/// `side`, the panel the Open action was invoked on. +pub fn spawn_open_with_association(side: PanelSide, path: PathBuf, cwd: PathBuf, tx: Sender, token: WorkerToken) { + spawn_open_with_association_with(side, path, tx, token, move |path| launch_with_association(path, &cwd)); +} + +/// `spawn_open_with_association` with the launch injected (design D8): a +/// panicked launch reports a failure on the invoking panel. +fn spawn_open_with_association_with(side: PanelSide, path: PathBuf, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path) -> Result<(), String> + Send + 'static, +{ + let body_path = path.clone(); + spawn_worker( + token, + move || body(&body_path), + move |result| { + let message = match result { + Ok(Ok(())) => return, + Ok(Err(message)) => message, + Err(msg) => { + log_worker_panic("open with association", &msg); + format!("Could not open {} (internal error: {msg})", name_or_path(&path)) + } + }; + deliver(&tx, "open-with failure", Command::OpenWithAssociationFailed { side, message }); + }, + ); +} + +/// The platform launch inside its COM bracket (Windows) or as is. +fn launch_with_association(path: &Path, cwd: &Path) -> Result<(), String> { + #[cfg(windows)] + { + with_com_initialized(|| crate::app::open_with_association(path, cwd)) + } + #[cfg(not(windows))] + { + crate::app::open_with_association(path, cwd) + } +} + +/// Run `body` with COM initialized in a single-threaded apartment on this +/// thread (application-shell "COM is initialized on the launch worker"; +/// design D7): the `COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE` +/// combination the `ShellExecute` documentation recommends. +#[cfg(windows)] +fn with_com_initialized(body: impl FnOnce() -> T) -> T { + use windows_sys::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_APARTMENTTHREADED, COINIT_DISABLE_OLE1DDE}; + com_bracket( + // SAFETY: a null reserved pointer and a valid COINIT combination; + // every call on this thread is paired with the `CoUninitialize` + // below when it succeeded. + || unsafe { CoInitializeEx(std::ptr::null(), (COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE) as u32) }, + // SAFETY: `com_bracket` calls this only after the `CoInitializeEx` + // above succeeded on this same thread, so every uninitialize is + // paired with an initialize. + || unsafe { CoUninitialize() }, + body, + ) +} + +/// `CoInitializeEx`'s success codes: `S_OK` (initialized by this call) and +/// `S_FALSE` (already initialized on this thread, reference count bumped). +/// Both must be balanced by one `CoUninitialize`; any failure (such as +/// `RPC_E_CHANGED_MODE`) must not be. +const COM_S_OK: i32 = 0; +const COM_S_FALSE: i32 = 1; + +/// The bracket itself, with the two COM calls injected so it is testable on +/// every platform: `init`, then `body`, then `uninit` only if `init` +/// succeeded. +fn com_bracket(init: impl FnOnce() -> i32, uninit: impl FnOnce(), body: impl FnOnce() -> T) -> T { + let hr = init(); + let initialized = hr == COM_S_OK || hr == COM_S_FALSE; + let result = body(); + if initialized { + uninit(); + } + result +} + /// Read `path`'s immediate child directories for Tree-mode expansion /// (`listing::list_child_dirs`) on a worker thread, reporting them back via /// `Command::TreeNodeExpanded`. A read failure (permission denied, a @@ -152,11 +809,28 @@ pub fn spawn_viewer_search(path: PathBuf, start_offset: u64, pattern: Vec, r /// node expanded, so a broken node simply shows no children instead of /// leaving the query outstanding forever (additional-panel-modes "Children /// read on expand"; design D7). -pub fn spawn_tree_expand(panel: PanelSide, path: PathBuf, tx: Sender) { - std::thread::spawn(move || { - let children = filecommand_core::listing::list_child_dirs(&StdFsReader, &path).unwrap_or_default(); - let _ = tx.send(Command::TreeNodeExpanded { panel, path, children }); - }); +pub fn spawn_tree_expand(panel: PanelSide, path: PathBuf, tx: Sender, token: WorkerToken) { + spawn_tree_expand_with(panel, path, tx, token, |path| filecommand_core::listing::list_child_dirs(&StdFsReader, path).unwrap_or_default()); +} + +/// `spawn_tree_expand` with the read injected (design D6): a panicked read +/// reports no children, the same reply as a read failure. +fn spawn_tree_expand_with(panel: PanelSide, path: PathBuf, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path) -> Vec + Send + 'static, +{ + let body_path = path.clone(); + spawn_worker( + token, + move || body(&body_path), + move |result| { + let children = result.unwrap_or_else(|msg| { + log_worker_panic("tree expansion", &msg); + vec![] + }); + deliver(&tx, "tree expansion", Command::TreeNodeExpanded { panel, path, children }); + }, + ); } /// Run the Alt+F7 subtree name search on a worker thread, streaming each @@ -165,41 +839,134 @@ pub fn spawn_tree_expand(panel: PanelSide, path: PathBuf, tx: Sender) { /// itself performs its own I/O per directory, so results reach the UI /// incrementally rather than only once the whole subtree has been walked /// (find-file "Non-blocking search with static progress", "UI stays -/// responsive during a large walk"). -pub fn spawn_find_subtree(root: PathBuf, pattern: String, request: u64, tx: Sender) { - std::thread::spawn(move || { - find_in_subtree(&StdFsReader, &root, &pattern, |m| { - let _ = tx.send(Command::FindFileMatch { request, m }); +/// responsive during a large walk"). `cancel` is the walk's stop signal +/// (find-file "Dismissed or superseded searches stop"; listing-request-guard +/// design D5): `find_in_subtree` checks it before each directory read and +/// between entries, and once set the walk returns and no further +/// `FindFileMatch` or `FindFileSearchDone` is sent. +pub fn spawn_find_subtree(root: PathBuf, pattern: String, request: u64, cancel: CancelFlag, tx: Sender, token: WorkerToken) { + let walk_cancel = cancel.clone(); + spawn_find_subtree_with(root, pattern, request, cancel, tx, token, move |root, pattern, tx| { + find_in_subtree(&StdFsReader, root, pattern, &walk_cancel, |m| { + if !walk_cancel.is_cancelled() { + deliver(tx, "find match", Command::FindFileMatch { request, m }); + } }); - let _ = tx.send(Command::FindFileSearchDone { request }); }); } -pub fn spawn_listing(panel: PanelSide, path: PathBuf, tx: Sender) { - std::thread::spawn(move || { - let mut total = 0usize; - - if parent_path(&path).is_some() { - total += 1; - if tx.send(Command::ListingChunk { panel, entries: vec![Entry::parent_dir()] }).is_err() { +/// `spawn_find_subtree` with the walk injected (process-lifecycle-hardening +/// design D6): whether the walk returns or panics, the search is reported +/// done, so the find dialog never shows a search still in progress — unless +/// it was cancelled, in which case nothing is sent (listing-request-guard +/// design D5). +fn spawn_find_subtree_with(root: PathBuf, pattern: String, request: u64, cancel: CancelFlag, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(&Path, &str, &Sender) + Send + 'static, +{ + let body_tx = tx.clone(); + spawn_worker( + token, + move || body(&root, &pattern, &body_tx), + move |result| { + if let Err(msg) = result { + log_worker_panic("find", &msg); + } + if cancel.is_cancelled() { return; } - } + deliver(&tx, "find done", Command::FindFileSearchDone { request }); + }, + ); +} - let reader = StdFsReader; - let result = list_dir_chunked(&reader, &path, CHUNK_SIZE, |chunk| { - let _ = tx.send(Command::ListingChunk { panel, entries: chunk }); - }); +/// Fulfil `Effect::StartListing`: stream `path`'s entries back as +/// `ListingChunk`s (a synthetic `..` first when the directory has a parent), +/// then `ListingComplete` or `ListingFailed`, every reply echoing `request` +/// so the reducer can match it against the panel's current listing +/// (panel-navigation "Superseded directory listings are discarded and +/// cancelled"; listing-request-guard design D1). `cancel` is the listing's +/// stop signal (design D5): checked before the `..` chunk, as every entry +/// arrives (inside `list_dir_streaming`, which breaks off the enumeration), +/// and before the final reply — once set, nothing further is sent, not even +/// a terminal reply, since the reducer has already superseded this id and +/// would drop it. Entries go out in batches bounded by a growing count and +/// ~50 ms of enumeration time (streaming-listing-performance design D2), so +/// the first entries and a live `Reading… N` count appear while the +/// directory is still being enumerated. +pub fn spawn_listing(panel: PanelSide, path: PathBuf, request: u64, cancel: CancelFlag, tx: Sender, token: WorkerToken) { + spawn_listing_with(panel, path, request, cancel, tx, token, read_listing); +} - match result { - Ok(fs_count) => { - let _ = tx.send(Command::ListingComplete { panel, total: total + fs_count }); - } - Err(e) => { - let _ = tx.send(Command::ListingFailed { panel, message: e.to_string() }); - } +/// The listing worker's body: stream the synthetic `..` and every chunk of +/// `path`, then return the closing message — `ListingComplete`, or +/// `ListingFailed` naming the directory. `None` when a chunk could not be +/// delivered (the event loop is gone), since nothing is listening for the +/// closing message either, and `None` once `cancel` is set, since the +/// reducer has superseded this request. +fn read_listing(panel: PanelSide, path: &Path, request: u64, cancel: &CancelFlag, tx: &Sender) -> Option { + let mut total = 0usize; + + if cancel.is_cancelled() { + return None; + } + if parent_path(path).is_some() { + total += 1; + if !deliver(tx, "listing chunk", Command::ListingChunk { panel, entries: vec![Entry::parent_dir()], request }) { + return None; } + } + + let reader = StdFsReader; + let result = list_dir_streaming(&reader, path, cancel, BatchPolicy::default(), &Instant::now, |chunk| { + deliver(tx, "listing chunk", Command::ListingChunk { panel, entries: chunk, request }); }); + + if cancel.is_cancelled() { + return None; + } + Some(match result { + Ok(totals) => Command::ListingComplete { panel, total: total + totals.entries, unreadable: totals.unreadable, request }, + // The message names the directory it could not read + // (filesystem-error-handling "Error messages name the affected + // path"), not just the bare OS error. + Err(e) => Command::ListingFailed { panel, message: listing_failed_message(path, &e), request }, + }) +} + +/// `spawn_listing` with the read injected (process-lifecycle-hardening +/// design D6): a panicked read ends as a failed listing naming the +/// directory, so the panel leaves `Reading… N` and shows the error instead +/// of waiting forever (application-shell "A panicked listing ends as a +/// failed listing"). A cancelled listing sends nothing, panicked or not. +fn spawn_listing_with(panel: PanelSide, path: PathBuf, request: u64, cancel: CancelFlag, tx: Sender, token: WorkerToken, body: B) +where + B: FnOnce(PanelSide, &Path, u64, &CancelFlag, &Sender) -> Option + Send + 'static, +{ + let body_tx = tx.clone(); + let body_path = path.clone(); + let body_cancel = cancel.clone(); + spawn_worker( + token, + move || body(panel, &body_path, request, &body_cancel, &body_tx), + move |result| { + let closing = match result { + Ok(closing) => closing, + Err(msg) => { + log_worker_panic("listing", &msg); + if cancel.is_cancelled() { + None + } else { + Some(Command::ListingFailed { panel, message: format!("Internal error while reading {}: {msg}", path.display()), request }) + } + } + }; + if let Some(cmd) = closing { + let kind = if matches!(cmd, Command::ListingFailed { .. }) { "listing failure" } else { "listing complete" }; + deliver(&tx, kind, cmd); + } + }, + ); } /// The main loop's answer to a conflict/error dialog the worker thread is @@ -210,51 +977,117 @@ pub enum JobReply { } /// What the main loop keeps for an in-flight job: a flag it can flip to -/// request cancellation, and a channel to forward dialog answers down to -/// the blocked worker thread. Not part of `core::State` — channels/flags -/// are TUI-only plumbing, not pure application state. +/// request cancellation, a channel to forward dialog answers down to the +/// blocked worker thread, and the thread itself so quit can wait for its +/// cancellation cleanup (application-shell "Quit waits for job cancellation +/// cleanup"; process-lifecycle-hardening design D3). Not part of +/// `core::State` — channels/flags/threads are TUI-only plumbing, not pure +/// application state. pub struct JobHandle { pub cancel: CancelFlag, pub reply_tx: Sender, + pub thread: JoinHandle<()>, } +/// How often a job waiting on a conflict or error answer re-checks its +/// cancel flag (design D5). Comfortably inside the 2 s quit budget. +const REPLY_POLL: Duration = Duration::from_millis(100); + struct ChannelSink { tx: Sender, reply_rx: mpsc::Receiver, cancel: CancelFlag, source_dir: PathBuf, dest_dir: PathBuf, + /// Rate-limits `progress` to ~20 updates a second (idle-aware-event-loop + /// "Progress updates are rate-limited"; design D7) — at the sink rather + /// than inside the engine, so every emitter (per-chunk copy progress, + /// per-file delete/move progress) is throttled in one place and + /// `run_job`'s own tests stay unthrottled and deterministic. + throttle: ProgressThrottle, +} + +impl ChannelSink { + /// Design D7's flush points: the newest held progress goes out ahead of + /// any message the user makes a decision on — or that ends the job — so + /// those moments never render stale file names or counts. + fn flush_progress(&mut self) { + if let Some(info) = self.throttle.flush() { + deliver(&self.tx, "job progress", Command::JobProgress(info)); + } + } + + /// Wait for the main loop's answer to the prompt just sent, observing + /// cancellation meanwhile (operation-dialogs "Cancellation is observed + /// while a job waits for an answer"; design D5): `None` when the cancel + /// flag was set before an answer arrived, or when the answer can no + /// longer arrive because the `JobHandle` — and its sender — is gone. + /// Either way the caller resolves the prompt as an abort, never as a + /// default Skip applied to this and later items. + fn await_reply(&self) -> Option { + loop { + match self.reply_rx.recv_timeout(REPLY_POLL) { + Ok(reply) => return Some(reply), + Err(RecvTimeoutError::Timeout) => { + if self.cancel.is_cancelled() { + return None; + } + } + Err(RecvTimeoutError::Disconnected) => return None, + } + } + } } impl JobSink for ChannelSink { fn progress(&mut self, info: ProgressInfo) { - let _ = self.tx.send(Command::JobProgress(info)); + if let Some(info) = self.throttle.offer(info, Instant::now()) { + deliver(&self.tx, "job progress", Command::JobProgress(info)); + } } fn conflict(&mut self, info: ConflictInfo) -> ConflictChoice { - if self.tx.send(Command::JobConflict(info)).is_err() { - return ConflictChoice::Skip; + self.flush_progress(); + // An undeliverable prompt means the event loop is gone: stop rather + // than continue unattended with a default answer. + if !deliver(&self.tx, "conflict prompt", Command::JobConflict(info)) { + return ConflictChoice::Cancel; } - // Blocks until the main loop answers via `JobHandle::reply_tx` after - // the user responds to the conflict dialog. - match self.reply_rx.recv() { - Ok(JobReply::Conflict(choice)) => choice, - _ => ConflictChoice::Skip, + match self.await_reply() { + Some(JobReply::Conflict(choice)) => choice, + // Cancelled or disconnected while waiting — or a reply of the + // wrong kind, which core never sends while a conflict is shown. + _ => ConflictChoice::Cancel, } } fn error(&mut self, info: ErrorInfo) -> ErrorChoice { - if self.tx.send(Command::JobError(info)).is_err() { + self.flush_progress(); + if !deliver(&self.tx, "error prompt", Command::JobError(info)) { return ErrorChoice::Abort; } - match self.reply_rx.recv() { - Ok(JobReply::Error(choice)) => choice, + match self.await_reply() { + Some(JobReply::Error(choice)) => choice, _ => ErrorChoice::Abort, } } fn done(&mut self, outcome: JobOutcome) { - let _ = self.tx.send(Command::JobDone { outcome, source_dir: self.source_dir.clone(), dest_dir: self.dest_dir.clone() }); + self.flush_progress(); + // Metadata-restore warnings the engine recorded are logged under + // `copy`, so a partial failure leaves a diagnostic trail even when + // the user dismisses the end-of-job summary unread + // (error-diagnostics design D3; operation-dialogs "Metadata-restore + // failures are reported in the end-of-job summary"). + let items = match &outcome { + JobOutcome::Completed { skipped } | JobOutcome::Cancelled { skipped } => skipped, + }; + for item in items { + if item.kind == ItemKind::Warning { + diag::log("copy", format_args!("{}: {}", item.path.display(), item.reason)); + } + } + deliver(&self.tx, "job outcome", Command::JobDone { outcome, source_dir: self.source_dir.clone(), dest_dir: self.dest_dir.clone() }); } fn is_cancelled(&self) -> bool { @@ -262,11 +1095,55 @@ impl JobSink for ChannelSink { } } +pub fn spawn_job(job: Job, tx: Sender, token: WorkerToken) -> JobHandle { + spawn_job_with(job, tx, token, |job, sink| { + let fs = RealFs::new(); + filecommand_core::fs_ops::run_job(job, &fs, sink); + }) +} + +/// `spawn_job` with the engine run injected (design D6): a panicked job +/// still ends — as `Cancelled` with one skipped item naming the internal +/// error against the source directory — so the progress or prompt dialog +/// closes, the panels re-read, and the end-of-job summary names the error +/// (application-shell "A panicked job ends and closes its dialog"). The +/// skipped list `run_job` had built up is lost with the unwound frame; the +/// sender is cloned before the sink takes it so the reply can still go out. +fn spawn_job_with(job: Job, tx: Sender, token: WorkerToken, body: B) -> JobHandle +where + B: FnOnce(&Job, &mut ChannelSink) + Send + 'static, +{ + let cancel = CancelFlag::new(); + let (reply_tx, reply_rx) = mpsc::channel::(); + let source_dir = job.source_dir.clone(); + let dest_dir = job.dest_dir.clone(); + let cancel_for_thread = cancel.clone(); + let thread = std::thread::spawn(move || { + let _token = token; + let failure_tx = tx.clone(); + let mut sink = ChannelSink { + tx, + reply_rx, + cancel: cancel_for_thread, + source_dir: source_dir.clone(), + dest_dir: dest_dir.clone(), + throttle: ProgressThrottle::new(), + }; + if let Err(msg) = guarded(move || body(&job, &mut sink)) { + log_worker_panic("job", &msg); + let skipped = vec![SkippedItem::skipped(source_dir.clone(), format!("internal error: {msg}"))]; + deliver(&failure_tx, "job outcome", Command::JobDone { outcome: JobOutcome::Cancelled { skipped }, source_dir, dest_dir }); + } + }); + JobHandle { cancel, reply_tx, thread } +} + #[cfg(test)] mod tests { use super::*; + use filecommand_core::editor::NotEditableReason; use filecommand_core::info::PENDING; - use filecommand_core::listing::RawDirEntry; + use filecommand_core::listing::{RawDirEntry, RawDirListing}; use std::io; use std::io::Write; @@ -284,7 +1161,7 @@ mod tests { fn spawn_viewer_search_reports_the_match_back_on_the_channel() { let path = temp_file("found", b"the quick brown fox jumps over the lazy dog"); let (tx, rx) = mpsc::channel(); - spawn_viewer_search(path, 0, b"brown".to_vec(), 7, tx); + spawn_viewer_search(path, 0, b"brown".to_vec(), 7, CancelFlag::new(), tx, WorkerGauge::new().token()); match rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(Command::ViewerSearchResult { offset, match_range, request }) => { assert_eq!(offset, Some(10)); @@ -299,7 +1176,7 @@ mod tests { fn spawn_viewer_search_reports_no_match_as_none() { let path = temp_file("not-found", b"the quick brown fox"); let (tx, rx) = mpsc::channel(); - spawn_viewer_search(path, 0, b"zebra".to_vec(), 1, tx); + spawn_viewer_search(path, 0, b"zebra".to_vec(), 1, CancelFlag::new(), tx, WorkerGauge::new().token()); match rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(Command::ViewerSearchResult { offset: None, match_range: None, request: 1 }) => {} other => panic!("expected a no-match ViewerSearchResult, got {other:?}"), @@ -310,36 +1187,75 @@ mod tests { fn spawn_viewer_search_on_an_unopenable_path_reports_no_match_instead_of_panicking() { let missing = std::env::temp_dir().join("filecommand-tui-worker-viewer-test-does-not-exist.bin"); let (tx, rx) = mpsc::channel(); - spawn_viewer_search(missing, 0, b"x".to_vec(), 1, tx); + spawn_viewer_search(missing, 0, b"x".to_vec(), 1, CancelFlag::new(), tx, WorkerGauge::new().token()); match rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(Command::ViewerSearchResult { offset: None, match_range: None, request: 1 }) => {} other => panic!("expected a no-match ViewerSearchResult, got {other:?}"), } } - struct FakeReader(io::Result>); + /// viewer: Instant open — "Closing the viewer releases the file": the + /// worker's `ByteSource` is a local that drops before the result is + /// delivered, so once a search with no match has scanned to the end of + /// the file, no FileCommand thread still holds it — the file and its + /// directory can be removed (an open handle blocks directory removal on + /// Windows). Cancelling a still-running search when the viewer closes + /// is `listing-request-guard`'s scope; this pins the release-on-return + /// half. + #[test] + fn spawn_viewer_search_releases_the_file_when_the_search_finishes() { + // Larger than one chunk and match-free, so the worker scans to the + // end of the file before returning. + let contents = vec![b'y'; 3 * DEFAULT_CHUNK_SIZE]; + let path = temp_file("release", &contents); + let dir = path.parent().unwrap().to_path_buf(); + let (tx, rx) = mpsc::channel(); + spawn_viewer_search(path.clone(), 0, b"zebra".to_vec(), 1, CancelFlag::new(), tx, WorkerGauge::new().token()); + match rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(Command::ViewerSearchResult { offset: None, match_range: None, request: 1 }) => {} + other => panic!("expected a no-match ViewerSearchResult, got {other:?}"), + } + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(&dir).unwrap(); + } + + struct FakeReader(io::Result); impl FsReader for FakeReader { - fn read_dir(&self, _path: &Path) -> io::Result> { + fn read_dir(&self, _path: &Path) -> io::Result { match &self.0 { - Ok(entries) => Ok(entries.clone()), + Ok(listing) => Ok(listing.clone()), Err(e) => Err(io::Error::new(e.kind(), e.to_string())), } } } fn raw(name: &str, is_dir: bool) -> RawDirEntry { - RawDirEntry { name: name.into(), is_dir, size: 0, modified: None } + RawDirEntry { name: name.into(), is_dir, size: 0, modified: None, hidden: false, link: false } } #[test] fn gather_info_counts_files_and_directories_separately() { - let reader = FakeReader(Ok(vec![raw("a.txt", false), raw("b.txt", false), raw("sub", true)])); + let reader = FakeReader(Ok(RawDirListing { entries: vec![raw("a.txt", false), raw("b.txt", false), raw("sub", true)], unreadable: 0 })); let values = gather_info(&reader, Path::new(r"C:\somewhere")); assert_eq!(values.file_count, Some(2)); assert_eq!(values.dir_count, Some(1)); } + #[test] + fn gather_info_counts_directory_links_as_directories_and_ignores_skipped_entries() { + // link-aware-listing design D6 (task 4.11): a junction to a + // directory is a directory for the Info counts, as it is for the + // panel, and entries the reader skipped as unreadable count as + // neither. + let dir_link = RawDirEntry { name: "lnk".into(), is_dir: true, size: 0, modified: None, hidden: false, link: true }; + let file_link = RawDirEntry { name: "f.lnk".into(), is_dir: false, size: 7, modified: None, hidden: false, link: true }; + let reader = FakeReader(Ok(RawDirListing { entries: vec![raw("a.txt", false), raw("sub", true), dir_link, file_link], unreadable: 3 })); + let values = gather_info(&reader, Path::new(r"C:\somewhere")); + assert_eq!(values.file_count, Some(2)); + assert_eq!(values.dir_count, Some(2)); + } + #[test] fn gather_info_always_resolves_every_field() { // Even on a path with no drive letter and an unreadable directory, @@ -352,43 +1268,324 @@ mod tests { let boxes = filecommand_core::info::info_boxes(&values, None); let rendered: Vec<&str> = boxes.iter().flat_map(|b| b.fields.iter()).map(|f| f.value.as_str()).collect(); - assert!(!rendered.iter().any(|v| *v == PENDING), "no field is left pending: {rendered:?}"); + assert!(!rendered.contains(&PENDING), "no field is left pending: {rendered:?}"); } #[test] fn race_with_timeout_returns_the_value_when_it_finishes_in_time() { let result = race_with_timeout(std::time::Duration::from_secs(5), || 42); - assert_eq!(result, Some(42)); + assert_eq!(result, Ok(42)); } #[test] - fn race_with_timeout_gives_up_and_returns_none_on_a_slow_call() { + fn race_with_timeout_gives_up_and_reports_timeout_on_a_slow_call() { let result = race_with_timeout(std::time::Duration::from_millis(20), || { std::thread::sleep(std::time::Duration::from_secs(5)); "too slow" }); - assert_eq!(result, None); + assert!(matches!(result, Err(RecvTimeoutError::Timeout))); } #[test] - fn spawn_find_subtree_streams_matches_then_reports_done() { - let dir = std::env::temp_dir().join(format!("filecommand-tui-worker-find-file-test-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("sub")).unwrap(); - std::fs::write(dir.join("report.txt"), b"x").unwrap(); - std::fs::write(dir.join("sub").join("report2.txt"), b"x").unwrap(); - std::fs::write(dir.join("other.txt"), b"x").unwrap(); + fn race_with_timeout_reports_a_panicked_call_as_disconnected_not_timeout() { + // design D2: a crash must be distinguishable from a genuine timeout. + let result = race_with_timeout(std::time::Duration::from_secs(5), || panic!("boom")); + assert!(matches!(result, Err(RecvTimeoutError::Disconnected))); + } - let (tx, rx) = mpsc::channel(); - spawn_find_subtree(dir.clone(), "report".to_string(), 7, tx); + // ----------------------------------------------------------------- + // git-info-timeout-memory: one query in flight per panel, and timeout + // memory (design D2/D3) + // ----------------------------------------------------------------- - let mut matched_names = Vec::new(); - let saw_done; - loop { - match rx.recv_timeout(std::time::Duration::from_secs(5)) { - Ok(Command::FindFileMatch { request, m }) => { - assert_eq!(request, 7); - matched_names.push(m.entry.name.to_string_lossy().into_owned()); + /// A scripted stand-in for the real per-directory git query: records the + /// order queries start in and runs a per-path script, so slot-runner + /// tests can block, publish roots, and panic on cue without libgit2. + struct FakeGitQuery { + started: Sender, + scripts: Mutex>, + } + + enum FakeScript { + /// Return this info immediately. + Ready(GitInfo), + /// Publish `root` as the discovered repository root, then block + /// until the gate fires (a slow status walk). + PublishThenBlock(PathBuf, mpsc::Receiver<()>), + /// Block on the gate without publishing anything (discovery itself + /// is the slow part). + Block(mpsc::Receiver<()>), + /// Die without answering (a panic inside the query). + Panic, + } + + impl FakeGitQuery { + fn new(started: Sender, scripts: Vec<(PathBuf, FakeScript)>) -> Arc { + Arc::new(FakeGitQuery { started, scripts: Mutex::new(scripts.into_iter().collect()) }) + } + + fn run(&self, path: &Path, published: &PublishedRoot) -> GitInfo { + let _ = self.started.send(path.to_path_buf()); + // Bound ahead of the match so the lock guard's temporary is + // dropped before any script runs — a `Panic` script must not + // poison the map for the next runner. + let script = self.scripts.lock().unwrap().remove(path); + match script { + Some(FakeScript::Ready(info)) => info, + Some(FakeScript::PublishThenBlock(root, gate)) => { + *published.lock().unwrap() = Some(root); + let _ = gate.recv(); + GitInfo { branch: Some("late".to_string()), ..Default::default() } + } + Some(FakeScript::Block(gate)) => { + let _ = gate.recv(); + GitInfo { branch: Some("late".to_string()), ..Default::default() } + } + Some(FakeScript::Panic) => panic!("scripted query panic"), + None => GitInfo::none(), + } + } + } + + fn fake_query(fake: &Arc) -> impl Fn(&Path, &PublishedRoot) -> GitInfo + Send + Sync + 'static { + let fake = fake.clone(); + move |path, published| fake.run(path, published) + } + + /// Receive the next `GitInfoResolved` reply, panicking on anything else. + fn recv_git_reply(rx: &mpsc::Receiver) -> (PathBuf, u64, GitInfo, Option) { + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Command::GitInfoResolved { panel: _, path, request, info, timed_out_root }) => (path, request, info, timed_out_root), + other => panic!("expected GitInfoResolved, got {other:?}"), + } + } + + #[test] + fn a_superseded_pending_request_is_never_started() { + // git-info "Rapid navigation does not pile up queries": with the + // first query still running, two more navigations queue; when the + // first reports, only the *last* queued directory is queried. + let (started_tx, started_rx) = mpsc::channel::(); + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let fake = FakeGitQuery::new( + started_tx, + vec![ + (PathBuf::from("/first"), FakeScript::Block(gate_rx)), + (PathBuf::from("/third"), FakeScript::Ready(GitInfo { branch: Some("third-branch".to_string()), ..Default::default() })), + ], + ); + let slot = GitQuerySlot::new(); + let (tx, rx) = mpsc::channel(); + let timeout = std::time::Duration::from_secs(5); + + slot.request_with(PanelSide::Left, PathBuf::from("/first"), 1, tx.clone(), WorkerGauge::new().token(), timeout, fake_query(&fake)); + assert_eq!(started_rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(PathBuf::from("/first")), "the first query starts"); + slot.request_with(PanelSide::Left, PathBuf::from("/second"), 2, tx.clone(), WorkerGauge::new().token(), timeout, fake_query(&fake)); + slot.request_with(PanelSide::Left, PathBuf::from("/third"), 3, tx.clone(), WorkerGauge::new().token(), timeout, fake_query(&fake)); + gate_tx.send(()).unwrap(); // let the first query finish within its timeout + + let (path, request, _info, timed_out_root) = recv_git_reply(&rx); + assert_eq!((path, request, timed_out_root), (PathBuf::from("/first"), 1, None), "the first query reports normally"); + assert_eq!(started_rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(PathBuf::from("/third")), "the newest queued request runs next"); + let (path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!((path, request, timed_out_root), (PathBuf::from("/third"), 3, None)); + assert_eq!(info.branch.as_deref(), Some("third-branch")); + assert!( + matches!(started_rx.recv_timeout(std::time::Duration::from_millis(300)), Err(RecvTimeoutError::Timeout)), + "the superseded second request is never started" + ); + assert!(rx.recv_timeout(std::time::Duration::from_millis(300)).is_err(), "and never answered"); + } + + #[test] + fn a_pending_request_inside_the_timed_out_repository_is_never_started() { + // git-info "Waiting query inside the timed-out repository is not + // started": the running query publishes root R and then times out; + // the request queued under R meanwhile is discarded rather than + // starting a second walk of the slow repository (design D3). + let (started_tx, started_rx) = mpsc::channel::(); + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let fake = FakeGitQuery::new(started_tx, vec![(PathBuf::from("/repo/src"), FakeScript::PublishThenBlock(PathBuf::from("/repo"), gate_rx))]); + let slot = GitQuerySlot::new(); + let (tx, rx) = mpsc::channel(); + let timeout = std::time::Duration::from_millis(300); + + slot.request_with(PanelSide::Left, PathBuf::from("/repo/src"), 1, tx.clone(), WorkerGauge::new().token(), timeout, fake_query(&fake)); + assert_eq!(started_rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(PathBuf::from("/repo/src"))); + // Queued while the first query is still walking (before it times + // out), inside the about-to-be-remembered repository. + slot.request_with(PanelSide::Left, PathBuf::from("/repo/docs"), 2, tx, WorkerGauge::new().token(), timeout, fake_query(&fake)); + + let (_path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!(request, 1); + assert_eq!(info, GitInfo::none(), "a timeout degrades to no info"); + assert_eq!(timed_out_root, Some(PathBuf::from("/repo")), "the published root is reported"); + assert!( + matches!(started_rx.recv_timeout(std::time::Duration::from_millis(500)), Err(RecvTimeoutError::Timeout)), + "the queued request inside the timed-out repository never starts" + ); + assert!(rx.recv_timeout(std::time::Duration::from_millis(300)).is_err(), "and it is never answered"); + drop(gate_tx); // let the orphaned fake walk finish + } + + #[test] + fn a_timeout_reports_the_published_repository_root() { + // design D2: discovery finished — the root was published — before + // the status walk stalled. + let (started_tx, _started_rx) = mpsc::channel::(); + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let fake = FakeGitQuery::new(started_tx, vec![(PathBuf::from("/repo/src"), FakeScript::PublishThenBlock(PathBuf::from("/repo"), gate_rx))]); + let slot = GitQuerySlot::new(); + let (tx, rx) = mpsc::channel(); + slot.request_with( + PanelSide::Left, + PathBuf::from("/repo/src"), + 1, + tx, + WorkerGauge::new().token(), + std::time::Duration::from_millis(100), + fake_query(&fake), + ); + + let (path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!(path, PathBuf::from("/repo/src")); + assert_eq!(request, 1); + assert_eq!(info, GitInfo::none()); + assert_eq!(timed_out_root, Some(PathBuf::from("/repo"))); + drop(gate_tx); + } + + #[test] + fn a_timeout_before_discovery_finishes_reports_the_queried_directory() { + // git-info "Repository not located before the timeout": with no + // root published, the queried directory itself is reported (only it + // and its descendants are remembered). + let (started_tx, _started_rx) = mpsc::channel::(); + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let fake = FakeGitQuery::new(started_tx, vec![(PathBuf::from("/slow/deep"), FakeScript::Block(gate_rx))]); + let slot = GitQuerySlot::new(); + let (tx, rx) = mpsc::channel(); + slot.request_with( + PanelSide::Left, + PathBuf::from("/slow/deep"), + 1, + tx, + WorkerGauge::new().token(), + std::time::Duration::from_millis(100), + fake_query(&fake), + ); + + let (path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!(path, PathBuf::from("/slow/deep")); + assert_eq!(request, 1); + assert_eq!(info, GitInfo::none()); + assert_eq!(timed_out_root, Some(PathBuf::from("/slow/deep")), "the queried directory stands in for the unlocated root"); + drop(gate_tx); + } + + #[test] + fn a_panicked_query_reports_no_timed_out_root_and_leaves_the_slot_idle() { + // design D2/D3: a crash is not a timeout (`timed_out_root: None`), + // and the slot is usable for the next request afterwards. + let (started_tx, started_rx) = mpsc::channel::(); + let fake = FakeGitQuery::new( + started_tx, + vec![ + (PathBuf::from("/panic"), FakeScript::Panic), + (PathBuf::from("/ok"), FakeScript::Ready(GitInfo { branch: Some("ok-branch".to_string()), ..Default::default() })), + ], + ); + let slot = GitQuerySlot::new(); + let (tx, rx) = mpsc::channel(); + let timeout = std::time::Duration::from_secs(5); + + slot.request_with(PanelSide::Left, PathBuf::from("/panic"), 1, tx.clone(), WorkerGauge::new().token(), timeout, fake_query(&fake)); + assert_eq!(started_rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(PathBuf::from("/panic")), "the first query starts"); + let (_path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!(request, 1); + assert_eq!(info, GitInfo::none()); + assert_eq!(timed_out_root, None, "a panic is not a timeout"); + + // With nothing queued, the runner marks the slot idle and exits. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while slot.state.lock().unwrap().running { + assert!(std::time::Instant::now() < deadline, "the slot is marked idle after the panicked query"); + std::thread::yield_now(); + } + + slot.request_with(PanelSide::Left, PathBuf::from("/ok"), 2, tx, WorkerGauge::new().token(), timeout, fake_query(&fake)); + assert_eq!(started_rx.recv_timeout(std::time::Duration::from_secs(5)), Ok(PathBuf::from("/ok")), "an idle slot serves the next request"); + let (_path, request, info, timed_out_root) = recv_git_reply(&rx); + assert_eq!(request, 2); + assert_eq!(info.branch.as_deref(), Some("ok-branch")); + assert_eq!(timed_out_root, None); + } + + // ------------------------------------------------------------- + // error-diagnostics: the listing failure message names the directory + // (filesystem-error-handling "Error messages name the affected path"; + // task 4.3) + // ------------------------------------------------------------- + + #[test] + fn listing_failed_message_names_the_directory() { + // Scenario "Access-denied listing names the directory": `Cannot + // read : `. + let err = io::Error::new(io::ErrorKind::PermissionDenied, "Access is denied. (os error 5)"); + let dir = Path::new("Windows").join("CSC"); + assert_eq!(listing_failed_message(&dir, &err), "Cannot read CSC: Access is denied. (os error 5)"); + } + + #[test] + fn listing_failed_message_keeps_the_whole_path_for_a_root() { + // A drive root (`C:\`) or share root (`\\server\share`) has no file + // name, so the whole path stands in (design D4). + let root = if cfg!(windows) { r"C:\" } else { "/" }; + let err = io::Error::new(io::ErrorKind::PermissionDenied, "denied"); + assert_eq!(listing_failed_message(Path::new(root), &err), format!("Cannot read {root}: denied")); + } + + #[test] + fn spawn_listing_of_a_missing_directory_reports_cannot_read_naming_it() { + // End to end through the worker thread: the `ListingFailed` command + // carries the prefixed message, not a bare OS error. + let dir = std::env::temp_dir().join(format!("filecommand-tui-worker-listing-fail-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let name = dir.file_name().unwrap().to_string_lossy().into_owned(); + let (tx, rx) = mpsc::channel(); + spawn_listing(PanelSide::Left, dir, 7, CancelFlag::new(), tx, WorkerGauge::new().token()); + loop { + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Command::ListingChunk { .. }) => continue, // the synthetic `..` entry + Ok(Command::ListingFailed { panel, message, .. }) => { + assert_eq!(panel, PanelSide::Left); + assert!(message.starts_with(&format!("Cannot read {name}: ")), "names the directory and the OS reason: {message}"); + break; + } + other => panic!("expected ListingChunk or ListingFailed, got {other:?}"), + } + } + } + + #[test] + fn spawn_find_subtree_streams_matches_then_reports_done() { + let dir = std::env::temp_dir().join(format!("filecommand-tui-worker-find-file-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write(dir.join("report.txt"), b"x").unwrap(); + std::fs::write(dir.join("sub").join("report2.txt"), b"x").unwrap(); + std::fs::write(dir.join("other.txt"), b"x").unwrap(); + + let (tx, rx) = mpsc::channel(); + spawn_find_subtree(dir.clone(), "report".to_string(), 7, CancelFlag::new(), tx, WorkerGauge::new().token()); + + let mut matched_names = Vec::new(); + let saw_done; + loop { + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Command::FindFileMatch { request, m }) => { + assert_eq!(request, 7); + matched_names.push(m.entry.name.to_string_lossy().into_owned()); } Ok(Command::FindFileSearchDone { request }) => { assert_eq!(request, 7); @@ -403,19 +1600,659 @@ mod tests { assert!(saw_done); let _ = std::fs::remove_dir_all(&dir); } -} -pub fn spawn_job(job: Job, tx: Sender) -> JobHandle { - let cancel = CancelFlag::new(); - let (reply_tx, reply_rx) = mpsc::channel::(); - let handle = JobHandle { cancel: cancel.clone(), reply_tx }; - let source_dir = job.source_dir.clone(); - let dest_dir = job.dest_dir.clone(); - let cancel_for_thread = cancel; - std::thread::spawn(move || { - let fs = RealFs::new(); - let mut sink = ChannelSink { tx, reply_rx, cancel: cancel_for_thread, source_dir, dest_dir }; - filecommand_core::fs_ops::run_job(&job, &fs, &mut sink); - }); - handle + // ------------------------------------------------------------- + // listing-request-guard: workers echo the request id and go silent + // once cancelled (design D1/D5; task 5.13) + // ------------------------------------------------------------- + + fn listing_fixture(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-tui-worker-guard-test-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::write(dir.join("report.txt"), b"x").unwrap(); + std::fs::write(dir.join("other.txt"), b"x").unwrap(); + dir + } + + /// A cancelled worker sends nothing: the first thing the receiver sees + /// is the channel closing when the thread returns — never a message. + fn assert_silent(rx: &mpsc::Receiver) { + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Err(RecvTimeoutError::Disconnected) | Err(RecvTimeoutError::Timeout) => {} + Ok(msg) => panic!("a cancelled worker must send nothing, got {msg:?}"), + } + } + + #[test] + fn spawn_listing_echoes_the_request_id_on_every_reply() { + let dir = listing_fixture("echo"); + let (tx, rx) = mpsc::channel(); + spawn_listing(PanelSide::Left, dir.clone(), 42, CancelFlag::new(), tx, WorkerGauge::new().token()); + let mut chunks = 0; + loop { + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(Command::ListingChunk { panel: PanelSide::Left, request, .. }) => { + assert_eq!(request, 42); + chunks += 1; + } + Ok(Command::ListingComplete { panel: PanelSide::Left, total, unreadable, request }) => { + assert_eq!(request, 42); + assert_eq!(total, 4, "`..` plus three entries"); + assert_eq!(unreadable, 0); + break; + } + other => panic!("unexpected message: {other:?}"), + } + } + assert!(chunks >= 2, "the synthetic `..` chunk and at least one directory chunk"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn spawn_listing_with_a_pre_cancelled_flag_sends_nothing() { + let dir = listing_fixture("cancelled"); + let (tx, rx) = mpsc::channel(); + let cancel = CancelFlag::new(); + cancel.cancel(); + spawn_listing(PanelSide::Left, dir.clone(), 7, cancel, tx, WorkerGauge::new().token()); + assert_silent(&rx); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn spawn_find_subtree_with_a_pre_cancelled_flag_sends_nothing() { + let dir = listing_fixture("find-cancelled"); + let (tx, rx) = mpsc::channel(); + let cancel = CancelFlag::new(); + cancel.cancel(); + spawn_find_subtree(dir.clone(), "report".to_string(), 7, cancel, tx, WorkerGauge::new().token()); + assert_silent(&rx); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn spawn_viewer_search_with_a_pre_cancelled_flag_sends_nothing() { + let path = temp_file("search-cancelled", b"the quick brown fox"); + let (tx, rx) = mpsc::channel(); + let cancel = CancelFlag::new(); + cancel.cancel(); + spawn_viewer_search(path, 0, b"brown".to_vec(), 7, cancel, tx, WorkerGauge::new().token()); + assert_silent(&rx); + } + + // ------------------------------------------------------------- + // idle-aware-event-loop: the worker gauge (design D3; task 2.1) and + // the sink's progress throttle (file-operations "Progress updates are + // rate-limited"; design D7; task 4.2) + // ------------------------------------------------------------- + + #[test] + fn the_gauge_counts_live_tokens_and_a_panicked_thread_releases_its() { + let gauge = WorkerGauge::new(); + assert_eq!(gauge.live(), 0); + let token = gauge.token(); + assert_eq!(gauge.live(), 1); + let panicked = std::thread::spawn(move || { + let _token = token; + panic!("scripted worker panic"); + }) + .join(); + assert!(panicked.is_err()); + assert_eq!(gauge.live(), 0, "the token's Drop decrements even on panic"); + } + + /// A `ChannelSink` over a recording channel, with the reply channel's + /// sender returned so conflict/error answers can be pre-loaded. + fn recording_sink() -> (ChannelSink, Sender, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(); + let (reply_tx, reply_rx) = mpsc::channel(); + let sink = ChannelSink { + tx, + reply_rx, + cancel: CancelFlag::new(), + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst"), + throttle: ProgressThrottle::new(), + }; + (sink, reply_tx, rx) + } + + fn progress(files_done: usize, bytes_done: u64, name: &str) -> ProgressInfo { + ProgressInfo { files_done, files_total: 3, bytes_done, bytes_total: 6_000, current_file: std::ffi::OsString::from(name) } + } + + #[test] + fn a_fast_job_flushes_its_final_progress_before_done() { + // file-operations "The final progress is always delivered": three + // files' progress inside one 50 ms interval is held by the throttle, + // and `done` pushes the newest figures ahead of `JobDone`. + let (mut sink, _reply_tx, rx) = recording_sink(); + sink.progress(progress(1, 2_000, "a.txt")); + sink.progress(progress(2, 4_000, "b.txt")); + sink.progress(progress(3, 6_000, "c.txt")); + sink.done(JobOutcome::Completed { skipped: vec![] }); + + let msgs: Vec = rx.try_iter().collect(); + assert_eq!( + msgs, + vec![ + Command::JobProgress(progress(1, 2_000, "a.txt")), + Command::JobProgress(progress(3, 6_000, "c.txt")), + Command::JobDone { + outcome: JobOutcome::Completed { skipped: vec![] }, + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst") + }, + ], + "the first update goes straight out, the newest held one precedes JobDone, the middle one is dropped" + ); + } + + #[test] + fn a_conflict_is_preceded_by_the_pending_progress() { + // file-operations "Progress is current when a conflict is asked": + // the held `a.txt` update reaches the UI ahead of the question. + let (mut sink, reply_tx, rx) = recording_sink(); + sink.progress(progress(1, 2_000, "a.txt")); + sink.progress(progress(2, 4_000, "b.txt")); + let conflict = ConflictInfo { + source_name: std::ffi::OsString::from("b.txt"), + source_size: 2_000, + source_modified: None, + target_path: PathBuf::from("/dst/b.txt"), + target_size: 1_000, + target_modified: None, + }; + // Pre-load the answer so the blocking `conflict` returns at once. + reply_tx.send(JobReply::Conflict(ConflictChoice::Skip)).unwrap(); + assert_eq!(sink.conflict(conflict.clone()), ConflictChoice::Skip); + + let msgs: Vec = rx.try_iter().collect(); + assert_eq!( + msgs, + vec![ + Command::JobProgress(progress(1, 2_000, "a.txt")), + Command::JobProgress(progress(2, 4_000, "b.txt")), + Command::JobConflict(conflict), + ], + "the held update is flushed before the conflict question" + ); + } + + // ------------------------------------------------------------- + // process-lifecycle-hardening: cancellation is observed while a job + // waits for an answer (operation-dialogs; design D5; task 3.2) + // ------------------------------------------------------------- + + fn sample_conflict() -> ConflictInfo { + ConflictInfo { + source_name: std::ffi::OsString::from("b.txt"), + source_size: 2_000, + source_modified: None, + target_path: PathBuf::from("/dst/b.txt"), + target_size: 1_000, + target_modified: None, + } + } + + fn sample_error() -> ErrorInfo { + ErrorInfo { path: PathBuf::from("/src/locked.db"), message: "in use".to_string() } + } + + /// Set the sink's cancel flag from another thread after `delay`. + fn cancel_after(cancel: &CancelFlag, delay: Duration) { + let cancel = cancel.clone(); + std::thread::spawn(move || { + std::thread::sleep(delay); + cancel.cancel(); + }); + } + + #[test] + fn a_cancel_request_while_waiting_on_a_conflict_resolves_it_as_cancel() { + // Scenario "A cancel request ends a job waiting on a conflict + // prompt": no answer is ever sent; the flag alone ends the wait, + // within about two poll intervals. + let (mut sink, _reply_tx, _rx) = recording_sink(); + cancel_after(&sink.cancel, Duration::from_millis(50)); + let started = Instant::now(); + assert_eq!(sink.conflict(sample_conflict()), ConflictChoice::Cancel); + assert!(started.elapsed() < Duration::from_millis(500), "the wait ends promptly after the flag is set, took {:?}", started.elapsed()); + } + + #[test] + fn a_cancel_request_while_waiting_on_an_error_resolves_it_as_abort() { + // Scenario "A cancel request ends a job waiting on an error prompt". + let (mut sink, _reply_tx, _rx) = recording_sink(); + cancel_after(&sink.cancel, Duration::from_millis(50)); + let started = Instant::now(); + assert_eq!(sink.error(sample_error()), ErrorChoice::Abort); + assert!(started.elapsed() < Duration::from_millis(500), "took {:?}", started.elapsed()); + } + + #[test] + fn a_dropped_reply_sender_resolves_a_conflict_as_cancel_and_an_error_as_abort() { + // Scenario "A lost answer does not resume the job with a default": + // the `JobHandle` is gone, so neither prompt can ever be answered. + let (mut sink, reply_tx, _rx) = recording_sink(); + drop(reply_tx); + assert_eq!(sink.conflict(sample_conflict()), ConflictChoice::Cancel, "not the old default Skip"); + assert_eq!(sink.error(sample_error()), ErrorChoice::Abort); + } + + #[test] + fn a_normal_answer_is_still_returned_unchanged() { + let (mut sink, reply_tx, _rx) = recording_sink(); + reply_tx.send(JobReply::Conflict(ConflictChoice::Rename(std::ffi::OsString::from("b2.txt")))).unwrap(); + assert_eq!(sink.conflict(sample_conflict()), ConflictChoice::Rename(std::ffi::OsString::from("b2.txt"))); + reply_tx.send(JobReply::Error(ErrorChoice::Retry)).unwrap(); + assert_eq!(sink.error(sample_error()), ErrorChoice::Retry); + } + + #[test] + fn an_answer_arriving_after_a_poll_interval_is_still_delivered() { + // The timeout loop keeps waiting while the flag is clear: an answer + // that takes longer than one poll interval still arrives. + let (mut sink, reply_tx, _rx) = recording_sink(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(250)); + let _ = reply_tx.send(JobReply::Conflict(ConflictChoice::Overwrite)); + }); + assert_eq!(sink.conflict(sample_conflict()), ConflictChoice::Overwrite); + } + + // ------------------------------------------------------------- + // process-lifecycle-hardening: worker panics become each worker's + // defined failure reply (application-shell "Panic hook restores the + // terminal before reporting"; design D6; task 5.4) + // ------------------------------------------------------------- + + #[test] + fn guarded_returns_the_value_or_the_panic_message() { + assert_eq!(guarded(|| 7), Ok(7)); + assert_eq!(guarded::<()>(|| panic!("plain str")), Err("plain str".to_string())); + let n = 3; + assert_eq!(guarded::<()>(|| panic!("formatted {n}")), Err("formatted 3".to_string())); + assert_eq!(guarded::<()>(|| std::panic::panic_any(42u8)), Err("unknown panic".to_string())); + } + + fn recv(rx: &mpsc::Receiver) -> Command { + rx.recv_timeout(Duration::from_secs(5)).expect("the worker replies even after a panic") + } + + // ------------------------------------------------------------- + // offload-blocking-io: the command-line probe, the F3/F4 opens, the + // editor save and the launch worker (tasks 5.6, 5.7, 5.8; design D1, + // D2, D4, D6, D7, D8) + // ------------------------------------------------------------- + + /// A fresh, empty per-test directory under the system temp dir. + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filecommand-tui-worker-offload-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn probe(path: PathBuf, request: u64) -> ProbeKind { + let (tx, rx) = mpsc::channel(); + spawn_path_probe(request, path, tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::PathProbed { request: got, kind } => { + assert_eq!(got, request, "the request id is echoed back unchanged"); + kind + } + other => panic!("expected PathProbed, got {other:?}"), + } + } + + /// Design D2: a directory, a file and a missing path map to the three + /// kinds. + #[test] + fn spawn_path_probe_reports_dir_file_and_missing() { + let dir = scratch_dir("probe"); + let file = dir.join("notes.txt"); + std::fs::write(&file, b"x").unwrap(); + assert_eq!(probe(dir.clone(), 1), ProbeKind::Dir); + assert_eq!(probe(file, 2), ProbeKind::File); + assert_eq!(probe(dir.join("nope"), 3), ProbeKind::Missing); + let _ = std::fs::remove_dir_all(&dir); + } + + /// command-line "cd to a path longer than 260 characters": the probe + /// goes through `to_extended_path`, so a directory whose absolute path + /// exceeds `MAX_PATH` is found rather than reported missing. + #[cfg(windows)] + #[test] + fn spawn_path_probe_finds_a_directory_whose_path_exceeds_260_characters() { + let root = scratch_dir("long"); + let deep = root.join("a".repeat(120)).join("b".repeat(120)).join("c".repeat(60)); + assert!(deep.as_os_str().len() > 260, "precondition: {} characters", deep.as_os_str().len()); + std::fs::create_dir_all(to_extended_path(&deep)).expect("created through the \\\\?\\ prefix"); + assert_eq!(probe(deep, 9), ProbeKind::Dir); + let _ = std::fs::remove_dir_all(to_extended_path(&root)); + } + + /// Design D8: a panicked probe reports `Missing`, with the request id. + #[test] + fn a_panicked_probe_reports_missing() { + let (tx, rx) = mpsc::channel(); + spawn_path_probe_with(4, tx, WorkerGauge::new().token(), || panic!("scripted probe panic")); + assert_eq!(recv(&rx), Command::PathProbed { request: 4, kind: ProbeKind::Missing }); + } + + /// Design D4: the source is on the hand-off channel before the + /// `ViewerOpened` reply is sent, under the same request id. + #[test] + fn spawn_viewer_open_hands_the_source_over_before_viewer_opened() { + let path = temp_file("viewer-open", b"hello world"); + let (tx, rx) = mpsc::channel(); + let (handoff_tx, handoff_rx) = mpsc::channel(); + spawn_viewer_open(4, path.clone(), handoff_tx, tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::ViewerOpened { request: 4, path: opened, file_len: 11 } => assert_eq!(opened, path), + other => panic!("expected ViewerOpened, got {other:?}"), + } + let (request, handed_path, source) = handoff_rx.try_recv().expect("the source was queued before the command"); + assert_eq!(request, 4); + assert_eq!(handed_path, path); + assert_eq!(source.len(), 11); + } + + #[test] + fn spawn_viewer_open_on_a_missing_file_reports_viewer_open_failed() { + let missing = std::env::temp_dir().join("filecommand-tui-worker-offload-missing.bin"); + let (tx, rx) = mpsc::channel(); + let (handoff_tx, handoff_rx) = mpsc::channel(); + spawn_viewer_open(5, missing, handoff_tx, tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::ViewerOpenFailed { request: 5, message } => { + assert!(message.starts_with("Cannot open filecommand-tui-worker-offload-missing.bin:"), "{message}"); + } + other => panic!("expected ViewerOpenFailed, got {other:?}"), + } + assert!(handoff_rx.try_recv().is_err(), "nothing is handed over on failure"); + } + + /// Design D8: a panicked open reports `ViewerOpenFailed`. + #[test] + fn a_panicked_viewer_open_reports_viewer_open_failed() { + let (tx, rx) = mpsc::channel(); + let (handoff_tx, _handoff_rx) = mpsc::channel(); + spawn_viewer_open_with(6, PathBuf::from("/x/slow.log"), handoff_tx, tx, WorkerGauge::new().token(), |_| panic!("scripted open panic")); + match recv(&rx) { + Command::ViewerOpenFailed { request: 6, message } => assert_eq!(message, "Internal error while opening slow.log: scripted open panic"), + other => panic!("expected ViewerOpenFailed, got {other:?}"), + } + } + + /// Design D4: `spawn_editor_open` wraps `EditorState::open` with the + /// same replies as before plus the request id. + #[test] + fn spawn_editor_open_reports_loaded_not_editable_too_large_and_failed() { + let dir = scratch_dir("editor-open"); + let text = dir.join("notes.txt"); + std::fs::write(&text, b"abc\n").unwrap(); + let (tx, rx) = mpsc::channel(); + spawn_editor_open(8, text.clone(), tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::EditorOpened { request: 8, editor } => { + assert_eq!(editor.path, text); + assert_eq!(editor.lines, vec!["abc".to_string(), String::new()]); + } + other => panic!("expected EditorOpened, got {other:?}"), + } + + let binary = dir.join("tool.exe"); + std::fs::write(&binary, b"MZ\0\0").unwrap(); + let (tx, rx) = mpsc::channel(); + spawn_editor_open(9, binary.clone(), tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::EditorNotEditable { request: 9, path, reason: NotEditableReason::Binary } => assert_eq!(path, binary), + other => panic!("expected EditorNotEditable, got {other:?}"), + } + + let (tx, rx) = mpsc::channel(); + spawn_editor_open_with(10, dir.join("huge.log"), tx, WorkerGauge::new().token(), |_| Ok(LoadResult::TooLarge { size: 20_000_000 })); + assert_eq!(recv(&rx), Command::EditorTooLarge { request: 10, path: dir.join("huge.log"), size: 20_000_000 }); + + let (tx, rx) = mpsc::channel(); + spawn_editor_open(11, dir.join("nope.txt"), tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::EditorOpenFailed { request: 11, message } => assert!(message.starts_with("Cannot open nope.txt:"), "{message}"), + other => panic!("expected EditorOpenFailed, got {other:?}"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// Design D8: a panicked load reports `EditorOpenFailed`. + #[test] + fn a_panicked_editor_open_reports_editor_open_failed() { + let (tx, rx) = mpsc::channel(); + spawn_editor_open_with(12, PathBuf::from("/x/notes.txt"), tx, WorkerGauge::new().token(), |_| panic!("scripted load panic")); + match recv(&rx) { + Command::EditorOpenFailed { request: 12, message } => assert_eq!(message, "Internal error while opening notes.txt: scripted load panic"), + other => panic!("expected EditorOpenFailed, got {other:?}"), + } + } + + /// Design D6: the save runs on the worker and replies with the post-save + /// state and `then_quit` unchanged. + #[test] + fn spawn_editor_save_writes_the_file_and_replies_with_the_saved_state() { + let dir = scratch_dir("editor-save"); + let path = dir.join("notes.txt"); + std::fs::write(&path, b"abc\n").unwrap(); + let mut editor = EditorState::from_bytes(path.clone(), b"abc\n").unwrap(); + editor.type_char('X'); + let (tx, rx) = mpsc::channel(); + spawn_editor_save(Box::new(editor), true, tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::EditorSaved { editor, then_quit: true } => { + assert!(!editor.is_modified(), "the reply carries the clean post-save state"); + assert_eq!(editor.path, path); + } + other => panic!("expected EditorSaved, got {other:?}"), + } + assert_eq!(std::fs::read_to_string(&path).unwrap(), "Xabc\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A failed or panicked save reports `EditorSaveFailed` naming the file + /// (design D6, D8). + #[test] + fn a_failed_or_panicked_editor_save_reports_editor_save_failed() { + let editor = EditorState::from_bytes(PathBuf::from("/x/edit.txt"), b"abc\n").unwrap(); + let (tx, rx) = mpsc::channel(); + spawn_editor_save_with(Box::new(editor.clone()), false, tx, WorkerGauge::new().token(), |_| { + Err(io::Error::new(io::ErrorKind::StorageFull, "disk full")) + }); + assert_eq!(recv(&rx), Command::EditorSaveFailed { message: "Cannot save edit.txt: disk full".to_string() }); + + let (tx, rx) = mpsc::channel(); + spawn_editor_save_with(Box::new(editor), false, tx, WorkerGauge::new().token(), |_| panic!("scripted save panic")); + assert_eq!(recv(&rx), Command::EditorSaveFailed { message: "Internal error while saving edit.txt: scripted save panic".to_string() }); + } + + /// Design D7: `CoUninitialize` is called only after an initialization + /// that returned `S_OK` or `S_FALSE`, and always after the body. + #[test] + fn com_bracket_uninitializes_only_after_a_successful_initialization() { + use std::cell::RefCell; + const RPC_E_CHANGED_MODE: i32 = 0x8001_0106_u32 as i32; + for (hr, expect_uninit) in [(COM_S_OK, true), (COM_S_FALSE, true), (RPC_E_CHANGED_MODE, false), (-1, false)] { + let log = RefCell::new(Vec::new()); + let out = com_bracket( + || { + log.borrow_mut().push("init"); + hr + }, + || log.borrow_mut().push("uninit"), + || { + log.borrow_mut().push("body"); + 42 + }, + ); + assert_eq!(out, 42, "the body's value comes back"); + let expected: Vec<&str> = if expect_uninit { vec!["init", "body", "uninit"] } else { vec!["init", "body"] }; + assert_eq!(*log.borrow(), expected, "hr = {hr:#x}"); + } + } + + /// application-shell "Launching a slow handler leaves the panels + /// interactive": the failure carries the invoking side. A missing file + /// never launches anything, so the real `ShellExecuteW` path is safe to + /// exercise here. + #[cfg(windows)] + #[test] + fn spawn_open_with_association_on_a_missing_file_reports_the_failure_on_the_original_side() { + let dir = scratch_dir("open-with"); + let missing = dir.join("missing-report.pdf"); + let (tx, rx) = mpsc::channel(); + spawn_open_with_association(PanelSide::Right, missing, dir.clone(), tx, WorkerGauge::new().token()); + match recv(&rx) { + Command::OpenWithAssociationFailed { side: PanelSide::Right, message } => { + assert!(message.contains("missing-report.pdf"), "the failure names the target: {message}"); + } + other => panic!("expected OpenWithAssociationFailed for the right panel, got {other:?}"), + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// A successful launch sends nothing (fire-and-forget); a panicked one + /// reports a failure on the invoking panel (design D7, D8). + #[test] + fn open_with_association_worker_is_silent_on_success_and_reports_a_panic() { + let (tx, rx) = mpsc::channel(); + spawn_open_with_association_with(PanelSide::Left, PathBuf::from("/x/report.pdf"), tx, WorkerGauge::new().token(), |_| Ok(())); + assert!(rx.recv_timeout(Duration::from_millis(300)).is_err(), "a successful launch reports nothing"); + + let (tx, rx) = mpsc::channel(); + spawn_open_with_association_with(PanelSide::Left, PathBuf::from("/x/report.pdf"), tx, WorkerGauge::new().token(), |_| { + panic!("scripted launch panic") + }); + assert_eq!( + recv(&rx), + Command::OpenWithAssociationFailed { + side: PanelSide::Left, + message: "Could not open report.pdf (internal error: scripted launch panic)".to_string() + } + ); + } + + #[test] + fn a_panicked_listing_ends_as_a_failed_listing_naming_the_path() { + let (tx, rx) = mpsc::channel(); + let gauge = WorkerGauge::new(); + let dir = PathBuf::from(if cfg!(windows) { r"C:\data\reports" } else { "/data/reports" }); + spawn_listing_with(PanelSide::Right, dir.clone(), 7, CancelFlag::new(), tx, gauge.token(), |_, _, _, _, _| panic!("scripted listing panic")); + match recv(&rx) { + Command::ListingFailed { panel, message, request } => { + assert_eq!(panel, PanelSide::Right); + assert_eq!(request, 7, "the failure echoes the listing request id"); + assert_eq!(message, format!("Internal error while reading {}: scripted listing panic", dir.display())); + } + other => panic!("expected ListingFailed, got {other:?}"), + } + assert!(rx.recv_timeout(Duration::from_millis(200)).is_err(), "nothing else follows"); + } + + #[test] + fn a_panicked_job_ends_cancelled_with_an_internal_error_item() { + let (tx, rx) = mpsc::channel(); + let job = Job { + kind: filecommand_core::fs_ops::JobKind::Copy, + sources: vec![], + source_dir: PathBuf::from("/src"), + dest_dir: PathBuf::from("/dst"), + new_dir_name: None, + }; + let handle = spawn_job_with(job, tx, WorkerGauge::new().token(), |_, _| panic!("scripted job panic")); + match recv(&rx) { + Command::JobDone { outcome: JobOutcome::Cancelled { skipped }, source_dir, dest_dir } => { + assert_eq!((source_dir, dest_dir), (PathBuf::from("/src"), PathBuf::from("/dst"))); + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].path, PathBuf::from("/src")); + assert_eq!(skipped[0].reason, "internal error: scripted job panic"); + assert_eq!(skipped[0].kind, ItemKind::Skipped); + } + other => panic!("expected a Cancelled JobDone, got {other:?}"), + } + assert!(handle.thread.join().is_ok(), "the panic was contained: the thread itself exits normally"); + } + + #[test] + fn a_panicked_info_query_resolves_every_value() { + let (tx, rx) = mpsc::channel(); + spawn_info_query_with(PanelSide::Left, PathBuf::from("/x"), 4, tx, WorkerGauge::new().token(), |_| panic!("scripted info panic")); + match recv(&rx) { + Command::InfoResolved { panel, path, request, values } => { + assert_eq!((panel, path, request), (PanelSide::Left, PathBuf::from("/x"), 4)); + assert!(values.is_complete(), "no field is left pending: {values:?}"); + assert_eq!((values.file_count, values.dir_count), (Some(0), Some(0))); + } + other => panic!("expected InfoResolved, got {other:?}"), + } + } + + #[test] + fn a_panicked_drive_label_lookup_resolves_to_no_label() { + let (tx, rx) = mpsc::channel(); + spawn_drive_label_with(PanelSide::Left, 'E', 9, tx, WorkerGauge::new().token(), || panic!("scripted label panic")); + match recv(&rx) { + Command::DriveLabelResolved { target, letter, label, generation } => { + assert_eq!((target, letter, label, generation), (PanelSide::Left, 'E', None, 9)); + } + other => panic!("expected DriveLabelResolved, got {other:?}"), + } + } + + #[test] + fn a_panicked_tree_expansion_resolves_to_no_children() { + let (tx, rx) = mpsc::channel(); + spawn_tree_expand_with(PanelSide::Right, PathBuf::from("/tree"), tx, WorkerGauge::new().token(), |_| panic!("scripted tree panic")); + match recv(&rx) { + Command::TreeNodeExpanded { panel, path, children } => { + assert_eq!((panel, path), (PanelSide::Right, PathBuf::from("/tree"))); + assert!(children.is_empty()); + } + other => panic!("expected TreeNodeExpanded, got {other:?}"), + } + } + + #[test] + fn a_panicked_find_walk_still_reports_the_search_done() { + let (tx, rx) = mpsc::channel(); + spawn_find_subtree_with(PathBuf::from("/root"), "x".to_string(), 11, CancelFlag::new(), tx, WorkerGauge::new().token(), |_, _, _| { + panic!("scripted find panic") + }); + match recv(&rx) { + Command::FindFileSearchDone { request } => assert_eq!(request, 11), + other => panic!("expected FindFileSearchDone, got {other:?}"), + } + } + + #[test] + fn a_panicked_viewer_search_reports_no_match() { + let (tx, rx) = mpsc::channel(); + spawn_viewer_search_with(5, CancelFlag::new(), tx, WorkerGauge::new().token(), || panic!("scripted search panic")); + match recv(&rx) { + Command::ViewerSearchResult { offset: None, match_range: None, request: 5 } => {} + other => panic!("expected a no-match ViewerSearchResult, got {other:?}"), + } + } + + #[test] + fn a_panicked_worker_still_releases_its_gauge_token_after_replying() { + let (tx, rx) = mpsc::channel(); + let gauge = WorkerGauge::new(); + spawn_drive_label_with(PanelSide::Left, 'E', 1, tx, gauge.token(), || panic!("boom")); + let _ = recv(&rx); + let deadline = Instant::now() + Duration::from_secs(5); + while gauge.live() != 0 { + assert!(Instant::now() < deadline, "the token is released once the reply is out"); + std::thread::yield_now(); + } + } } diff --git a/crates/filecommand-tui/tests/bench_100k_ignored.rs b/crates/filecommand-tui/tests/bench_100k_ignored.rs index b29f98b..872aea8 100644 --- a/crates/filecommand-tui/tests/bench_100k_ignored.rs +++ b/crates/filecommand-tui/tests/bench_100k_ignored.rs @@ -1,96 +1,279 @@ -//! Spike (c) benchmark: exercises the real streaming-insert and render code -//! paths at 100k entries so `docs/spikes/c-100k-entry-render-benchmark.md` -//! is backed by actual numbers, not guesses. Marked `#[ignore]` since it's a -//! manual perf spike, not a correctness test — run with: +//! Spike (c) benchmark, extended by `streaming-listing-performance` (design +//! D8): exercises the real streaming-merge, listing-apply, re-sort and render +//! code paths at 100k entries so `docs/spikes/c-100k-entry-render-benchmark.md` +//! is backed by actual numbers, not guesses, and prints the ratios the +//! panel-navigation spec compares ("Streaming 100,000 entries costs about one +//! sort", "Frame cost does not grow with directory size"). Marked `#[ignore]` +//! since it is a manual perf measurement, not a correctness test — run with: //! //! ```sh //! cargo test --release -p filecommand-tui --test bench_100k_ignored -- --ignored --nocapture //! ``` +//! +//! `bench_1m_streamed_merge` is the one-off million-entry measurement that +//! settles the design's open question; it needs a few hundred MB and runs +//! for several seconds. use std::path::PathBuf; -use std::time::Instant; +use std::time::{Duration, Instant}; use ratatui::backend::TestBackend; use ratatui::Terminal; -use filecommand_core::listing::{Entry, EntryKind}; -use filecommand_core::listing::SortMode; -use filecommand_core::panel::{insert_sorted, ListingProgress, PanelState, SortDirection}; +use filecommand_core::listing::{BatchPolicy, Entry, EntryKind, SortMode}; +use filecommand_core::panel::{cmp_entries, insert_sorted, ListingProgress, PanelState, SortDirection}; use filecommand_core::theme::{ColorDepth, Theme}; -use filecommand_core::State; +use filecommand_core::{update, Command, PanelSide, State}; use filecommand_tui::views; const N: usize = 100_000; +/// `n` plain files named `file-000000.txt` … in ascending (already sorted) +/// order, with distinct sizes so Size order is a real permutation. fn synthetic_entries(n: usize) -> Vec { - (0..n) - .map(|i| Entry { name: format!("file-{i:06}.txt").into(), kind: EntryKind::File, size: i as u64, modified: None }) - .collect() + (0..n).map(|i| Entry::new(format!("file-{i:06}.txt"), EntryKind::File, i as u64, None, false, false)).collect() } -#[test] -#[ignore] -fn bench_streamed_sorted_insert_100k() { - let entries = synthetic_entries(N); - let start = Instant::now(); - let mut sorted: Vec = Vec::new(); - for e in entries { - insert_sorted(&mut sorted, e, SortMode::Name, SortDirection::Asc); +/// A deterministic Fisher–Yates shuffle (xorshift64), so "shuffled arrival" +/// is the same permutation on every run and every machine. +fn shuffled(mut entries: Vec) -> Vec { + let mut x: u64 = 0x9E37_79B9_7F4A_7C15; + for i in (1..entries.len()).rev() { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + let j = (x % (i as u64 + 1)) as usize; + entries.swap(i, j); } - let elapsed = start.elapsed(); - eprintln!("streamed insert_sorted x {N}: {elapsed:?} ({:.2} ms/entry avg)", elapsed.as_secs_f64() * 1000.0 / N as f64); - assert_eq!(sorted.len(), N); + entries } -#[test] -#[ignore] -fn bench_bulk_sort_100k() { - let mut entries = synthetic_entries(N); +/// Split `entries` into the batches the reader's policy would produce on a +/// fast local enumeration: `clamp(sent / 4, 256, 16_384)` each. +fn policy_batches(entries: Vec) -> Vec> { + let policy = BatchPolicy::default(); + let mut batches = Vec::new(); + let mut sent = 0usize; + let mut rest = entries.into_iter().peekable(); + while rest.peek().is_some() { + let take = policy.target_len(sent); + let batch: Vec = rest.by_ref().take(take).collect(); + sent += batch.len(); + batches.push(batch); + } + batches +} + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +fn time(f: impl FnOnce() -> T) -> (T, Duration) { let start = Instant::now(); - entries.sort_by(|a, b| filecommand_core::panel::cmp_entries(a, b, SortMode::Name, SortDirection::Asc)); - let elapsed = start.elapsed(); - eprintln!("bulk sort_by x {N}: {elapsed:?}"); - assert_eq!(entries.len(), N); + let out = f(); + (out, start.elapsed()) } -#[test] -#[ignore] -fn bench_render_frame_with_100k_entries() { - let mut panel = PanelState::new(PathBuf::from(r"C:\bench")); - let mut entries = synthetic_entries(N); - entries.sort_by(|a, b| filecommand_core::panel::cmp_entries(a, b, SortMode::Name, SortDirection::Asc)); - panel.entries = entries; - panel.progress = ListingProgress::Complete { count: N }; - panel.cursor = N / 2; +/// Bulk `sort_by` of `entries` under Name order — the "one sort" the spec +/// compares the streamed merge against. +fn bulk_sort(mut entries: Vec) -> (Vec, Duration) { + let (_, d) = time(|| entries.sort_by(|a, b| cmp_entries(a, b, SortMode::Name, SortDirection::Asc))); + (entries, d) +} - let state = State { left: panel.clone(), right: panel, ..State::empty(Theme::classic()) }; - let identity_lines = [String::new(), String::new(), String::new(), String::new()]; +/// Merge `batches` into a fresh panel through `merge_streamed`, returning the +/// total time and the longest single merge. +fn streamed_merge(batches: Vec>) -> (PanelState, Duration, Duration) { + let mut panel = PanelState::new(PathBuf::from(r"C:\bench")); + let mut total = Duration::ZERO; + let mut worst = Duration::ZERO; + for batch in batches { + let (_, d) = time(|| panel.merge_streamed(batch)); + total += d; + worst = worst.max(d); + } + (panel, total, worst) +} - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).unwrap(); +/// The pre-change strategy — `insert_sorted` once per entry — kept for the +/// record; `insert_sorted` itself survives for single-entry callers. +fn per_entry_insert(entries: Vec) -> (Vec, Duration) { + let mut sorted: Vec = Vec::with_capacity(entries.len()); + let (_, d) = time(|| { + for e in entries { + insert_sorted(&mut sorted, e, SortMode::Name, SortDirection::Asc); + } + }); + (sorted, d) +} - // Warm up allocations once before timing the steady-state frame cost. - terminal - .draw(|f| { - let area = f.area(); - views::render(f.buffer_mut(), area, &state, ColorDepth::Ansi16, &identity_lines, "3:04 PM", None); - }) - .unwrap(); +fn panel_of(n: usize, quick_filter: Option<&str>) -> PanelState { + let mut panel = PanelState::new(PathBuf::from(r"C:\bench")); + let (entries, _) = bulk_sort(synthetic_entries(n)); + panel.set_entries(entries); + panel.progress = ListingProgress::Complete { count: n }; + panel.cursor = n / 2; + panel.cursor_user_moved = true; + panel.set_quick_filter(quick_filter.map(str::to_string)); + panel.ensure_cursor_visible(20); + panel +} +/// Average frame time of `FRAMES` full-screen renders of `state` at 80×24 +/// through a real `TestBackend`, after one warm-up frame. +fn render_frame_avg(state: &State) -> Duration { const FRAMES: usize = 200; - let start = Instant::now(); - for _ in 0..FRAMES { + let identity_lines = [String::new(), String::new(), String::new(), String::new()]; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let draw = |terminal: &mut Terminal| { terminal - .draw(|f| { - let area = f.area(); - views::render(f.buffer_mut(), area, &state, ColorDepth::Ansi16, &identity_lines, "3:04 PM", None); - }) - .unwrap(); + .draw(|f| { + let area = f.area(); + views::render(f.buffer_mut(), area, state, ColorDepth::Ansi16, &identity_lines, "3:04 PM", None); + }) + .unwrap(); + }; + draw(&mut terminal); + let (_, d) = time(|| { + for _ in 0..FRAMES { + draw(&mut terminal); + } + }); + d / FRAMES as u32 +} + +fn state_with(panel: PanelState) -> State { + let mut state = State { left: panel.clone(), right: panel, ..State::empty(Theme::classic()) }; + state.term_size = (80, 24); + state +} + +#[test] +#[ignore] +fn bench_streaming_listing_performance_100k() { + eprintln!(); + eprintln!("== streaming-listing-performance benchmark, N = {N} (release build) =="); + + // --- sorting and streaming ------------------------------------------ + let (sorted, bulk_presorted) = bulk_sort(synthetic_entries(N)); + eprintln!("bulk sort_by (Name), already-sorted input {:>9.3} ms (the original spike's bench: pre-sorted input)", ms(bulk_presorted)); + let (_, bulk) = bulk_sort(shuffled(synthetic_entries(N))); + eprintln!("bulk sort_by (Name), shuffled input {:>9.3} ms (the spec's \"one bulk sort of the same entries\")", ms(bulk)); + let mut batches_to_sort = policy_batches(shuffled(synthetic_entries(N))); + let (_, batch_sorts) = time(|| { + for batch in &mut batches_to_sort { + batch.sort_by(|a, b| cmp_entries(a, b, SortMode::Name, SortDirection::Asc)); + } + }); + eprintln!("sorting each policy batch on its own, shuffled {:>9.3} ms (the merge's per-batch sort share)", ms(batch_sorts)); + + let (_, old_sorted) = per_entry_insert(synthetic_entries(N)); + eprintln!("old per-entry insert_sorted, sorted arrival {:>9.3} ms (pre-sorted input: every insert lands at the end)", ms(old_sorted)); + let (_, old_shuffled) = per_entry_insert(shuffled(synthetic_entries(N))); + eprintln!("old per-entry insert_sorted, shuffled arrival {:>9.3} ms (reference only)", ms(old_shuffled)); + + let sorted_batches = policy_batches(synthetic_entries(N)); + let n_batches = sorted_batches.len(); + let (panel_sorted, merge_sorted, worst_sorted) = streamed_merge(sorted_batches); + assert_eq!(panel_sorted.entries, sorted); + eprintln!( + "merge_streamed, {n_batches:>2} policy batches, sorted arrival {:>9.3} ms (worst single merge {:.3} ms)", + ms(merge_sorted), + ms(worst_sorted) + ); + + let shuffled_batches = policy_batches(shuffled(synthetic_entries(N))); + let (panel_shuffled, merge_shuffled, worst_shuffled) = streamed_merge(shuffled_batches); + assert_eq!(panel_shuffled.entries, sorted, "the merge equals one bulk sort"); + eprintln!( + "merge_streamed, {n_batches:>2} policy batches, shuffled arrival {:>9.3} ms (worst single merge {:.3} ms)", + ms(merge_shuffled), + ms(worst_shuffled) + ); + + // --- the whole reducer path: ListingChunk → merge → anchor → viewport ---- + let mut state = State::empty(Theme::classic()); + state.term_size = (80, 24); + state.left.listing_request = Some(1); + let mut apply_total = Duration::ZERO; + let mut apply_worst = Duration::ZERO; + for batch in policy_batches(shuffled(synthetic_entries(N))) { + let (s, d) = time(|| update(state, Command::ListingChunk { panel: PanelSide::Left, entries: batch, request: 1 }).0); + state = s; + apply_total += d; + apply_worst = apply_worst.max(d); } - let elapsed = start.elapsed(); + let (s, d) = time(|| update(state, Command::ListingComplete { panel: PanelSide::Left, total: N, unreadable: 0, request: 1 }).0); + state = s; + apply_total += d; + assert_eq!(state.left.entries.len(), N); + assert_eq!(state.left.entries, sorted); eprintln!( - "render {FRAMES} frames against a {N}-entry panel: {elapsed:?} ({:.3} ms/frame avg)", - elapsed.as_secs_f64() * 1000.0 / FRAMES as f64 + "update(): {n_batches} ListingChunks + ListingComplete, shuffled {:>9.3} ms (worst single chunk {:.3} ms)", + ms(apply_total), + ms(apply_worst) + ); + + // --- re-sort by each mode (Ctrl+F3..F6) ------------------------------ + let mut panel = panel_of(N, None); + for mode in [SortMode::Extension, SortMode::Time, SortMode::Size, SortMode::Unsorted, SortMode::Name] { + let (_, d) = time(|| panel.set_sort_mode(mode)); + eprintln!("set_sort_mode({mode:?}) at {N} {:>9.3} ms", ms(d)); + } + + // --- rendering --------------------------------------------------------- + let render_1k = render_frame_avg(&state_with(panel_of(1_000, None))); + let render_100k = render_frame_avg(&state_with(panel_of(N, None))); + let render_1k_filtered = render_frame_avg(&state_with(panel_of(1_000, Some("9")))); + let render_100k_filtered = render_frame_avg(&state_with(panel_of(N, Some("9")))); + eprintln!("render frame, 1k entries, no filter {:>9.3} ms/frame", ms(render_1k)); + eprintln!("render frame, 100k entries, no filter {:>9.3} ms/frame", ms(render_100k)); + eprintln!("render frame, 1k entries, quick filter \"9\" {:>9.3} ms/frame", ms(render_1k_filtered)); + eprintln!("render frame, 100k entries, quick filter \"9\" {:>9.3} ms/frame", ms(render_100k_filtered)); + + // --- the ratios the spec states -------------------------------------- + let merge_ratio = merge_shuffled.as_secs_f64() / bulk.as_secs_f64(); + let render_ratio = render_100k.as_secs_f64() / render_1k.as_secs_f64(); + let render_ratio_filtered = render_100k_filtered.as_secs_f64() / render_1k_filtered.as_secs_f64(); + eprintln!(); + eprintln!("ratio: streamed merge (shuffled) / bulk sort {merge_ratio:.2}x (spec: <= 3x)"); + eprintln!("ratio: 100k frame / 1k frame, no filter {render_ratio:.2}x (spec: <= 2x)"); + eprintln!("ratio: 100k frame / 1k frame, quick filter {render_ratio_filtered:.2}x (spec: <= 2x)"); + eprintln!("ratio: old per-entry insert (shuffled) / merge {:.1}x", old_shuffled.as_secs_f64() / merge_shuffled.as_secs_f64()); + eprintln!(); + assert!(merge_ratio <= 3.0, "panel-navigation \"Streaming 100,000 entries costs about one sort\": {merge_ratio:.2}x"); + assert!(render_ratio <= 2.0, "panel-navigation \"Frame cost does not grow with directory size\": {render_ratio:.2}x"); + assert!( + render_ratio_filtered <= 2.0, + "panel-navigation \"Frame cost does not grow with directory size\" (filtered): {render_ratio_filtered:.2}x" ); } + +/// The design's open question (streaming-listing-performance): does a +/// million-entry directory need a different structure while streaming? One +/// measurement of the merge path at 1M, shuffled arrival, in policy batches. +#[test] +#[ignore] +fn bench_1m_streamed_merge() { + const M: usize = 1_000_000; + eprintln!(); + eprintln!("== one-off 1M-entry measurement (release build) =="); + let (_, bulk) = bulk_sort(shuffled(synthetic_entries(M))); + eprintln!("bulk sort_by (Name) at 1M, shuffled input {:>9.3} ms", ms(bulk)); + let batches = policy_batches(shuffled(synthetic_entries(M))); + let n_batches = batches.len(); + let (panel, total, worst) = streamed_merge(batches); + assert_eq!(panel.entries.len(), M); + eprintln!("merge_streamed, {n_batches} policy batches, shuffled {:>9.3} ms total, worst single merge {:.3} ms", ms(total), ms(worst)); + let render = render_frame_avg(&state_with({ + let mut p = panel; + p.progress = ListingProgress::Complete { count: M }; + p.cursor = M / 2; + p.cursor_user_moved = true; + p.ensure_cursor_visible(20); + p + })); + eprintln!("render frame, 1M entries, no filter {:>9.3} ms/frame", ms(render)); + eprintln!("ratio: streamed merge / bulk sort at 1M {:.2}x", total.as_secs_f64() / bulk.as_secs_f64()); +} diff --git a/crates/filecommand-tui/tests/clipboard_windows.rs b/crates/filecommand-tui/tests/clipboard_windows.rs index 58a8088..c925e3b 100644 --- a/crates/filecommand-tui/tests/clipboard_windows.rs +++ b/crates/filecommand-tui/tests/clipboard_windows.rs @@ -56,7 +56,7 @@ fn windows_clipboard_round_trips_a_real_file_list() { let long_path_item = PathBuf::from(format!(r"\\?\{}", real_file.display())); let unc_item = PathBuf::from(r"\\?\UNC\srv\share\dir\file.txt"); - let raw_items = vec![real_file.clone(), long_path_item, unc_item]; + let raw_items = [real_file.clone(), long_path_item, unc_item]; let normalized: Vec = raw_items.iter().map(|p| clipboard::normalize_path(p)).collect(); assert_eq!(normalized[0], real_file, "a plain absolute path is left unchanged"); @@ -64,7 +64,7 @@ fn windows_clipboard_round_trips_a_real_file_list() { assert_eq!(normalized[2], PathBuf::from(r"\\srv\share\dir\file.txt"), "`\\\\?\\UNC\\server\\share` must be rewritten to `\\\\server\\share`"); assert!(!normalized.iter().any(|p| p.display().to_string().contains(r"\\?\")), "no normalized item may still carry the verbatim-path prefix"); - let clip = PlatformClipboard::default(); + let clip = PlatformClipboard; let fell_back_to_paths = clip.set_files(&normalized).expect("set_files should succeed against a free clipboard"); assert!(!fell_back_to_paths, "on Windows, Files must write real file objects, not fall back to text"); diff --git a/crates/filecommand-tui/tests/help_flags.rs b/crates/filecommand-tui/tests/help_flags.rs index 83a2853..1e3e91f 100644 --- a/crates/filecommand-tui/tests/help_flags.rs +++ b/crates/filecommand-tui/tests/help_flags.rs @@ -1,17 +1,14 @@ -/// Integration tests for -h / -? / --help flags (help-flags OpenSpec change). -/// -/// Uses CARGO_BIN_EXE_filecommand (set by cargo at test build time) to invoke -/// the real binary and assert exit-code 0 + version string in stdout without -/// the TUI ever being launched. +//! Integration tests for -h / -? / --help flags (help-flags OpenSpec change). +//! +//! Uses CARGO_BIN_EXE_filecommand (set by cargo at test build time) to invoke +//! the real binary and assert exit-code 0 + version string in stdout without +//! the TUI ever being launched. const VERSION: &str = env!("CARGO_PKG_VERSION"); const BIN: &str = env!("CARGO_BIN_EXE_filecommand"); fn run(arg: &str) -> std::process::Output { - std::process::Command::new(BIN) - .arg(arg) - .output() - .unwrap_or_else(|e| panic!("failed to spawn {BIN}: {e}")) + std::process::Command::new(BIN).arg(arg).output().unwrap_or_else(|e| panic!("failed to spawn {BIN}: {e}")) } #[test] @@ -52,3 +49,21 @@ fn help_long_prints_version() { let stdout = String::from_utf8_lossy(&out.stdout); assert!(stdout.contains(VERSION), "--help stdout should contain version {VERSION}:\n{stdout}"); } + +#[test] +fn help_lists_the_noanimations_flag() { + // ui-animations "Flag is documented in usage". + let out = run("--help"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--noanimations"), "--help should list --noanimations:\n{stdout}"); +} + +#[test] +fn help_lists_the_log_flag_and_environment_variable() { + // error-diagnostics; application-shell "Optional diagnostic log": + // "Usage lists the log options". + let out = run("--help"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("--log "), "--help should list --log :\n{stdout}"); + assert!(stdout.contains("FILECOMMAND_LOG"), "--help should mention FILECOMMAND_LOG:\n{stdout}"); +} diff --git a/crates/filecommand-tui/tests/integration_smoke.rs b/crates/filecommand-tui/tests/integration_smoke.rs index 554f07a..92b0206 100644 --- a/crates/filecommand-tui/tests/integration_smoke.rs +++ b/crates/filecommand-tui/tests/integration_smoke.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc::{self, Receiver, Sender}; use std::time::Duration; -use filecommand_core::editor::EditorState; +use filecommand_core::fs_ops::CancelFlag; use filecommand_core::panel::DisplayMode; use filecommand_core::{update, Command, Effect, PanelSide, State, UiPhase}; use filecommand_tui::worker; @@ -27,32 +27,31 @@ const QUIET_TIMEOUT: Duration = Duration::from_millis(150); /// feeding worker-thread results back over `tx`/`rx` the same way /// `app::run_effects` does. Effects outside this test's scope (menu/drive- /// select/viewer/...) are silently ignored — they simply don't arise from -/// the scripted command sequence below. -fn execute(effects: Vec, tx: &Sender) { +/// the scripted command sequence below. `git_slots` are the test's per-panel +/// git-query slots, and `workers` its worker gauge, mirroring `Runtime`'s +/// (git-info-timeout-memory design D3; idle-aware-event-loop design D3). +fn execute(effects: Vec, tx: &Sender, git_slots: &[worker::GitQuerySlot; 2], workers: &worker::WorkerGauge) { for effect in effects { match effect { - Effect::StartListing { panel, path } => worker::spawn_listing(panel, path, tx.clone()), - Effect::QueryGitInfo { panel, path, request } => worker::spawn_git_info_query(panel, path, request, tx.clone()), - Effect::FindInSubtree { root, pattern, request } => worker::spawn_find_subtree(root, pattern, request, tx.clone()), - // `Effect::OpenEditor`/`SaveEditor` are cheap enough to run - // synchronously on the input path in the real app too (design - // D1) — mirrored here rather than spawning a thread for them. - Effect::OpenEditor { path } => { - let reply = match EditorState::open(&path) { - Ok(filecommand_core::editor::LoadResult::Loaded(editor)) => Command::EditorOpened(Box::new(editor)), - Ok(filecommand_core::editor::LoadResult::TooLarge { size }) => Command::EditorTooLarge { path, size }, - Err(e) => Command::EditorOpenFailed { message: e.to_string() }, - }; - let _ = tx.send(reply); + // The smoke test never supersedes a listing or a walk, so each + // gets a flag nothing will ever set (listing-request-guard). + Effect::StartListing { panel, path, request } => { + worker::spawn_listing(panel, path, request, CancelFlag::new(), tx.clone(), workers.token()) } - Effect::SaveEditor { editor, then_quit } => { - let mut editor = *editor; - let reply = match editor.save() { - Ok(()) => Command::EditorSaved { editor: Box::new(editor), then_quit }, - Err(e) => Command::EditorSaveFailed { message: e.to_string() }, + Effect::QueryGitInfo { panel, path, request } => { + let slot = match panel { + PanelSide::Left => &git_slots[0], + PanelSide::Right => &git_slots[1], }; - let _ = tx.send(reply); + slot.request(panel, path, request, tx.clone(), workers.token()); + } + Effect::FindInSubtree { root, pattern, request } => { + worker::spawn_find_subtree(root, pattern, request, CancelFlag::new(), tx.clone(), workers.token()) } + // `Effect::OpenEditor`/`SaveEditor` run on the same worker + // threads the real app uses (offload-blocking-io design D4/D6). + Effect::OpenEditor { request, path } => worker::spawn_editor_open(request, path, tx.clone(), workers.token()), + Effect::SaveEditor { editor, then_quit } => worker::spawn_editor_save(editor, then_quit, tx.clone(), workers.token()), _ => {} } } @@ -62,14 +61,21 @@ fn execute(effects: Vec, tx: &Sender) { /// (and *their* resulting effects, transitively) until the system goes /// quiet — so callers always see a settled `State` rather than one with /// listings still streaming in. -fn step(mut state: State, cmd: Command, tx: &Sender, rx: &Receiver) -> State { +fn step( + mut state: State, + cmd: Command, + tx: &Sender, + rx: &Receiver, + git_slots: &[worker::GitQuerySlot; 2], + workers: &worker::WorkerGauge, +) -> State { let (s, effects) = update(state, cmd); state = s; - execute(effects, tx); + execute(effects, tx, git_slots, workers); while let Ok(reply) = rx.recv_timeout(QUIET_TIMEOUT) { let (s, effects) = update(state, reply); state = s; - execute(effects, tx); + execute(effects, tx, git_slots, workers); } state } @@ -113,11 +119,12 @@ impl Drop for Fixture { fn scripted_smoke_sequence_covers_edit_save_tabs_filter_jump_and_find() { let fx = Fixture::new("main"); let (tx, rx) = mpsc::channel::(); + let git_slots = [worker::GitQuerySlot::new(), worker::GitQuerySlot::new()]; + let workers = worker::WorkerGauge::new(); - let (state, effects) = - State::initial(filecommand_core::theme::Theme::classic(), (80, 24), 1_000, fx.left(), fx.root.join("right"), false); - execute(effects, &tx); - let mut state = step(state, Command::Tick(1_000), &tx, &rx); + let (state, effects) = State::initial(filecommand_core::theme::Theme::classic(), (80, 24), 1_000, 1_000, fx.left(), fx.root.join("right"), false); + execute(effects, &tx, &git_slots, &workers); + let mut state = step(state, Command::Tick { now_ms: 1_000, epoch_ms: 1_000 }, &tx, &rx, &git_slots, &workers); // ---- Sanity: both panels loaded for real ------------------------- assert!(state.left.entries.iter().any(|e| e.name == "report.txt"), "left panel must have listed report.txt: {:?}", state.left.entries); @@ -126,17 +133,17 @@ fn scripted_smoke_sequence_covers_edit_save_tabs_filter_jump_and_find() { // ---- F4 edit + save ------------------------------------------------ let report_idx = state.left.entries.iter().position(|e| e.name == "report.txt").expect("report.txt listed"); state.left.cursor = report_idx; - state = step(state, Command::RequestEditor, &tx, &rx); + state = step(state, Command::RequestEditor, &tx, &rx, &git_slots, &workers); assert!(matches!(state.phase, UiPhase::Editor(_)), "F4 must open the built-in editor, got {:?}", state.phase); - state = step(state, Command::EditorChar('X'), &tx, &rx); + state = step(state, Command::EditorChar('X'), &tx, &rx, &git_slots, &workers); if let UiPhase::Editor(editor) = &state.phase { assert!(editor.is_modified(), "typing a character must mark the buffer modified"); } else { panic!("expected the editor phase to still be open"); } - state = step(state, Command::EditorSave, &tx, &rx); + state = step(state, Command::EditorSave, &tx, &rx, &git_slots, &workers); let on_disk = std::fs::read_to_string(fx.left().join("report.txt")).expect("read saved file"); assert!(on_disk.starts_with('X'), "the typed character must have been written to disk: {on_disk:?}"); if let UiPhase::Editor(editor) = &state.phase { @@ -147,63 +154,63 @@ fn scripted_smoke_sequence_covers_edit_save_tabs_filter_jump_and_find() { // F10 on an unmodified buffer exits directly (builtin-editor "Quitting // an unmodified buffer exits directly"). - state = step(state, Command::EditorRequestQuit, &tx, &rx); + state = step(state, Command::EditorRequestQuit, &tx, &rx, &git_slots, &workers); assert_eq!(state.phase, UiPhase::Panels, "F10 on an unmodified buffer returns straight to the panels"); // ---- Ctrl+T / Alt+ panel tabs ----------------------------------- assert_eq!(state.left.tab_count(), 1); - state = step(state, Command::OpenTab, &tx, &rx); + state = step(state, Command::OpenTab(PanelSide::Left), &tx, &rx, &git_slots, &workers); assert_eq!(state.left.tab_count(), 2, "Ctrl+T opens a second tab"); // Navigate the (now active) second tab into `sub` — this also records // the frecency visit Ctrl+J will read from later. let sub_idx = state.left.entries.iter().position(|e| e.name == "sub").expect("sub listed"); state.left.cursor = sub_idx; - state = step(state, Command::Enter, &tx, &rx); + state = step(state, Command::Enter, &tx, &rx, &git_slots, &workers); assert_eq!(state.left.cwd, fx.sub(), "tab 2 navigated into sub"); - state = step(state, Command::SwitchTab(1), &tx, &rx); + state = step(state, Command::SwitchTab(1), &tx, &rx, &git_slots, &workers); assert_eq!(state.left.cwd, fx.left(), "switching back to tab 1 restores its own directory"); assert!(state.left.entries.iter().any(|e| e.name == "report.txt")); // ---- Ctrl+P quick filter -------------------------------------------- - state = step(state, Command::QuickFilterStart, &tx, &rx); - state = step(state, Command::QuickFilterChar('r'), &tx, &rx); - state = step(state, Command::QuickFilterChar('e'), &tx, &rx); + state = step(state, Command::QuickFilterStart(PanelSide::Left), &tx, &rx, &git_slots, &workers); + state = step(state, Command::QuickFilterChar('r'), &tx, &rx, &git_slots, &workers); + state = step(state, Command::QuickFilterChar('e'), &tx, &rx, &git_slots, &workers); let visible_names: Vec = state.left.visible_indices().into_iter().map(|i| state.left.entries[i].name.to_string_lossy().into_owned()).collect(); assert!(visible_names.contains(&"report.txt".to_string()), "{visible_names:?}"); assert!(!visible_names.contains(&"sub".to_string()), "`sub` does not contain `re`: {visible_names:?}"); - state = step(state, Command::QuickFilterEnd, &tx, &rx); + state = step(state, Command::QuickFilterEnd, &tx, &rx, &git_slots, &workers); assert!(state.left.quick_filter.is_none(), "Esc clears the filter"); // ---- Ctrl+J fuzzy jump ------------------------------------------------ assert!(state.dir_history.iter().any(|e| e.path == fx.sub()), "the earlier tab-2 navigation into sub must be recorded"); - state = step(state, Command::FuzzyJumpOpen, &tx, &rx); - assert!(state.fuzzy_jump.is_some()); - state = step(state, Command::FuzzyJumpConfirm, &tx, &rx); - assert!(state.fuzzy_jump.is_none(), "the dialog closes"); + state = step(state, Command::FuzzyJumpOpen, &tx, &rx, &git_slots, &workers); + assert!(state.fuzzy_jump().is_some()); + state = step(state, Command::FuzzyJumpConfirm, &tx, &rx, &git_slots, &workers); + assert!(state.fuzzy_jump().is_none(), "the dialog closes"); assert_eq!(state.left.cwd, fx.sub(), "Ctrl+J jumped the active panel to the most-frecent visited directory"); // Back to `left`'s root before searching its subtree. - state = step(state, Command::ParentDir, &tx, &rx); + state = step(state, Command::ParentDir, &tx, &rx, &git_slots, &workers); assert_eq!(state.left.cwd, fx.left()); assert_eq!(state.active, PanelSide::Left); // ---- Alt+F7 find file --------------------------------------------- - state = step(state, Command::FindFileOpen, &tx, &rx); - assert_eq!(state.find_file.as_ref().unwrap().root, fx.left()); + state = step(state, Command::FindFileOpen, &tx, &rx, &git_slots, &workers); + assert_eq!(state.find_file().unwrap().root, fx.left()); for c in "nested".chars() { - state = step(state, Command::FindFileChar(c), &tx, &rx); + state = step(state, Command::TextEdit(filecommand_core::line_edit::TextEditOp::Insert(c)), &tx, &rx, &git_slots, &workers); } - state = step(state, Command::FindFileSubmit, &tx, &rx); + state = step(state, Command::FindFileSubmit, &tx, &rx, &git_slots, &workers); { - let dialog = state.find_file.as_ref().expect("find-file dialog still open"); + let dialog = state.find_file().expect("find-file dialog still open"); assert!(dialog.done, "the walk must have finished by the time `step` quiesces"); assert_eq!(dialog.results.len(), 1, "exactly nested.txt should match `nested`: {:?}", dialog.results); assert_eq!(dialog.results[0].entry.name, "nested.txt"); } - state = step(state, Command::FindFileConfirm, &tx, &rx); - assert!(state.find_file.is_none(), "the dialog closes on confirm"); + state = step(state, Command::FindFileConfirm, &tx, &rx, &git_slots, &workers); + assert!(state.find_file().is_none(), "the dialog closes on confirm"); assert_eq!(state.left.cwd, fx.sub(), "find-file navigated the active panel to the match's containing directory"); let cursor_name = state.left.entries.get(state.left.cursor).map(|e| e.name.to_string_lossy().into_owned()); assert_eq!(cursor_name.as_deref(), Some("nested.txt"), "the cursor must settle on the matched entry"); diff --git a/crates/filecommand-tui/tests/panic_restoration.rs b/crates/filecommand-tui/tests/panic_restoration.rs index feb15b7..81b6e3a 100644 --- a/crates/filecommand-tui/tests/panic_restoration.rs +++ b/crates/filecommand-tui/tests/panic_restoration.rs @@ -1,9 +1,11 @@ //! Verifies that a panic occurring while the terminal guard is "active" //! triggers terminal restoration *before* the previously installed panic //! hook runs, and that the previous hook is still invoked (chaining) — -//! plus (mouse-basics task 1.3) that `TerminalGuard::suspend`/`resume` -//! order mouse capture correctly around the alternate screen: capture -//! disabled before `LeaveAlternateScreen` on suspend, re-enabled on resume. +//! plus (mouse-basics task 1.3; idle-aware-event-loop task 3.1) that +//! `TerminalGuard::suspend`/`resume` order mouse capture and bracketed +//! paste correctly around the alternate screen: both disabled before +//! `LeaveAlternateScreen` on suspend, re-enabled after the re-entry on +//! resume. //! //! Real raw-mode/alternate-screen/mouse-capture syscalls aren't observable //! portably inside a captured test harness (no guaranteed real console), so @@ -73,6 +75,10 @@ fn panic_restores_terminal_before_previous_hook_runs() { /// otherwise a child shell run from the command line (or an external /// editor, or Ctrl+O's scrollback view) would inherit mouse tracking, and /// on Windows capture leaves conhost's QuickEdit disabled the whole time. +/// Bracketed paste is likewise released before the leave, right after raw +/// mode goes off, so the child gets a terminal without it +/// (idle-aware-event-loop design D6; text-field-editing "Child programs get +/// a terminal without bracketed paste"). #[test] fn suspend_disables_mouse_capture_before_leaving_the_alternate_screen() { // A `Mutex` (rather than a bare `&mut Vec`) so all three step closures @@ -86,6 +92,10 @@ fn suspend_disables_mouse_capture_before_leaving_the_alternate_screen() { order.lock().unwrap().push("raw_mode_off"); Ok(()) }, + || { + order.lock().unwrap().push("bracketed_paste_off"); + Ok(()) + }, || { order.lock().unwrap().push("mouse_capture_off"); Ok(()) @@ -96,13 +106,14 @@ fn suspend_disables_mouse_capture_before_leaving_the_alternate_screen() { }, ) .expect("suspend_with must succeed when every step succeeds"); - assert_eq!(*order.lock().unwrap(), vec!["raw_mode_off", "mouse_capture_off", "leave_alternate_screen"]); + assert_eq!(*order.lock().unwrap(), vec!["raw_mode_off", "bracketed_paste_off", "mouse_capture_off", "leave_alternate_screen"]); } /// When mouse capture was never configured on, `suspend` must not touch it /// at all — `[mouse] enabled = false` / `--nomouse` means "behave exactly /// as before this capability existed" (mouse-input "Mouse capture -/// configuration"). +/// configuration"). Bracketed paste is still released: it follows the +/// terminal, not the mouse flag. #[test] fn suspend_skips_the_mouse_capture_step_when_mouse_is_disabled() { // A `Mutex` (rather than a bare `&mut Vec`) so all three step closures @@ -116,6 +127,10 @@ fn suspend_skips_the_mouse_capture_step_when_mouse_is_disabled() { order.lock().unwrap().push("raw_mode_off"); Ok(()) }, + || { + order.lock().unwrap().push("bracketed_paste_off"); + Ok(()) + }, || { order.lock().unwrap().push("mouse_capture_off"); Ok(()) @@ -126,12 +141,17 @@ fn suspend_skips_the_mouse_capture_step_when_mouse_is_disabled() { }, ) .expect("suspend_with must succeed when every step succeeds"); - assert_eq!(*order.lock().unwrap(), vec!["raw_mode_off", "leave_alternate_screen"], "mouse capture was never enabled, so nothing disables it"); + assert_eq!( + *order.lock().unwrap(), + vec!["raw_mode_off", "bracketed_paste_off", "leave_alternate_screen"], + "mouse capture was never enabled, so nothing disables it" + ); } /// `resume` re-enables mouse capture once the alternate screen has been /// re-entered — the mirror image of `suspend` releasing it beforehand, so -/// capture is active only while the TUI actually owns the screen. +/// capture is active only while the TUI actually owns the screen. Bracketed +/// paste comes back beside it (idle-aware-event-loop design D6). #[test] fn resume_re_enables_mouse_capture_after_entering_the_alternate_screen() { // A `Mutex` (rather than a bare `&mut Vec`) so all three step closures @@ -149,17 +169,21 @@ fn resume_re_enables_mouse_capture_after_entering_the_alternate_screen() { order.lock().unwrap().push("enter_alternate_screen"); Ok(()) }, + || { + order.lock().unwrap().push("bracketed_paste_on"); + Ok(()) + }, || { order.lock().unwrap().push("mouse_capture_on"); Ok(()) }, ) .expect("resume_with must succeed when every step succeeds"); - assert_eq!(*order.lock().unwrap(), vec!["raw_mode_on", "enter_alternate_screen", "mouse_capture_on"]); + assert_eq!(*order.lock().unwrap(), vec!["raw_mode_on", "enter_alternate_screen", "bracketed_paste_on", "mouse_capture_on"]); } /// The disabled-mouse counterpart of the resume test above: no capture step -/// runs at all. +/// runs at all; bracketed paste still re-enables. #[test] fn resume_skips_the_mouse_capture_step_when_mouse_is_disabled() { // A `Mutex` (rather than a bare `&mut Vec`) so all three step closures @@ -177,11 +201,15 @@ fn resume_skips_the_mouse_capture_step_when_mouse_is_disabled() { order.lock().unwrap().push("enter_alternate_screen"); Ok(()) }, + || { + order.lock().unwrap().push("bracketed_paste_on"); + Ok(()) + }, || { order.lock().unwrap().push("mouse_capture_on"); Ok(()) }, ) .expect("resume_with must succeed when every step succeeds"); - assert_eq!(*order.lock().unwrap(), vec!["raw_mode_on", "enter_alternate_screen"]); + assert_eq!(*order.lock().unwrap(), vec!["raw_mode_on", "enter_alternate_screen", "bracketed_paste_on"]); } diff --git a/crates/filecommand-tui/tests/snapshot_animations.rs b/crates/filecommand-tui/tests/snapshot_animations.rs new file mode 100644 index 0000000..e35ade8 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshot_animations.rs @@ -0,0 +1,436 @@ +//! Snapshot and behavior tests for the ui-animations capability. +//! +//! Animation progress is derived from `State::clock_ms` minus a stamp the +//! reducer recorded, so every frame here is pinned: states are built with +//! `animations = true`, stamped at a fixed `T0`, and rendered at `T0 + t`. +//! Existing snapshot suites build states through `State::empty`, which keeps +//! animations off, so they are untouched by this file. + +use std::path::PathBuf; + +use ratatui::backend::TestBackend; +use ratatui::buffer::Buffer; +use ratatui::Terminal; + +use filecommand_core::anim; +use filecommand_core::listing::{DateTime, Entry, EntryKind}; +use filecommand_core::menu::MenuState; +use filecommand_core::panel::{ListingProgress, PanelState, SortDirection}; +use filecommand_core::theme::{ColorDepth, Role, Theme}; +use filecommand_core::{update, Command, Overlay, State, UiPhase}; + +use filecommand_tui::style::role_style; +use filecommand_tui::views; + +/// The clock reading every animated surface in this file is stamped with. +const T0: u64 = 10_000; +const FIXED_CLOCK_TEXT: &str = "3:04 PM"; + +fn identity_lines() -> [String; 4] { + [ + "FileCommand".to_string(), + "Version 0.1.0".to_string(), + "Copyright (C) 2026 The FileCommand Authors".to_string(), + "Inspired by the Norton Commander, 1986-1998".to_string(), + ] +} + +fn complete_panel(cwd: &str, cursor: usize) -> PanelState { + let date = DateTime { year: 2026, month: 1, day: 2, hour: 3, minute: 4 }; + let mut panel = PanelState::new(PathBuf::from(cwd)); + panel.entries = vec![ + Entry::parent_dir(), + Entry::new("docs", EntryKind::Directory, 0, Some(date), false, false), + Entry::new("Cargo.toml", EntryKind::File, 612, Some(date), false, false), + Entry::new("readme.txt", EntryKind::File, 12_345, Some(date), false, false), + ]; + panel.progress = ListingProgress::Complete { count: panel.entries.len() }; + panel.sort_direction = SortDirection::Asc; + panel.cursor = cursor; + panel +} + +/// A panels-phase state with animations enabled and the clock at `T0`. +fn animated_state() -> State { + State { + left: complete_panel(r"C:\Users\demo\left", 1), + right: complete_panel(r"C:\Users\demo\right", 0), + animations: true, + clock_ms: T0, + ..State::empty(Theme::classic()) + } +} + +/// `state` with the reducer clock moved to `T0 + t`, as the next `Tick` would. +fn at(state: &State, t: u64) -> State { + State { clock_ms: T0 + t, ..state.clone() } +} + +fn render_buffer(state: &State) -> Buffer { + render_buffer_with_hitmap(state).0 +} + +fn render_buffer_with_hitmap(state: &State) -> (Buffer, views::RenderOutput) { + let lines = identity_lines(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("TestBackend terminal"); + let mut output = None; + terminal + .draw(|frame| { + let area = frame.area(); + output = Some(views::render(frame.buffer_mut(), area, state, ColorDepth::Ansi16, &lines, FIXED_CLOCK_TEXT, None)); + }) + .expect("draw into TestBackend"); + (terminal.backend().buffer().clone(), output.expect("rendered")) +} + +fn text(buf: &Buffer) -> String { + let a = buf.area; + let mut out = String::new(); + for y in 0..a.height { + for x in 0..a.width { + out.push_str(buf[(a.x + x, a.y + y)].symbol()); + } + out.push('\n'); + } + out +} + +fn render_text(state: &State) -> String { + text(&render_buffer(state)) +} + +/// The same state with every animation disabled: the reference "final" +/// rendering an animated state must converge to. +fn finished(state: &State) -> State { + State { animations: false, ..state.clone() } +} + +fn splash_state() -> State { + State { phase: UiPhase::Splash { started_at_ms: T0 }, ..animated_state() } +} + +fn menu_state() -> State { + let mut menu = MenuState::opened(); + menu.opened_at_ms = T0; + menu.pulldown_opened_at_ms = T0; + State { overlay: Some(Overlay::Menu(menu)), ..animated_state() } +} + +/// F7 through the real reducer, so the dialog stamp is whatever production +/// would record rather than a hand-set field. +fn mkdir_dialog_state() -> State { + let (state, _) = update(animated_state(), Command::RequestMkdir); + assert!(matches!(state.phase, UiPhase::FileOpSetup(_))); + assert_eq!(state.dialog_opened_at_ms, T0, "the reducer stamped the open"); + state +} + +// --------------------------------------------------------------------- +// Snapshots at pinned clock offsets (tasks 6.4) +// --------------------------------------------------------------------- + +#[test] +fn splash_entrance_frames() { + let state = splash_state(); + insta::assert_snapshot!("splash_entrance_0ms", render_text(&at(&state, 0))); + insta::assert_snapshot!("splash_entrance_100ms", render_text(&at(&state, 100))); + insta::assert_snapshot!("splash_entrance_400ms", render_text(&at(&state, 400))); + insta::assert_snapshot!("splash_entrance_650ms", render_text(&at(&state, 650))); + insta::assert_snapshot!("splash_entrance_800ms", render_text(&at(&state, 800))); +} + +#[test] +fn pulldown_unfolds_top_down() { + let state = menu_state(); + insta::assert_snapshot!("pulldown_unfold_0ms", render_text(&at(&state, 0))); + insta::assert_snapshot!("pulldown_unfold_50ms", render_text(&at(&state, 50))); + insta::assert_snapshot!("pulldown_unfold_100ms", render_text(&at(&state, 100))); +} + +#[test] +fn menu_bar_sweeps_in_left_to_right() { + // Bar only (pull-down collapsed), so the frame isolates the sweep: the + // left half of the top row is the bar, the right half still shows the + // panels' top borders and the clock. + let mut state = menu_state(); + state.menu_mut().unwrap().pulldown_open = false; + insta::assert_snapshot!("menu_bar_sweep_50ms", render_text(&at(&state, 50))); +} + +#[test] +fn dialog_opens_through_an_iris() { + let state = mkdir_dialog_state(); + insta::assert_snapshot!("dialog_iris_0ms", render_text(&at(&state, 0))); + insta::assert_snapshot!("dialog_iris_50ms", render_text(&at(&state, 50))); + insta::assert_snapshot!("dialog_iris_100ms", render_text(&at(&state, 100))); +} + +#[test] +fn panels_wipe_in_after_the_splash() { + let state = State { panels_revealed_at_ms: Some(T0), ..animated_state() }; + insta::assert_snapshot!("panel_wipe_75ms", render_text(&at(&state, 75))); +} + +// --------------------------------------------------------------------- +// Final frames are byte-identical to the non-animated rendering +// --------------------------------------------------------------------- + +#[test] +fn every_surface_converges_to_the_pre_animation_rendering() { + let surfaces: Vec<(&str, State, u64)> = vec![ + ("splash", splash_state(), anim::SPLASH_ENTRANCE_MS), + ("menu bar + pull-down", menu_state(), anim::POPUP_UNFOLD_MS.max(anim::MENU_BAR_SWEEP_MS)), + ("dialog", mkdir_dialog_state(), anim::DIALOG_IRIS_MS), + ("panel wipe", State { panels_revealed_at_ms: Some(T0), ..animated_state() }, anim::PANEL_WIPE_MS), + ]; + for (name, state, duration) in surfaces { + let reference = render_buffer(&finished(&state)); + assert_eq!(render_buffer(&at(&state, duration)), reference, "{name}: not identical at its duration"); + assert_eq!(render_buffer(&at(&state, duration + 5_000)), reference, "{name}: not identical long after"); + } +} + +#[test] +fn disabled_animations_render_the_final_frame_at_every_clock_reading() { + for (name, state) in [("splash", splash_state()), ("menu", menu_state()), ("dialog", mkdir_dialog_state())] { + let off = finished(&state); + let reference = render_buffer(&at(&off, 10_000)); + for t in [0, 10, 50, 99] { + assert_eq!(render_buffer(&at(&off, t)), reference, "{name} with animations off must not depend on the clock (t = {t})"); + } + } +} + +#[test] +fn states_built_without_config_never_animate() { + // `State::empty` has animations off: an open dialog renders complete even + // though its stamp equals the clock (existing snapshots rely on this). + let base = State { left: complete_panel("/l", 0), right: complete_panel("/r", 0), ..State::empty(Theme::classic()) }; + let (state, _) = update(base, Command::RequestMkdir); + assert!(!state.animations); + assert_eq!(render_buffer(&state), render_buffer(&State { clock_ms: 5_000, ..state.clone() })); +} + +// --------------------------------------------------------------------- +// Behavior +// --------------------------------------------------------------------- + +#[test] +fn hit_maps_use_final_geometry_during_the_iris() { + // A click during the 100 ms iris lands where the button will be: the hit + // map is built from the finished dialog, never from the growing window. + let mut state = mkdir_dialog_state(); + state.quit_confirm = true; + state.quit_opened_at_ms = T0; + let early = render_buffer_with_hitmap(&at(&state, 0)).1.hitmap; + let done = render_buffer_with_hitmap(&at(&state, 500)).1.hitmap; + assert!(!early.dialog_buttons.is_empty(), "the quit dialog's buttons are live from frame one"); + assert_eq!(format!("{early:?}"), format!("{done:?}")); +} + +#[test] +fn highlight_flashes_in_the_hotkey_accent_then_settles() { + let theme = Theme::classic(); + let hotkey = role_style(&theme, Role::MenuHotkey, ColorDepth::Ansi16); + let highlight = role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16); + assert_ne!(hotkey.fg, highlight.fg, "the settle is only visible if the roles differ"); + + // The pull-down finished unfolding long ago; then a keypress moves the + // highlight down one row through the reducer. + let pressed = T0 + 300; + let (state, _) = update(State { clock_ms: pressed, ..menu_state() }, Command::MenuSelectNext); + let moved = state.menu().unwrap(); + assert_eq!(moved.selected_at_ms, Some(pressed)); + let row = 2 + moved.selected as u16; // pull-down top frame is row 1, items start at row 2 + + // The `Left` pull-down's box starts at column 1 (one left of its title), + // so column 2 is the first interior cell of the selected row. + let cell_fg = |since_press: u64| render_buffer(&State { clock_ms: pressed + since_press, ..state.clone() })[(2, row)].fg; + assert_eq!(cell_fg(10), hotkey.fg.unwrap(), "within 50 ms the new row is in the hotkey accent"); + assert_eq!(cell_fg(anim::HIGHLIGHT_SETTLE_MS - 1), hotkey.fg.unwrap(), "still flashing just before 50 ms"); + assert_eq!(cell_fg(anim::HIGHLIGHT_SETTLE_MS), highlight.fg.unwrap(), "at 50 ms it settles to the normal highlight"); + assert_eq!(cell_fg(5_000), highlight.fg.unwrap(), "and stays settled"); +} + +#[test] +fn layered_quit_dialog_irises_without_reanimating_the_dialog_beneath() { + // Help finished opening long ago; the quit prompt opens over it 30 ms ago. + let (mut state, _) = update(State { clock_ms: T0 - 5_000, ..animated_state() }, Command::HelpOpen); + assert_eq!(state.dialog_opened_at_ms, T0 - 5_000); + state.clock_ms = T0; + let (state, _) = update(state, Command::RequestQuit); + assert_eq!(state.quit_opened_at_ms, T0); + assert_eq!(state.dialog_opened_at_ms, T0 - 5_000, "the beneath stamp is untouched"); + + let mid = text(&render_buffer(&at(&state, 30))); + assert!(mid.contains("About FileCommand"), "the Help window beneath stays fully drawn:\n{mid}"); + + // And once the iris is done the frame equals the non-animated one. + assert_eq!(render_buffer(&at(&state, anim::DIALOG_IRIS_MS)), render_buffer(&finished(&state))); +} + +/// Bounding box `(x0, y0, x1, y1)` of the cells in `a` that differ from `b`, +/// or `None` when the buffers are identical. +fn diff_bounds(a: &Buffer, b: &Buffer) -> Option<(u16, u16, u16, u16)> { + let mut bounds: Option<(u16, u16, u16, u16)> = None; + for y in 0..a.area.height { + for x in 0..a.area.width { + if a[(x, y)] != b[(x, y)] { + bounds = Some(match bounds { + None => (x, y, x, y), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + }); + } + } + } + bounds +} + +#[test] +fn the_iris_grows_from_the_center_of_the_finished_dialog() { + let state = mkdir_dialog_state(); + // What is on screen with no dialog at all: the panels the iris opens onto. + let panels = render_buffer(&State { phase: UiPhase::Panels, ..state.clone() }); + let (fx0, fy0, fx1, fy1) = diff_bounds(&render_buffer(&finished(&state)), &panels).expect("the dialog draws something"); + let (full_w, full_h) = (fx1 - fx0 + 1, fy1 - fy0 + 1); + let (cx, cy) = ((fx0 + fx1) as i32, (fy0 + fy1) as i32); // doubled centers, to stay in integers + + let mut last_area = 0u32; + for t in [0u64, 25, 50, 75, 99] { + let (x0, y0, x1, y1) = diff_bounds(&render_buffer(&at(&state, t)), &panels).unwrap_or_else(|| panic!("a sliver is open at {t} ms")); + assert!(x0 >= fx0 && y0 >= fy0 && x1 <= fx1 && y1 <= fy1, "the iris stays inside the finished dialog at {t} ms"); + assert!(((x0 + x1) as i32 - cx).abs() <= 1 && ((y0 + y1) as i32 - cy).abs() <= 1, "the window stays centered at {t} ms"); + let area = (x1 - x0 + 1) as u32 * (y1 - y0 + 1) as u32; + assert!(area >= last_area, "the window never shrinks ({t} ms)"); + last_area = area; + } + // Roughly half the dialog's width and height at the midpoint (spec: "a + // centered window roughly half the dialog's width and height"). + let (x0, y0, x1, y1) = diff_bounds(&render_buffer(&at(&state, 50)), &panels).unwrap(); + let (mid_w, mid_h) = (x1 - x0 + 1, y1 - y0 + 1); + assert!(mid_w.abs_diff(full_w / 2) <= 2, "mid width {mid_w} vs dialog {full_w}"); + assert!(mid_h.abs_diff(full_h / 2) <= 2, "mid height {mid_h} vs dialog {full_h}"); + assert!(last_area < full_w as u32 * full_h as u32, "still smaller than the dialog just before 100 ms"); +} + +/// The screen column (not byte offset — box glyphs are three bytes each) at +/// which `needle` starts in `row`. +fn column_of(row: &str, needle: &str) -> u16 { + row[..row.find(needle).expect("needle is in the row")].chars().count() as u16 +} + +/// The pop-up unfold invariants, checked against any pop-up's finished +/// rectangle: at every step the visible box is no taller than the finished +/// one, always closes with the bottom frame edge directly under its last +/// row, grows monotonically, and is complete at `POPUP_UNFOLD_MS`. +fn assert_unfolds_top_down(name: &str, state: &State) { + let panels = render_buffer(&State { overlay: None, ..state.clone() }); + let (_, fy0, _, fy1) = diff_bounds(&render_buffer(&finished(state)), &panels).unwrap_or_else(|| panic!("{name}: draws nothing")); + let full_h = fy1 - fy0 + 1; + + let mut last_h = 0; + for t in [0u64, 20, 40, 50, 60, 80, 99] { + let buf = render_buffer(&at(state, t)); + let (_, y0, _, y1) = diff_bounds(&buf, &panels).unwrap_or_else(|| panic!("{name}: nothing visible at {t} ms")); + assert_eq!(y0, fy0, "{name}: the top frame row is first and never moves ({t} ms)"); + let h = y1 - y0 + 1; + assert!(h <= full_h, "{name}: {h} rows at {t} ms exceeds the finished {full_h}"); + assert!(h >= last_h, "{name}: the box never shrinks ({t} ms)"); + assert!(h >= 2, "{name}: at least the top and bottom frame edges ({t} ms)"); + // The last visible row is the bottom frame edge (single or double line). + let bottom: String = (0..80u16).map(|x| buf[(x, y1)].symbol().to_string()).collect(); + assert!(bottom.contains('\u{2514}') || bottom.contains('\u{255A}'), "{name}: bottom edge missing at {t} ms: `{bottom}`"); + last_h = h; + } + assert!(last_h < full_h, "{name}: still unfolding just before {} ms", anim::POPUP_UNFOLD_MS); + assert_eq!(render_buffer(&at(state, anim::POPUP_UNFOLD_MS)), render_buffer(&finished(state)), "{name}: complete at the unfold duration"); +} + +#[test] +fn user_menu_and_action_menu_unfold_like_the_pulldowns() { + use filecommand_core::config::UserMenuEntry; + + let mut base = animated_state(); + base.user_menu_entries = (1..=6).map(|i| UserMenuEntry { label: format!("Entry {i}"), command: format!("cmd{i}") }).collect(); + let (user_menu, _) = update(base.clone(), Command::UserMenuOpen); + assert_eq!(user_menu.user_menu().unwrap().opened_at_ms, T0); + assert_unfolds_top_down("F2 user menu", &user_menu); + + // Enter on a file opens the action menu (the cursor starts on `docs`; move to a file). + let mut base = animated_state(); + base.left.cursor = 2; // Cargo.toml + let (action_menu, _) = update(base, Command::Enter); + assert_eq!(action_menu.file_action_menu().expect("Enter on a file opens the menu").opened_at_ms, T0); + assert_unfolds_top_down("Enter action menu", &action_menu); +} + +#[test] +fn user_menu_and_action_menu_highlights_settle() { + use filecommand_core::config::UserMenuEntry; + + let theme = Theme::classic(); + let hotkey = role_style(&theme, Role::MenuHotkey, ColorDepth::Ansi16).fg.unwrap(); + let highlight = role_style(&theme, Role::MenuHighlight, ColorDepth::Ansi16).fg.unwrap(); + let pressed = T0 + 300; // well after the unfold finished + + let mut base = State { clock_ms: T0, ..animated_state() }; + base.user_menu_entries = (1..=3).map(|i| UserMenuEntry { label: format!("Entry {i}"), command: format!("c{i}") }).collect(); + let (state, _) = update(base, Command::UserMenuOpen); + let (state, _) = update(State { clock_ms: pressed, ..state }, Command::UserMenuMove(1)); + assert_eq!(state.user_menu().unwrap().selected_at_ms, Some(pressed)); + // Find the highlighted row by its label: "Entry 2". + let fg_of_entry_2 = |t: u64| { + let buf = render_buffer(&State { clock_ms: pressed + t, ..state.clone() }); + let rows = text(&buf); + let (y, row) = rows.lines().enumerate().find(|(_, r)| r.contains("Entry 2")).expect("Entry 2 is listed"); + buf[(column_of(row, "Entry 2"), y as u16)].fg + }; + assert_eq!(fg_of_entry_2(10), hotkey, "user menu: flashing in the hotkey accent"); + assert_eq!(fg_of_entry_2(anim::HIGHLIGHT_SETTLE_MS), highlight, "user menu: settled"); + + let mut base = State { clock_ms: T0, ..animated_state() }; + base.left.cursor = 2; + let (state, _) = update(base, Command::Enter); + let (state, _) = update(State { clock_ms: pressed, ..state }, Command::FileActionMenuMove(1)); + let second = state.file_action_menu().unwrap().entries[1].label(); + let fg_of_second = |t: u64| { + let buf = render_buffer(&State { clock_ms: pressed + t, ..state.clone() }); + let rows = text(&buf); + let (y, row) = rows.lines().enumerate().find(|(_, r)| r.contains(second) && r.contains('\u{2551}')).expect("entry is listed"); + buf[(column_of(row, second), y as u16)].fg + }; + assert_eq!(fg_of_second(10), hotkey, "action menu: flashing in the hotkey accent"); + assert_eq!(fg_of_second(anim::HIGHLIGHT_SETTLE_MS), highlight, "action menu: settled"); +} + +#[test] +fn the_scratch_sentinel_never_reaches_the_screen() { + // The reveal helpers draw into a scratch buffer pre-filled with U+0001 and + // copy only touched cells. A view that recolored cells without writing a + // symbol would leak that marker; this fails loudly if one ever does. + let mut user_menu_state = animated_state(); + user_menu_state.user_menu_entries = vec![filecommand_core::config::UserMenuEntry { label: "Entry".into(), command: "c".into() }]; + let (user_menu_state, _) = update(user_menu_state, Command::UserMenuOpen); + let mut action_menu_state = animated_state(); + action_menu_state.left.cursor = 2; + let (action_menu_state, _) = update(action_menu_state, Command::Enter); + let (help_state, _) = update(animated_state(), Command::HelpOpen); + let (quit_state, _) = update(animated_state(), Command::RequestQuit); + + let surfaces = [ + ("splash", splash_state()), + ("menu", menu_state()), + ("mkdir dialog", mkdir_dialog_state()), + ("user menu", user_menu_state), + ("action menu", action_menu_state), + ("help", help_state), + ("quit", quit_state), + ("panel wipe", State { panels_revealed_at_ms: Some(T0), ..animated_state() }), + ]; + for (name, state) in surfaces { + for t in (0..=200).step_by(10) { + let rendered = render_text(&at(&state, t)); + assert!(!rendered.contains('\u{1}'), "{name}: sentinel leaked onto the screen at {t} ms"); + } + } +} diff --git a/crates/filecommand-tui/tests/snapshot_matrix.rs b/crates/filecommand-tui/tests/snapshot_matrix.rs index 2b1a614..57ad8fd 100644 --- a/crates/filecommand-tui/tests/snapshot_matrix.rs +++ b/crates/filecommand-tui/tests/snapshot_matrix.rs @@ -41,9 +41,9 @@ fn sample_panel(display_mode: DisplayMode) -> PanelState { let mut panel = PanelState::new(PathBuf::from(r"C:\Users\demo\projects\filecommand")); panel.entries = vec![ Entry::parent_dir(), - Entry { name: OsString::from("docs"), kind: EntryKind::Directory, size: 0, modified: Some(fixed_date()) }, - Entry { name: OsString::from("Cargo.toml"), kind: EntryKind::File, size: 612, modified: Some(fixed_date()) }, - Entry { name: OsString::from("readme.txt"), kind: EntryKind::File, size: 12_345, modified: Some(fixed_date()) }, + Entry::new(OsString::from("docs"), EntryKind::Directory, 0, Some(fixed_date()), false, false), + Entry::new(OsString::from("Cargo.toml"), EntryKind::File, 612, Some(fixed_date()), false, false), + Entry::new(OsString::from("readme.txt"), EntryKind::File, 12_345, Some(fixed_date()), false, false), ]; panel.progress = ListingProgress::Complete { count: panel.entries.len() }; panel.display_mode = display_mode; diff --git a/crates/filecommand-tui/tests/snapshot_scrolling.rs b/crates/filecommand-tui/tests/snapshot_scrolling.rs index 379e7b7..10047bd 100644 --- a/crates/filecommand-tui/tests/snapshot_scrolling.rs +++ b/crates/filecommand-tui/tests/snapshot_scrolling.rs @@ -44,9 +44,7 @@ fn buffer_to_text(buf: &Buffer, area: Rect) -> String { /// `filei` for `i` in `0..n`, so position `p` (`p >= 1`) in the unfiltered /// visible list is always `file{p-1:02}`. fn many_entries(n: usize) -> Vec { - std::iter::once(Entry::parent_dir()) - .chain((0..n).map(|i| Entry { name: format!("file{i:02}").into(), kind: EntryKind::File, size: 0, modified: None })) - .collect() + std::iter::once(Entry::parent_dir()).chain((0..n).map(|i| Entry::new(format!("file{i:02}"), EntryKind::File, 0, None, false, false))).collect() } fn overflow_panel(cwd: &str, n_files: usize, cursor: usize) -> PanelState { @@ -121,10 +119,10 @@ fn snapshot_full_mode_overflow_thumb_at_middle_shows_scrolled_window_contents() let n_files = rows + 12; let total = n_files + 1; let max_offset = total - rows; // == 13, independent of `rows`, since n_files == rows + 12 - // A cursor position just past the initial window's bottom edge lands the - // reconciled offset partway through `0..max_offset` — strictly between - // the two ends, unlike a `total / 2` cursor which (at these dimensions) - // is still inside the unscrolled top window and wouldn't move it at all. + // A cursor position just past the initial window's bottom edge lands the + // reconciled offset partway through `0..max_offset` — strictly between + // the two ends, unlike a `total / 2` cursor which (at these dimensions) + // is still inside the unscrolled top window and wouldn't move it at all. let mid_cursor = rows + 5; let mut panel = overflow_panel(r"C:\Users\demo\left", n_files, mid_cursor); panel.ensure_cursor_visible(rows); @@ -184,7 +182,7 @@ fn snapshot_brief_mode_overflow_shifted_column_window() { fn overflow_tree_panel(n_children: usize, cursor: usize, rows: usize) -> PanelState { let mut panel = overflow_panel(r"C:\Users\demo\left", 3, 0); let mut tree = TreeState::new(PathBuf::from(r"C:\"), DisplayMode::Full); - let children: Vec = (0..n_children).map(|i| Entry { name: format!("dir{i:02}").into(), kind: EntryKind::Directory, size: 0, modified: None }).collect(); + let children: Vec = (0..n_children).map(|i| Entry::new(format!("dir{i:02}"), EntryKind::Directory, 0, None, false, false)).collect(); tree.insert_children(&PathBuf::from(r"C:\"), children); tree.cursor = cursor; tree.ensure_cursor_visible(rows); diff --git a/crates/filecommand-tui/tests/snapshot_views.rs b/crates/filecommand-tui/tests/snapshot_views.rs index 3460e14..1a55858 100644 --- a/crates/filecommand-tui/tests/snapshot_views.rs +++ b/crates/filecommand-tui/tests/snapshot_views.rs @@ -21,7 +21,7 @@ use filecommand_core::menu::{MenuId, MenuState}; use filecommand_core::panel::{ClipboardFeedback, DisplayMode, ListingProgress, PanelState, SortDirection, TreeState}; use filecommand_core::theme::{ColorDepth, Theme}; use filecommand_core::viewer::{ByteSource, ViewMode, ViewerState}; -use filecommand_core::{DragState, DropTarget, PanelSide, State, UiPhase}; +use filecommand_core::{DragState, DropTarget, Overlay, PanelSide, State, UiPhase}; use filecommand_tui::views; @@ -49,9 +49,9 @@ fn fixed_date() -> DateTime { fn fixed_entries() -> Vec { vec![ Entry::parent_dir(), - Entry { name: "docs".into(), kind: EntryKind::Directory, size: 0, modified: Some(fixed_date()) }, - Entry { name: "Cargo.toml".into(), kind: EntryKind::File, size: 612, modified: Some(fixed_date()) }, - Entry { name: "readme.txt".into(), kind: EntryKind::File, size: 12_345, modified: Some(fixed_date()) }, + Entry::new("docs", EntryKind::Directory, 0, Some(fixed_date()), false, false), + Entry::new("Cargo.toml", EntryKind::File, 612, Some(fixed_date()), false, false), + Entry::new("readme.txt", EntryKind::File, 12_345, Some(fixed_date()), false, false), ] } @@ -72,12 +72,7 @@ fn streaming_panel(cwd: &str, count: usize) -> PanelState { } fn base_state(phase: UiPhase, theme: Theme) -> State { - State { - left: complete_panel(r"C:\Users\demo\left", 1), - right: complete_panel(r"C:\Users\demo\right", 0), - phase, - ..State::empty(theme) - } + State { left: complete_panel(r"C:\Users\demo\left", 1), right: complete_panel(r"C:\Users\demo\right", 0), phase, ..State::empty(theme) } } /// Render `state` at `width`x`height` through a real ratatui `TestBackend` @@ -185,8 +180,9 @@ fn drop_dialog_state(focused: JobKind) -> State { kind: focused, sources: vec![SourceItem { original_name: "readme.txt".into(), path: PathBuf::from(r"C:\Users\demo\left\readme.txt"), is_dir: false }], source_dir: PathBuf::from(r"C:\Users\demo\left"), - input: r"C:\Users\demo\right".to_string(), + input: filecommand_core::line_edit::LineEdit::from(r"C:\Users\demo\right"), buttons: Some(DropButtons { focused }), + hidden_by_filter: 0, }); state } @@ -207,6 +203,233 @@ fn snapshot_drop_dialog_with_move_focused() { insta::assert_snapshot!("drop_dialog_move_focused", text); } +// ----------------------------------------------------------------- +// filter-selection-safety: the hidden-selection disclosure on the delete +// confirmation and destination dialogs (selection "Operations disclose +// selected entries hidden by the quick filter"; tasks.md 2.4/2.5/4.6) +// ----------------------------------------------------------------- + +fn hidden_disclosure_sources(n: usize) -> Vec { + (0..n) + .map(|i| SourceItem { + original_name: format!("f{i}.txt").into(), + path: PathBuf::from(format!(r"C:\Users\demo\left\f{i}.txt")), + is_dir: false, + }) + .collect() +} + +fn delete_confirm_state(sources: usize, hidden_by_filter: usize) -> State { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpSetup(FileOpSetup::DeleteConfirm { + sources: hidden_disclosure_sources(sources), + source_dir: PathBuf::from(r"C:\Users\demo\left"), + needs_second_confirm: false, + confirmed_once: false, + hidden_by_filter, + }); + state +} + +#[test] +fn snapshot_delete_confirm_discloses_hidden_selection() { + let text = render_to_text(80, 24, &delete_confirm_state(5, 2), ColorDepth::Ansi16); + assert!(text.contains("Delete 5 files? This cannot be undone."), "the question is untouched:\n{text}"); + assert!(text.contains("2 of 5 selected hidden by filter"), "the disclosure row is shown:\n{text}"); + insta::assert_snapshot!("delete_confirm_hidden_by_filter", text); +} + +#[test] +fn snapshot_delete_confirm_discloses_a_single_hidden_selection() { + let text = render_to_text(80, 24, &delete_confirm_state(1, 1), ColorDepth::Ansi16); + assert!(text.contains("Delete f0.txt? This cannot be undone."), "the single item is still named:\n{text}"); + assert!(text.contains("Selected item is hidden by filter"), "singular wording:\n{text}"); + insta::assert_snapshot!("delete_confirm_hidden_single", text); +} + +#[test] +fn snapshot_delete_confirm_without_hidden_selection_is_unchanged() { + let text = render_to_text(80, 24, &delete_confirm_state(5, 0), ColorDepth::Ansi16); + assert!(text.contains("Delete 5 files? This cannot be undone.")); + assert!(!text.contains("hidden by filter"), "no disclosure without hidden selections:\n{text}"); + insta::assert_snapshot!("delete_confirm_no_hidden", text); +} + +fn destination_input_state(sources: usize, hidden_by_filter: usize) -> State { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpSetup(FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: hidden_disclosure_sources(sources), + source_dir: PathBuf::from(r"C:\Users\demo\left"), + input: filecommand_core::line_edit::LineEdit::from(r"C:\Users\demo\right"), + buttons: None, + hidden_by_filter, + }); + state +} + +#[test] +fn snapshot_destination_input_discloses_hidden_selection() { + let text = render_to_text(80, 24, &destination_input_state(5, 2), ColorDepth::Ansi16); + assert!(text.contains("2 of 5 selected hidden by filter"), "the disclosure is shown:\n{text}"); + assert!(!text.contains("[ Copy ]"), "no button row appears — the box is unchanged:\n{text}"); + insta::assert_snapshot!("destination_input_hidden_by_filter", text); +} + +#[test] +fn snapshot_destination_input_discloses_a_single_hidden_selection() { + let text = render_to_text(80, 24, &destination_input_state(1, 1), ColorDepth::Ansi16); + assert!(text.contains("Selected item is hidden by filter"), "singular wording:\n{text}"); + insta::assert_snapshot!("destination_input_hidden_single", text); +} + +#[test] +fn snapshot_destination_input_without_hidden_selection_is_unchanged() { + let text = render_to_text(80, 24, &destination_input_state(5, 0), ColorDepth::Ansi16); + assert!(!text.contains("hidden by filter"), "no disclosure without hidden selections:\n{text}"); + insta::assert_snapshot!("destination_input_no_hidden", text); +} + +// ----------------------------------------------------------------- +// error-diagnostics: the job dialogs name full paths, and the end-of-job +// summary groups skipped items and warnings (tasks 4.3, 5.5). +// ----------------------------------------------------------------- + +/// filesystem-error-handling "Error messages name the affected path" +/// scenario "The error-recovery dialog shows the full path": the failing +/// item's path on one row, the OS message on the next. +#[test] +fn snapshot_error_dialog_shows_the_full_path_above_the_message() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpRunning { + source_dir: PathBuf::from(r"D:\data\reports"), + dest_dir: PathBuf::from(r"C:\Users\demo\right"), + dialog: filecommand_core::fs_ops::dialog::RunningDialog::Error { + kind: JobKind::Copy, + info: filecommand_core::fs_ops::ErrorInfo { + path: PathBuf::from(r"D:\data\reports\q3.xlsx"), + message: "Access is denied. (os error 5)".to_string(), + }, + progress: filecommand_core::fs_ops::ProgressInfo::starting(3, 30_000), + }, + }; + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains(r"D:\data\reports\q3.xlsx"), "the path row is shown:\n{text}"); + assert!(text.contains("Access is denied. (os error 5)"), "the message row follows it:\n{text}"); + insta::assert_snapshot!("error_dialog_full_path_above_message", text); +} + +// ----------------------------------------------------------------- +// process-lifecycle-hardening: the overwrite-conflict dialog's Cancel row +// (operation-dialogs "Overwrite-conflict dialog"; design D7; task 4.6) +// ----------------------------------------------------------------- + +fn conflict_dialog_state(rename_input: Option) -> State { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpRunning { + source_dir: PathBuf::from(r"D:\data\reports"), + dest_dir: PathBuf::from(r"C:\Users\demo\right"), + dialog: filecommand_core::fs_ops::dialog::RunningDialog::Conflict { + kind: JobKind::Copy, + info: filecommand_core::fs_ops::ConflictInfo { + source_name: "report.txt".into(), + source_size: 2_048, + source_modified: Some(fixed_date()), + target_path: PathBuf::from(r"C:\Users\demo\right\report.txt"), + target_size: 900, + target_modified: Some(DateTime { year: 2026, month: 1, day: 1, hour: 12, minute: 30 }), + }, + progress: filecommand_core::fs_ops::ProgressInfo::starting(10, 20_480), + rename_input, + }, + }; + state +} + +/// Scenario "The Cancel control and hotkey match Esc": the `(C)ancel` +/// control is drawn on its own row directly below the existing choice row, +/// which is unchanged (including its pre-existing truncation at the +/// dialog's inner width). +#[test] +fn snapshot_conflict_dialog_with_cancel_row() { + let state = conflict_dialog_state(None); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let choice_line = text.lines().position(|l| l.contains("(O)verwrite (S)kip (R)ename")).expect("the choice row is drawn"); + let cancel_line = text.lines().position(|l| l.contains("(C)ancel")).expect("the Cancel row is drawn"); + assert_eq!(cancel_line, choice_line + 1, "Cancel sits directly below the choices:\n{text}"); + insta::assert_snapshot!("conflict_dialog_with_cancel_row", text); +} + +/// Scenario "Esc in the Rename field returns to the choices": while the +/// name field is open, Esc closes the field rather than cancelling the job, +/// so the Cancel row is not offered — the field takes the choice row and +/// the row below it is blank. +#[test] +fn snapshot_conflict_dialog_rename_field_hides_the_cancel_row() { + let mut edit = filecommand_core::line_edit::LineEdit::default(); + for c in "report2.txt".chars() { + edit.apply(&filecommand_core::line_edit::TextEditOp::Insert(c)); + } + let state = conflict_dialog_state(Some(edit)); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("New name:"), "the rename field is shown:\n{text}"); + assert!(!text.contains("(C)ancel"), "no Cancel control while renaming:\n{text}"); + insta::assert_snapshot!("conflict_dialog_rename_field_open", text); +} + +/// Regression pin for the pre-existing skipped-only rendering (task 4.3): +/// each summary row is `{full path}: {reason}`. +#[test] +fn snapshot_skipped_summary_skipped_items() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpSummary(vec![ + filecommand_core::fs_ops::SkippedItem::skipped(PathBuf::from(r"D:\data\old.log"), "target already exists".to_string()), + filecommand_core::fs_ops::SkippedItem::skipped(PathBuf::from(r"D:\data\archive.zip"), "target already exists".to_string()), + ]); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("Skipped 2 item(s)"), "the title counts the skipped items:\n{text}"); + assert!(text.contains(r"D:\data\old.log: target already exists"), "each row is path: reason:\n{text}"); + insta::assert_snapshot!("skipped_summary_skipped_items", text); +} + +/// operation-dialogs "Metadata-restore failures are reported in the +/// end-of-job summary": a warnings-only summary is titled by its warning +/// count. +#[test] +fn snapshot_skipped_summary_warnings_only() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + // The reason is kept short so the whole row fits the 80-column screen + // un-truncated — the point here is the grouping, not the OS text. + state.phase = UiPhase::FileOpSummary(vec![filecommand_core::fs_ops::SkippedItem::warning( + PathBuf::from(r"C:\dst\report.docx"), + "modification time not restored: denied".to_string(), + )]); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("1 warning(s)"), "the title is the warning count:\n{text}"); + assert!(text.contains("Warnings"), "the warnings group is headed:\n{text}"); + assert!(text.contains(r"C:\dst\report.docx: modification time not restored: denied"), "{text}"); + insta::assert_snapshot!("skipped_summary_warnings_only", text); +} + +/// "Warnings and skipped items appear together": one summary, skipped items +/// first, the warnings group second, both counts in the title. +#[test] +fn snapshot_skipped_summary_skipped_and_warnings() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpSummary(vec![ + filecommand_core::fs_ops::SkippedItem::skipped(PathBuf::from(r"D:\data\a.txt"), "target already exists".to_string()), + filecommand_core::fs_ops::SkippedItem::skipped(PathBuf::from(r"D:\data\b.txt"), "target already exists".to_string()), + filecommand_core::fs_ops::SkippedItem::warning(PathBuf::from(r"D:\data\c.txt"), "read-only attribute not restored: denied".to_string()), + ]); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("Skipped 2 item(s), 1 warning(s)"), "the title gives both counts:\n{text}"); + let skipped_pos = text.find(r"D:\data\a.txt: target already exists").expect("skipped row present"); + let header_pos = text.find("Warnings").expect("the warnings group header is present"); + let warning_pos = text.find(r"D:\data\c.txt: read-only attribute not restored: denied").expect("warning row present"); + assert!(skipped_pos < header_pos && header_pos < warning_pos, "skipped rows come before the Warnings group:\n{text}"); + insta::assert_snapshot!("skipped_summary_skipped_and_warnings", text); +} + #[test] fn snapshot_splash_nc_classic() { let state = base_state(UiPhase::Splash { started_at_ms: 0 }, Theme::classic()); @@ -221,13 +444,47 @@ fn snapshot_splash_nc_mono() { insta::assert_snapshot!("splash_nc_mono", text); } +/// The "terminal too small" placeholder is `State::undersized` beside the +/// phase, not a phase of its own, so it is drawn over whatever screen is +/// active without discarding it (application-shell "Resize handling with +/// 60x16 hard floor and placeholder"; resize-preserves-session-state design +/// D1/D5). +fn undersized_state(phase: UiPhase) -> State { + State { undersized: true, term_size: (40, 10), ..base_state(phase, Theme::classic()) } +} + #[test] fn snapshot_terminal_too_small_placeholder() { - let state = base_state(UiPhase::Placeholder, Theme::classic()); + let state = undersized_state(UiPhase::Panels); let text = render_to_text(40, 10, &state, ColorDepth::Ansi16); insta::assert_snapshot!("placeholder_too_small", text); } +#[test] +fn snapshot_placeholder_hides_the_editor_identically() { + // The hidden screen never shows through: the placeholder over a modified + // editor buffer is byte-identical to the one over the panels. + let mut editor = EditorState::from_bytes(PathBuf::from("notes.txt"), b"one\ntwo\nthree\n").expect("valid UTF-8 fixture"); + editor.type_char('x'); + assert!(editor.is_modified()); + let over_editor = render_to_text(40, 10, &undersized_state(UiPhase::Editor(editor)), ColorDepth::Ansi16); + let over_panels = render_to_text(40, 10, &undersized_state(UiPhase::Panels), ColorDepth::Ansi16); + assert_eq!(over_editor, over_panels, "the placeholder must not leak the hidden editor"); + insta::assert_snapshot!("placeholder_over_editor", over_editor); +} + +#[test] +fn snapshot_quit_confirm_dialog_over_the_placeholder() { + // application-shell "F10 over the placeholder asks to quit": the quit + // dialog is drawn over the placeholder, exactly as it is over any phase. + let mut state = undersized_state(UiPhase::Panels); + state.term_size = (50, 12); + state.quit_confirm = true; + let text = render_to_text(50, 12, &state, ColorDepth::Ansi16); + assert!(text.contains("Quit FileCommand?"), "the dialog must be drawn over the placeholder"); + insta::assert_snapshot!("quit_confirm_dialog_over_placeholder", text); +} + #[test] fn snapshot_fkey_bar() { let state = base_state(UiPhase::Panels, Theme::classic()); @@ -276,7 +533,7 @@ fn quit_confirm_dialog_renders_above_an_open_pull_down_menu() { // task 4.5: the dialog can be raised while an F9 pull-down menu is open // — same topmost-overlay contract as above the viewer. let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); state.quit_confirm = true; let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Quit FileCommand?"), "the dialog must be drawn over the open pull-down"); @@ -305,7 +562,7 @@ fn command_line_row(text: &str) -> &str { #[test] fn snapshot_command_line_with_prompt_and_typed_text() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.command_line = "dir *.txt".to_string(); + state.command_line = filecommand_core::line_edit::LineEdit::from("dir *.txt"); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let row = command_line_row(&text); assert!(row.contains(r"C:\Users\demo\left>dir *.txt"), "prompt + buffer in `{row}`"); @@ -324,12 +581,80 @@ fn command_line_prompt_follows_the_active_panel() { fn snapshot_command_line_recalling_history() { let mut state = base_state(UiPhase::Panels, Theme::classic()); state.history = vec!["cd docs".to_string(), "type readme.txt".to_string()]; - state.command_line = "type readme.txt".to_string(); + state.command_line = filecommand_core::line_edit::LineEdit::from("type readme.txt"); state.history_cursor = Some(1); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("command_line_history_recall", command_line_row(&text)); } +/// Full-frame render that also reports the terminal cursor position +/// `views::render` computed (and applies it to the frame, exactly as +/// `app.rs`'s `draw` does), for the text-field caret tests. +fn render_with_cursor(width: u16, height: u16, state: &State) -> (String, Option<(u16, u16)>) { + let identity_lines = pinned_identity_lines(); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("TestBackend terminal"); + let mut cursor = None; + terminal + .draw(|frame| { + let area = frame.area(); + let output = views::render(frame.buffer_mut(), area, state, ColorDepth::Ansi16, &identity_lines, FIXED_CLOCK_TEXT, None); + cursor = output.cursor; + if let Some(pos) = output.cursor { + frame.set_cursor_position(pos); + } + }) + .expect("draw into TestBackend"); + (buffer_to_text(terminal.backend().buffer()), cursor) +} + +#[test] +fn snapshot_destination_field_scrolled_so_the_caret_is_visible() { + // text-field-editing "Long destination scrolls to the caret": a + // 60-character path in the 40-cell field shows the last 40 characters + // with the caret at the end, and the terminal cursor sits at the + // field's last cell. + let long_path = r"D:\backup\2026\reports\archive\final\q3\old\verylong\name\xy"; + assert_eq!(long_path.chars().count(), 60, "the fixture path must be 60 chars for the 40-cell field"); + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.phase = UiPhase::FileOpSetup(FileOpSetup::DestinationInput { + kind: JobKind::Copy, + sources: vec![SourceItem { original_name: "readme.txt".into(), path: PathBuf::from(r"C:\Users\demo\left\readme.txt"), is_dir: false }], + source_dir: PathBuf::from(r"C:\Users\demo\left"), + input: filecommand_core::line_edit::LineEdit::from(long_path), + buttons: None, + hidden_by_filter: 0, + }); + let (text, cursor) = render_with_cursor(80, 24, &state); + assert!(text.contains(r"[ts\archive\final\q3\old\verylong\name\xy]"), "the window scrolled to the last 40 chars:\n{text}"); + // Dialog centered at (17, 9): the field's text starts at column 20 and + // the caret (at the end of a full window) clamps onto its last cell. + assert_eq!(cursor, Some((20 + 39, 9 + 3)), "the terminal cursor sits at the field's last cell"); + insta::assert_snapshot!("destination_field_scrolled_to_caret", text); +} + +#[test] +fn snapshot_command_line_with_the_caret_mid_text() { + // command-line "Caret editing mid-line": `cd srv`, Left x2, `c` -> + // `cd scrv` — the row shows the whole line and the terminal cursor sits + // at the caret (the drawn `_` is gone). + let mut line = filecommand_core::line_edit::LineEdit::from("cd srv"); + line.left(); + line.left(); + line.insert('c'); + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.command_line = line; + let (text, cursor) = render_with_cursor(80, 24, &state); + let row = command_line_row(&text); + assert!(row.contains(r"C:\Users\demo\left>cd scrv"), "`{row}`"); + assert!(!row.contains('_'), "the drawn caret is replaced by the terminal cursor: `{row}`"); + // Prompt `C:\Users\demo\left>` is 19 cells; the caret is after the + // inserted `c` at char 5; the command-line row is second from the + // bottom. + assert_eq!(cursor, Some((19 + 5, 22)), "the terminal cursor sits at the caret"); + insta::assert_snapshot!("command_line_caret_mid_text", row); +} + #[test] fn the_clock_is_drawn_over_the_right_end_of_the_right_panel_s_top_border() { let state = base_state(UiPhase::Panels, Theme::classic()); @@ -344,13 +669,13 @@ fn the_f9_bar_hides_the_clock_and_closing_it_restores_the_clock() { let without_menu = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(without_menu.lines().next().unwrap().contains(FIXED_CLOCK_TEXT), "clock shows with the bar closed"); - state.menu = Some(MenuState::opened()); + state.open_overlay(Overlay::Menu(MenuState::opened())); let with_menu = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(!with_menu.lines().next().unwrap().contains(FIXED_CLOCK_TEXT), "clock is hidden while F9 is open"); - // Esc-ing the bar closed (state.menu back to None) restores it — same + // Esc-ing the bar closed (state.menu() back to None) restores it — same // as any other frame where the bar isn't open. - state.menu = None; + state.close_overlay(); let restored = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(restored.lines().next().unwrap().contains(FIXED_CLOCK_TEXT), "clock is restored once the bar closes"); } @@ -373,7 +698,7 @@ fn the_clock_hides_entirely_rather_than_colliding_with_a_long_path_title() { #[test] fn snapshot_menu_bar_with_left_pulldown_open() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.menu = Some(MenuState::opened()); + state.open_overlay(Overlay::Menu(MenuState::opened())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.lines().next().unwrap().contains("Left")); insta::assert_snapshot!("menu_bar_left_pulldown", text); @@ -382,7 +707,7 @@ fn snapshot_menu_bar_with_left_pulldown_open() { #[test] fn snapshot_menu_bar_with_files_pulldown_open() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.menu = Some(MenuState::for_menu(MenuId::Files)); + state.open_overlay(Overlay::Menu(MenuState::for_menu(MenuId::Files))); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("menu_bar_files_pulldown", text); } @@ -392,7 +717,7 @@ fn snapshot_menu_bar_with_pulldown_closed() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut menu = MenuState::opened(); menu.pulldown_open = false; - state.menu = Some(menu); + state.open_overlay(Overlay::Menu(menu)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("menu_bar_no_pulldown", text.lines().next().unwrap()); } @@ -401,7 +726,7 @@ fn snapshot_menu_bar_with_pulldown_closed() { fn the_menu_bar_replaces_the_panels_top_border_row() { let plain = render_to_text(80, 24, &base_state(UiPhase::Panels, Theme::classic()), ColorDepth::Ansi16); let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.menu = Some(MenuState::opened()); + state.open_overlay(Overlay::Menu(MenuState::opened())); let with_menu = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(plain.lines().next().unwrap().contains('\u{2554}'), "normally the top row is panel border"); @@ -415,7 +740,7 @@ fn the_menu_bar_replaces_the_panels_top_border_row() { #[test] fn snapshot_drive_select_dialog_labels_pending() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.drive_select = Some(DriveSelect::new(PanelSide::Left, vec!['A', 'C', 'D', 'Z'], Some('C'))); + state.open_overlay(Overlay::DriveSelect(DriveSelect::new(PanelSide::Left, vec!['A', 'C', 'D', 'Z'], Some('C')))); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("drive_select_labels_pending", text); } @@ -429,7 +754,7 @@ fn snapshot_drive_select_dialog_labels_resolved() { dialog.apply_label('Z', Some("net".to_string())); // A: has no media, so its fetch never resolves and its column stays // blank — which must not hold up the rest of the dialog. - state.drive_select = Some(dialog); + state.open_overlay(Overlay::DriveSelect(dialog)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("drive_select_labels_resolved", text); } @@ -603,7 +928,8 @@ fn editor_state(editor: EditorState) -> State { #[test] fn snapshot_editor_chrome_unmodified() { - let editor = EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"The quick brown fox\njumps over the lazy dog.\n"); + let editor = + EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"The quick brown fox\njumps over the lazy dog.\n").expect("valid UTF-8 fixture"); let state = editor_state(editor); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); insta::assert_snapshot!("editor_chrome_unmodified", text); @@ -616,7 +942,7 @@ fn snapshot_editor_header_position_and_modified_indicator() { // other file in this codebase), caret on line 12 column 8 (both // 1-based, matching the spec's header example), buffer modified. let content: String = (1..=440).map(|i| format!("line {i}\n")).collect(); - let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\big.txt"), content.as_bytes()); + let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\big.txt"), content.as_bytes()).expect("valid UTF-8 fixture"); editor.caret.line = 11; editor.caret.col = 7; editor.type_char('!'); @@ -632,7 +958,7 @@ fn snapshot_editor_header_position_and_modified_indicator() { #[test] fn snapshot_editor_overwrite_indicator() { - let mut editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"abc\n"); + let mut editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"abc\n").expect("valid UTF-8 fixture"); editor.toggle_mode(); let state = editor_state(editor); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); @@ -643,7 +969,7 @@ fn snapshot_editor_overwrite_indicator() { #[test] fn snapshot_editor_keybar() { - let editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"abc\n"); + let editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"abc\n").expect("valid UTF-8 fixture"); let state = editor_state(editor); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let last = text.lines().last().unwrap(); @@ -653,7 +979,7 @@ fn snapshot_editor_keybar() { #[test] fn snapshot_editor_marked_selection() { - let mut editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"alpha\nbeta\ngamma\ndelta\n"); + let mut editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"alpha\nbeta\ngamma\ndelta\n").expect("valid UTF-8 fixture"); editor.start_mark(); editor.move_down(); let state = editor_state(editor); @@ -663,7 +989,7 @@ fn snapshot_editor_marked_selection() { #[test] fn snapshot_editor_save_on_exit_confirm() { - let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"abc\n"); + let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"abc\n").expect("valid UTF-8 fixture"); editor.type_char('!'); editor.quit_confirm = true; let state = editor_state(editor); @@ -672,9 +998,45 @@ fn snapshot_editor_save_on_exit_confirm() { insta::assert_snapshot!("editor_save_on_exit_confirm", text); } +/// builtin-editor "A failed save leaves the original intact" / "Read-only +/// file is not saved": the failure message is a one-row overlay on the last +/// body row, with the header and the F-key bar unchanged +/// (editor-encoding-safety design D6). +#[test] +fn snapshot_editor_save_failed() { + let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"abc\n").expect("valid UTF-8 fixture"); + editor.type_char('!'); + editor.save_error = Some("Cannot save notes.txt: notes.txt is read-only — not saved".to_string()); + let state = editor_state(editor); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let rows: Vec<&str> = text.lines().collect(); + assert!(rows[22].starts_with("Save failed: Cannot save notes.txt: notes.txt is read-only — not saved"), "`{}`", rows[22]); + assert!(rows[0].contains("Edit: C:\\notes.txt *"), "header unchanged: `{}`", rows[0]); + assert!(rows[23].trim_end().starts_with("1Help 2Save 3Mark 4Replac 5 6 7Search 8 9 10Quit"), "F-key bar unchanged: `{}`", rows[23]); + insta::assert_snapshot!("editor_save_failed", text); +} + +/// application-shell "Saving to a slow share shows progress and accepts no +/// edits" (offload-blocking-io design D6): while the save is in flight the +/// last body row reads `Saving…`, with the header and the F-key bar exactly +/// as builtin-editor "Full-screen editor chrome" specifies. +#[test] +fn snapshot_editor_saving() { + let mut editor = EditorState::from_bytes(PathBuf::from(r"C:\notes.txt"), b"abc\n").expect("valid UTF-8 fixture"); + editor.type_char('!'); + editor.save_pending = true; + let state = editor_state(editor); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let rows: Vec<&str> = text.lines().collect(); + assert!(rows[22].starts_with("Saving…"), "`{}`", rows[22]); + assert!(rows[0].contains("Edit: C:\\notes.txt *"), "header unchanged: `{}`", rows[0]); + assert!(rows[23].trim_end().starts_with("1Help 2Save 3Mark 4Replac 5 6 7Search 8 9 10Quit"), "F-key bar unchanged: `{}`", rows[23]); + insta::assert_snapshot!("editor_saving", text); +} + #[test] fn editor_replaces_the_panels_full_screen() { - let editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"content\n"); + let editor = EditorState::from_bytes(PathBuf::from("f.txt"), b"content\n").expect("valid UTF-8 fixture"); let state = editor_state(editor); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(!text.contains('\u{2554}'), "no panel border while the editor is open:\n{text}"); @@ -782,9 +1144,8 @@ fn snapshot_brief_mode_three_name_columns() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut panel = complete_panel(r"C:\Users\demo\left", 0); panel.display_mode = DisplayMode::Brief; - panel.entries = std::iter::once(Entry::parent_dir()) - .chain((0..8).map(|i| Entry { name: format!("f{i}.txt").into(), kind: EntryKind::File, size: 0, modified: None })) - .collect(); + panel.entries = + std::iter::once(Entry::parent_dir()).chain((0..8).map(|i| Entry::new(format!("f{i}.txt"), EntryKind::File, 0, None, false, false))).collect(); state.left = panel; // A short panel (6 body rows, after the command line and F-key bar // reserve their own rows) so the 9 entries visibly wrap into a second @@ -804,10 +1165,7 @@ fn tree_panel(prior_mode: DisplayMode, cursor: usize) -> PanelState { let mut tree = TreeState::new(PathBuf::from(r"C:\"), prior_mode); tree.insert_children( &PathBuf::from(r"C:\"), - vec![ - Entry { name: "Alpha".into(), kind: EntryKind::Directory, size: 0, modified: None }, - Entry { name: "Beta".into(), kind: EntryKind::Directory, size: 0, modified: None }, - ], + vec![Entry::new("Alpha", EntryKind::Directory, 0, None, false, false), Entry::new("Beta", EntryKind::Directory, 0, None, false, false)], ); tree.cursor = cursor; panel.display_mode = DisplayMode::Tree; @@ -861,7 +1219,7 @@ fn quick_view_previews_the_opposite_cursor_file() { state.left = left; let mut right = PanelState::new(file_path.parent().unwrap().to_path_buf()); - right.entries = vec![Entry { name: "preview.txt".into(), kind: EntryKind::File, size: 34, modified: None }]; + right.entries = vec![Entry::new("preview.txt", EntryKind::File, 34, None, false, false)]; right.progress = ListingProgress::Complete { count: 1 }; right.cursor = 0; state.right = right; @@ -872,7 +1230,10 @@ fn quick_view_previews_the_opposite_cursor_file() { assert!(text.contains("Hello from quick view"), "the previewed file's head is rendered like the viewer's text mode:\n{text}"); assert!(text.contains("second line"), "{text}"); let bottom_row = text.lines().nth(21).unwrap(); - assert!(bottom_row.contains("preview.txt") && bottom_row.contains('3') && bottom_row.contains('4'), "mini-status shows the previewed file's name and size: `{bottom_row}`"); + assert!( + bottom_row.contains("preview.txt") && bottom_row.contains('3') && bottom_row.contains('4'), + "mini-status shows the previewed file's name and size: `{bottom_row}`" + ); } #[test] @@ -883,7 +1244,7 @@ fn snapshot_quick_view_shows_sub_dir_indicator_for_a_directory_cursor() { state.left = left; let mut right = PanelState::new(PathBuf::from(r"C:\Users\demo\right")); - right.entries = vec![Entry { name: "docs".into(), kind: EntryKind::Directory, size: 0, modified: None }]; + right.entries = vec![Entry::new("docs", EntryKind::Directory, 0, None, false, false)]; right.progress = ListingProgress::Complete { count: 1 }; right.cursor = 0; state.right = right; @@ -931,6 +1292,85 @@ fn snapshot_git_info_absent_reserves_no_marker_column() { assert!(!with_no_git.lines().next().unwrap().contains('('), "no branch suffix outside a repo"); } +// --------------------------------------------------------------------- +// hidden-entry-styling: hidden/system entries render dimmed, in Full and +// Brief alike (task 3.2) +// --------------------------------------------------------------------- + +/// The task-3.2 fixture: a hidden directory (`.git`, under the cursor), a +/// plain directory, a hidden file, a *selected* hidden file, and a plain +/// file — one row per precedence tier of design D3. +fn hidden_entries_fixture() -> Vec { + vec![ + Entry::parent_dir(), + Entry::new(".git", EntryKind::Directory, 0, Some(fixed_date()), true, false), + Entry::new("docs", EntryKind::Directory, 0, Some(fixed_date()), false, false), + Entry::new("desktop.ini", EntryKind::File, 87, Some(fixed_date()), true, false), + Entry::new("secret.txt", EntryKind::File, 35_840, Some(fixed_date()), true, false), + Entry::new("readme.txt", EntryKind::File, 612, Some(fixed_date()), false, false), + ] +} + +fn hidden_entries_panel(cwd: &str, mode: DisplayMode) -> PanelState { + let mut panel = complete_panel(cwd, 1); // cursor on the hidden `.git` + panel.display_mode = mode; + panel.entries = hidden_entries_fixture(); + panel.progress = ListingProgress::Complete { count: panel.entries.len() }; + panel.selected.insert("secret.txt".into()); + panel +} + +#[test] +fn snapshot_hidden_entries_dimmed_in_full_and_brief() { + use filecommand_core::theme::Role; + use filecommand_tui::style::role_style; + + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.left = hidden_entries_panel(r"C:\Users\demo\left", DisplayMode::Full); + state.right = hidden_entries_panel(r"C:\Users\demo\right", DisplayMode::Brief); + + // The cursor bar only renders on the active panel, so each mode's + // precedence is asserted against a render with that panel active. + let theme = Theme::classic(); + let style_of = |role: Role| role_style(&theme, role, ColorDepth::Ansi16); + let hidden = style_of(Role::PanelHidden); + let selected = style_of(Role::PanelSelected); + let cursor = style_of(Role::PanelCursor); + let directory = style_of(Role::PanelDirectory); + let file = style_of(Role::PanelFile); + assert_ne!(hidden, file, "sanity: the hidden role differs from the file role"); + + // Full (left) panel, active: entry rows start at y = 2, names at x = 1. + let buf = render_to_buffer(80, 24, &state, ColorDepth::Ansi16); + let full_cell = |y: u16| buf[(1, y)].style(); + assert_eq!(full_cell(3).fg, cursor.fg, "cursor bar wins over hidden (.git)"); + assert_eq!(full_cell(4).fg, directory.fg, "plain directory unchanged"); + assert_eq!(full_cell(5).fg, hidden.fg, "hidden file dimmed (desktop.ini)"); + assert_eq!(full_cell(5).bg, hidden.bg); + assert_eq!(full_cell(6).fg, selected.fg, "selected beats hidden (secret.txt)"); + assert_eq!(full_cell(7).fg, file.fg, "plain file unchanged"); + + // Brief (right) panel, active: six entries fit in the first of the + // three columns, rows y = 1..6 at x = 41 (right panel starts at x = 40 + // in the default 50/50 split at 80 columns). + state.active = PanelSide::Right; + let buf = render_to_buffer(80, 24, &state, ColorDepth::Ansi16); + let brief_cell = |y: u16| buf[(41, y)].style(); + assert_eq!(brief_cell(1).fg, directory.fg, ".. keeps the directory style"); + assert_eq!(brief_cell(2).fg, cursor.fg, "cursor bar wins over hidden (.git)"); + assert_eq!(brief_cell(3).fg, directory.fg, "plain directory unchanged"); + assert_eq!(brief_cell(4).fg, hidden.fg, "hidden file dimmed in Brief too (desktop.ini)"); + assert_eq!(brief_cell(5).fg, selected.fg, "selected beats hidden (secret.txt)"); + assert_eq!(brief_cell(6).fg, file.fg, "plain file unchanged"); + + // The snapshot itself pins the glyph grid: hidden styling changes only + // colors, and the hidden directory keeps its ▶SUB-DIR◀ marker. + state.active = PanelSide::Left; + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("desktop.ini") && text.contains(".git"), "hidden entries still list:\n{text}"); + insta::assert_snapshot!("hidden_entries_full_and_brief", text); +} + // --------------------------------------------------------------------- // M5: quick-filter/type-ahead mini-status, and the fuzzy-jump, find-file, // user-menu, and Help/About dialogs (§8; quick-filter, type-ahead-jump, @@ -948,7 +1388,7 @@ fn mini_status_row(text: &str) -> &str { #[test] fn snapshot_quick_filter_mini_status_replaces_normal_content() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.left.quick_filter = Some("rep".to_string()); + state.left.set_quick_filter(Some("rep".to_string())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let row = mini_status_row(&text); assert!(row.contains("rep"), "`{row}`"); @@ -964,7 +1404,7 @@ fn quick_filter_narrows_the_full_mode_body_to_matching_entries() { // (quick-filter "the panel body is narrowed to entries whose displayed // name contains the pattern"). let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.left.quick_filter = Some("read".to_string()); + state.left.set_quick_filter(Some("read".to_string())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let left_half: String = text.lines().map(|l| l.chars().take(40).collect::()).collect::>().join("\n"); assert!(left_half.contains(".."), "`..` must always stay visible: {left_half}"); @@ -976,7 +1416,7 @@ fn quick_filter_narrows_the_full_mode_body_to_matching_entries() { #[test] fn quick_filter_leaves_the_opposite_panels_body_unfiltered() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.left.quick_filter = Some("read".to_string()); + state.left.set_quick_filter(Some("read".to_string())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let right_half: String = text.lines().map(|l| l.chars().skip(40).collect::()).collect::>().join("\n"); assert!(right_half.contains("docs"), "the opposite panel's body must stay unfiltered: {right_half}"); @@ -987,7 +1427,7 @@ fn quick_filter_leaves_the_opposite_panels_body_unfiltered() { fn quick_filter_narrows_the_brief_mode_body_to_matching_entries() { let mut state = base_state(UiPhase::Panels, Theme::classic()); state.left.display_mode = filecommand_core::panel::DisplayMode::Brief; - state.left.quick_filter = Some("read".to_string()); + state.left.set_quick_filter(Some("read".to_string())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); let left_half: String = text.lines().map(|l| l.chars().take(40).collect::()).collect::>().join("\n"); assert!(left_half.contains("readme.txt"), "the matching entry must stay visible: {left_half}"); @@ -1050,6 +1490,73 @@ fn snapshot_clipboard_feedback_mini_status_error() { insta::assert_snapshot!("clipboard_feedback_mini_status_error", row); } +// --------------------------------------------------------------------- +// offload-blocking-io: the pending note on the mini-status line (design D3) +// --------------------------------------------------------------------- + +/// command-line "cd to an unresponsive UNC path does not freeze the UI": +/// `Checking …` on the panel's mini-status while the probe runs. +#[test] +fn snapshot_pending_note_checking_mini_status() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.left.set_pending_note(r"Checking \\nas\share…".to_string()); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let row = mini_status_row(&text); + assert!(row.contains(r"Checking \\nas\share…"), "`{row}`"); + insta::assert_snapshot!("pending_note_checking_mini_status", row); +} + +/// application-shell "F4 on a slow share keeps the panels live": +/// `Opening …` on the panel's mini-status while the open runs. +#[test] +fn snapshot_pending_note_opening_mini_status() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.left.set_pending_note("Opening notes.txt…".to_string()); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let row = mini_status_row(&text); + assert!(row.contains("Opening notes.txt…"), "`{row}`"); + insta::assert_snapshot!("pending_note_opening_mini_status", row); +} + +/// The note ranks below the status override (clipboard feedback here) and +/// above `last_error`, and uses the normal ministatus style. +#[test] +fn pending_note_ranks_below_the_override_and_above_the_error() { + use filecommand_core::theme::Role; + use filecommand_tui::style::role_style; + + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.left.pending_note = Some("Opening notes.txt…".to_string()); + state.left.last_error = Some("Cannot open notes.txt: access denied".to_string()); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let row = mini_status_row(&text); + assert!(row.contains("Opening notes.txt…"), "the note wins over the error: `{row}`"); + assert!(!row.contains("Cannot open"), "`{row}`"); + + state.left.clipboard_feedback = + Some(ClipboardFeedback { message: "1 file copied to clipboard".to_string(), is_error: false, expires_at_ms: 3_000 }); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let row = mini_status_row(&text); + assert!(row.contains("1 file copied to clipboard"), "the override wins over the note: `{row}`"); + assert!(!row.contains("Opening"), "`{row}`"); + + // The note is drawn in the normal ministatus role, not the error role. + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.left.set_pending_note("Opening notes.txt…".to_string()); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).expect("TestBackend terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + views::render(frame.buffer_mut(), area, &state, ColorDepth::Ansi16, &pinned_identity_lines(), FIXED_CLOCK_TEXT, None); + }) + .unwrap(); + let buf = terminal.backend().buffer(); + let x = (0..80u16).find(|x| buf[(*x, 21)].symbol() == "O").expect("the note is on the mini-status row"); + let theme = Theme::classic(); + assert_eq!(buf[(x, 21)].fg, role_style(&theme, Role::PanelMinistatus, ColorDepth::Ansi16).fg.unwrap()); +} + #[test] fn clipboard_feedback_takes_precedence_over_the_quick_filter_mini_status() { // Ctrl+C is matched over the panels ahead of the quick-filter's @@ -1057,7 +1564,7 @@ fn clipboard_feedback_takes_precedence_over_the_quick_filter_mini_status() { // the same frame; the feedback must win (clipboard-export "Clipboard // feedback"). let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.left.quick_filter = Some("rep".to_string()); + state.left.set_quick_filter(Some("rep".to_string())); state.left.clipboard_feedback = Some(ClipboardFeedback { message: "1 file copied to clipboard".to_string(), is_error: false, expires_at_ms: 3_000 }); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); @@ -1082,7 +1589,7 @@ fn snapshot_fuzzy_jump_dialog() { filecommand_core::quicksearch::FrecencyEntry { path: PathBuf::from(r"C:\Users\demo\Documents"), visit_count: 5, last_visited_ms: 1_000 }, filecommand_core::quicksearch::FrecencyEntry { path: PathBuf::from(r"C:\Users\demo\Downloads"), visit_count: 1, last_visited_ms: 500 }, ]; - state.fuzzy_jump = Some(filecommand_core::quicksearch::FuzzyJumpState::new()); + state.open_overlay(Overlay::FuzzyJump(filecommand_core::quicksearch::FuzzyJumpState::new())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Fuzzy jump")); assert!(text.contains("Documents") && text.contains("Downloads")); @@ -1093,18 +1600,18 @@ fn snapshot_fuzzy_jump_dialog() { fn snapshot_find_file_dialog_with_streamed_results() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut dialog = filecommand_core::find_file::FindFileState::new(PathBuf::from(r"C:\Users\demo\left")); - dialog.push('r'); - dialog.push('e'); + dialog.apply(&filecommand_core::line_edit::TextEditOp::Insert('r')); + dialog.apply(&filecommand_core::line_edit::TextEditOp::Insert('e')); dialog.submit(1); dialog.push_match( 1, filecommand_core::listing::FindMatch { relative_path: PathBuf::from(r"docs\readme.txt"), - entry: Entry { name: "readme.txt".into(), kind: EntryKind::File, size: 12_345, modified: Some(fixed_date()) }, + entry: Entry::new("readme.txt", EntryKind::File, 12_345, Some(fixed_date()), false, false), }, ); dialog.mark_done(1); - state.find_file = Some(dialog); + state.open_overlay(Overlay::FindFile(dialog)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Find file")); assert!(text.contains(r"docs\readme.txt")); @@ -1115,10 +1622,10 @@ fn snapshot_find_file_dialog_with_streamed_results() { fn snapshot_find_file_dialog_no_matches() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut dialog = filecommand_core::find_file::FindFileState::new(PathBuf::from(r"C:\Users\demo\left")); - dialog.push('z'); + dialog.apply(&filecommand_core::line_edit::TextEditOp::Insert('z')); dialog.submit(1); dialog.mark_done(1); - state.find_file = Some(dialog); + state.open_overlay(Overlay::FindFile(dialog)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("No matches")); insta::assert_snapshot!("find_file_dialog_no_matches", text); @@ -1132,7 +1639,7 @@ fn snapshot_user_menu_dialog_with_entries() { filecommand_core::config::UserMenuEntry { label: "Backup".to_string(), command: r"robocopy . D:\backup /E".to_string() }, filecommand_core::config::UserMenuEntry { label: "Checksum".to_string(), command: "certutil -hashfile x SHA256".to_string() }, ]; - state.user_menu = Some(filecommand_core::dialogs::UserMenuState::new()); + state.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Compress") && text.contains("Backup") && text.contains("Checksum")); assert!(!text.contains("robocopy"), "the command string is never shown, only the label"); @@ -1144,7 +1651,7 @@ fn snapshot_user_menu_dialog_with_entries() { #[test] fn snapshot_user_menu_dialog_empty_state() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.user_menu = Some(filecommand_core::dialogs::UserMenuState::new()); + state.open_overlay(Overlay::UserMenu(filecommand_core::dialogs::UserMenuState::new())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("no entries")); assert!(text.contains("Themes"), "the built-in Themes row renders below the placeholder even with no user entries"); @@ -1152,10 +1659,69 @@ fn snapshot_user_menu_dialog_empty_state() { insta::assert_snapshot!("user_menu_dialog_empty", text); } +// --------------------------------------------------------------------- +// pulldown-menu-completion: the Alt+F8 command-history dialog and the +// Ctrl+U swap (tasks 3.2; command-line "Command history dialog (Alt+F8)"; +// panel-navigation "Swap panels (Ctrl+U)") +// --------------------------------------------------------------------- + +#[test] +fn snapshot_history_dialog_populated() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.history = vec!["cd C:\\work".to_string(), "del old.log".to_string(), "cd D:\\".to_string()]; + state.open_overlay(Overlay::HistoryDialog(filecommand_core::dialogs::HistoryDialogState::new())); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("History")); + // Newest first: `cd D:\` renders above `del old.log`, above `cd C:\work`. + let newest = text.find("cd D:\\").unwrap(); + let middle = text.find("del old.log").unwrap(); + let oldest = text.find("cd C:\\work").unwrap(); + assert!(newest < middle && middle < oldest, "newest entry renders first:\n{text}"); + insta::assert_snapshot!("history_dialog_populated", text); +} + +#[test] +fn snapshot_history_dialog_empty() { + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.open_overlay(Overlay::HistoryDialog(filecommand_core::dialogs::HistoryDialogState::new())); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(text.contains("(no history)")); + insta::assert_snapshot!("history_dialog_empty", text); +} + +#[test] +fn snapshot_panels_after_ctrl_u_swap() { + // Through the real reducer: the two panels trade places, the active side + // (left) and its cursor bar stay put, and the command-line prompt follows + // the directory now shown on the left (panel-navigation "Swap keeps + // focus on the same side"). The git info moves with its panel, so the + // branch suffix/marker column the *inactive* right panel was holding + // invisibly surfaces on the left border once the swap brings it under + // the active panel ("Git info follows the swap"). + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.right.git_info = GitInfo { + branch: Some("main".to_string()), + statuses: std::collections::HashMap::from([(std::ffi::OsString::from("readme.txt"), FileStatus::Modified)]), + }; + let (state, effects) = filecommand_core::update(state, filecommand_core::Command::SwapPanels); + assert!(effects.iter().all(|e| !matches!(e, filecommand_core::Effect::StartListing { .. })), "no directory is re-read"); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + let top = text.lines().next().unwrap(); + let left_top: String = top.chars().take(40).collect(); + let right_top: String = top.chars().skip(40).collect(); + assert!(left_top.contains(r"C:\Users\demo\right (main)"), "the left border shows the right's directory and its branch: `{left_top}`"); + assert!(right_top.contains(r"C:\Users\demo\left"), "the right panel now shows the left's directory: `{right_top}`"); + assert!(!right_top.contains("(main)"), "no suffix on the inactive (repo-less) right border: `{right_top}`"); + // The marker column came along too: `M` beside readme.txt on the left. + assert_eq!(text.lines().nth(5).unwrap().chars().nth(1), Some('M'), "the marker column moved with the panel"); + assert!(command_line_row(&text).contains(r"C:\Users\demo\right>"), "the prompt follows the swap"); + insta::assert_snapshot!("panels_after_swap", text); +} + #[test] fn snapshot_theme_picker_dialog_with_active_theme_marked() { let mut state = base_state(UiPhase::Panels, Theme::terminal_green()); - state.theme_picker = Some(filecommand_core::dialogs::ThemePickerState::open("terminal-green")); + state.open_overlay(Overlay::ThemePicker(filecommand_core::dialogs::ThemePickerState::open("terminal-green"))); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Themes")); for name in filecommand_core::theme::BUILTIN_THEME_NAMES { @@ -1195,12 +1761,12 @@ fn snapshot_theme_picker_dialog_previews_a_non_active_highlighted_theme() { // render through `purple-lights`'s role table, while the marker stays // on the applied theme's (`nc-classic`) row (theme-selection scenario // "Moving the highlight previews the theme"). - use filecommand_core::theme::{BUILTIN_THEME_NAMES, Role}; + use filecommand_core::theme::{Role, BUILTIN_THEME_NAMES}; use filecommand_tui::style::role_style; let mut state = base_state(UiPhase::Panels, Theme::classic()); let highlight = BUILTIN_THEME_NAMES.iter().position(|n| *n == "purple-lights").unwrap(); - state.theme_picker = Some(filecommand_core::dialogs::ThemePickerState { highlight }); + state.open_overlay(Overlay::ThemePicker(filecommand_core::dialogs::ThemePickerState { highlight })); let buf = render_to_buffer(80, 24, &state, ColorDepth::Ansi16); let text = buffer_to_text(&buf); @@ -1237,7 +1803,7 @@ fn theme_picker_open_with_the_active_theme_highlighted_is_byte_identical_to_the_ let before = render_to_buffer(80, 24, &state, ColorDepth::Ansi16); let mut opened = state; - opened.theme_picker = Some(filecommand_core::dialogs::ThemePickerState::open("nc-classic")); + opened.open_overlay(Overlay::ThemePicker(filecommand_core::dialogs::ThemePickerState::open("nc-classic"))); let after = render_to_buffer(80, 24, &opened, ColorDepth::Ansi16); // Derive the dialog's exact footprint from its own box-drawing corners @@ -1319,36 +1885,72 @@ fn snapshot_fkey_bar_under_each_new_theme() { #[test] fn snapshot_file_action_menu_dialog_non_executable() { - // file-action-menu "Menu contents, ordering, and navigation": - // non-executable targets list View, Edit, Copy, Rename, Move, Delete - // with View highlighted first, and no Run entry. + // file-action-menu "Menu contents, ordering, and navigation" + // (open-with-associated-app design D2): non-executable targets list + // Open, View, Edit, Send to clipboard, Copy, Rename, Move, Delete with + // Open highlighted first, and no Run entry. let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.file_action_menu = Some(filecommand_core::dialogs::FileActionMenuState::new(std::ffi::OsString::from("readme.txt"), false)); + state.open_overlay(Overlay::FileActionMenu(filecommand_core::dialogs::FileActionMenuState::new(std::ffi::OsString::from("readme.txt"), false))); + let menu = state.file_action_menu().unwrap(); + assert_eq!(menu.entries[0], filecommand_core::dialogs::FileActionMenuEntry::Open, "Open is listed first"); + assert_eq!(menu.selected(), filecommand_core::dialogs::FileActionMenuEntry::Open, "Open is highlighted on open"); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(!text.contains("Run"), "a non-executable target must not list Run"); - assert!(text.contains("View") && text.contains("Edit") && text.contains("Copy")); + assert!(text.contains("Open") && text.contains("View") && text.contains("Edit") && text.contains("Copy")); assert!(text.contains("Rename") && text.contains("Move") && text.contains("Delete")); + let open_pos = text.find("Open").unwrap(); + let view_pos = text.find("View").unwrap(); + assert!(open_pos < view_pos, "Open must render before View"); insta::assert_snapshot!("file_action_menu_dialog_non_executable", text); } #[test] fn snapshot_file_action_menu_dialog_executable() { - // file-action-menu "Menu contents, ordering, and navigation": an - // executable target lists Run first (default-highlighted). + // file-action-menu "Menu contents, ordering, and navigation" + // (open-with-associated-app design D2): an executable target lists Run + // first (default-highlighted), then Open, then View. let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.file_action_menu = Some(filecommand_core::dialogs::FileActionMenuState::new(std::ffi::OsString::from("setup.exe"), true)); + state.open_overlay(Overlay::FileActionMenu(filecommand_core::dialogs::FileActionMenuState::new(std::ffi::OsString::from("setup.exe"), true))); + let menu = state.file_action_menu().unwrap(); + assert_eq!(menu.selected(), filecommand_core::dialogs::FileActionMenuEntry::Run, "Run stays highlighted on open"); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Run"), "an executable target must list Run"); let run_pos = text.find("Run").unwrap(); + let open_pos = text.find("Open").unwrap(); let view_pos = text.find("View").unwrap(); - assert!(run_pos < view_pos, "Run must render before View"); + assert!(run_pos < open_pos, "Run must render before Open"); + assert!(open_pos < view_pos, "Open must render before View"); insta::assert_snapshot!("file_action_menu_dialog_executable", text); } +#[test] +fn snapshot_file_action_menu_dialog_directory() { + // file-action-menu "Directory targets and selection-scoped invocation": + // a directory target lists Open, Send to clipboard, Copy, Rename, Move, + // Delete — Open first and highlighted, no View/Edit/Run — and Open + // shows the directory in the platform file browser. + let mut state = base_state(UiPhase::Panels, Theme::classic()); + state.open_overlay(Overlay::FileActionMenu(filecommand_core::dialogs::FileActionMenuState::open( + std::ffi::OsString::from("docs"), + true, + false, + false, + ))); + let menu = state.file_action_menu().unwrap(); + assert_eq!(menu.entries[0], filecommand_core::dialogs::FileActionMenuEntry::Open, "Open is listed first"); + assert_eq!(menu.selected(), filecommand_core::dialogs::FileActionMenuEntry::Open, "Open is highlighted on open"); + let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); + assert!(!text.contains("Run"), "a directory target must not list Run"); + let open_pos = text.find("Open").unwrap(); + let clipboard_pos = text.find("Send to clipboard").unwrap(); + assert!(open_pos < clipboard_pos, "Open must render before Send to clipboard"); + insta::assert_snapshot!("file_action_menu_dialog_directory", text); +} + #[test] fn snapshot_help_window_topic_list() { let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.help = Some(filecommand_core::dialogs::HelpState::new()); + state.open_overlay(Overlay::Help(filecommand_core::dialogs::HelpState::new())); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Help")); assert!(text.contains("About FileCommand")); @@ -1361,7 +1963,7 @@ fn snapshot_help_window_topic_page() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut help = filecommand_core::dialogs::HelpState::new(); help.page = Some(1); // Keyboard reference - state.help = Some(help); + state.open_overlay(Overlay::Help(help)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("modifier variants")); assert!(!text.contains("About FileCommand"), "the list is replaced by the page"); @@ -1373,7 +1975,7 @@ fn snapshot_about_dialog_over_help_window() { let mut state = base_state(UiPhase::Panels, Theme::classic()); let mut help = filecommand_core::dialogs::HelpState::new(); help.about_open = true; - state.help = Some(help); + state.open_overlay(Overlay::Help(help)); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("License: MIT OR Apache-2.0")); assert!(text.contains("OK")); @@ -1386,7 +1988,7 @@ fn snapshot_startup_warning_dialog_overlays_the_panels() { // warning dialog (not a panel mini-status) at startup (user-menu // "Malformed file warns and falls back without overwriting"). let mut state = base_state(UiPhase::Panels, Theme::classic()); - state.startup_warning = Some("usermenu.toml is malformed; F2 uses the default user menu".to_string()); + state.set_startup_warning("usermenu.toml is malformed; F2 uses the default user menu".to_string()); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Warning")); assert!(text.contains("usermenu.toml is malformed")); @@ -1400,7 +2002,7 @@ fn snapshot_startup_warning_dialog_overlays_the_splash_screen_too() { // with the splash screen — it must still be visible on top of it rather // than getting lost underneath. let mut state = base_state(UiPhase::Splash { started_at_ms: 0 }, Theme::classic()); - state.startup_warning = Some("usermenu.toml is malformed; F2 uses the default user menu".to_string()); + state.set_startup_warning("usermenu.toml is malformed; F2 uses the default user menu".to_string()); let text = render_to_text(80, 24, &state, ColorDepth::Ansi16); assert!(text.contains("Warning")); assert!(text.contains("usermenu.toml is malformed")); diff --git a/crates/filecommand-tui/tests/snapshots.rs b/crates/filecommand-tui/tests/snapshots.rs index fd289c7..b795eab 100644 --- a/crates/filecommand-tui/tests/snapshots.rs +++ b/crates/filecommand-tui/tests/snapshots.rs @@ -38,18 +38,22 @@ fn fixed_identity() -> [String; 4] { fn sample_entries() -> Vec { vec![ Entry::parent_dir(), - Entry { - name: OsString::from("Documents"), - kind: EntryKind::Directory, - size: 0, - modified: Some(DateTime { year: 2026, month: 8, day: 7, hour: 14, minute: 36 }), - }, - Entry { - name: OsString::from("notes.txt"), - kind: EntryKind::File, - size: 2048, - modified: Some(DateTime { year: 2026, month: 8, day: 7, hour: 9, minute: 5 }), - }, + Entry::new( + OsString::from("Documents"), + EntryKind::Directory, + 0, + Some(DateTime { year: 2026, month: 8, day: 7, hour: 14, minute: 36 }), + false, + false, + ), + Entry::new( + OsString::from("notes.txt"), + EntryKind::File, + 2048, + Some(DateTime { year: 2026, month: 8, day: 7, hour: 9, minute: 5 }), + false, + false, + ), ] } @@ -86,11 +90,11 @@ fn full_panel_active_and_inactive_nc_classic() { /// snapshots protect against — a plain `assert_eq!` against a literal /// golden string, which (unlike an `insta` snapshot) cannot be /// silently re-accepted with `cargo insta review`/`--accept` without a -/// deliberate source edit. Pins the exact 80x24 nominal-size rendering -/// the column-ladder (D3) and Brief-formula (D4) anchors both promise is -/// byte-identical to the pre-responsive-layout output (panel-navigation -/// "Full display mode layout"; additional-panel-modes "Brief display -/// mode"; responsive-layout task 6.3). +/// deliberate source edit. Pins the exact 80x24 nominal-size rendering of +/// the Full-mode layout: the `│` separator directly followed by the Size +/// column, every Size/Date/Time value complete (`▶UP--DIR◀`, `MM-DD-YY`, +/// `HH:MM`), and the row filling the interior exactly (panel-navigation +/// "Full display mode layout"; full-mode-column-widths design D1). #[test] fn full_panel_at_80x24_matches_the_pinned_golden_string() { let state = sample_state(Theme::classic()); @@ -99,13 +103,13 @@ fn full_panel_at_80x24_matches_the_pinned_golden_string() { let header_row = text.lines().nth(1).unwrap(); assert_eq!( header_row, - "\u{2551}Name\u{2193} \u{2502} Size Date Time \u{2551}\u{2551}Name\u{2193} \u{2502} Size Date Time \u{2551}", - "the Full-mode column header at the 80x24 nominal size changed (panel-navigation \"Full display mode layout\", D3 anchor)" + "\u{2551}Name\u{2193} \u{2502}Size Date Time \u{2551}\u{2551}Name\u{2193} \u{2502}Size Date Time \u{2551}", + "the Full-mode column header at the 80x24 nominal size changed (panel-navigation \"Full display mode layout\")" ); let first_entry_row = text.lines().nth(2).unwrap(); assert_eq!( first_entry_row, - "\u{2551}.. \u{2502} \u{25B6}UP--DI \u{2551}\u{2551} \u{2551}", + "\u{2551}.. \u{2502}\u{25B6}UP--DIR\u{25C4} \u{2551}\u{2551} \u{2551}", "the Full-mode entry row at the 80x24 nominal size changed" ); let last_line = text.lines().last().unwrap().trim_end(); @@ -133,7 +137,10 @@ fn splash_nc_mono() { #[test] fn terminal_too_small_placeholder() { - let state = State { phase: UiPhase::Placeholder, term_size: (40, 10), ..State::empty(Theme::classic()) }; + // "Terminal too small" is `State::undersized` beside the phase, not a + // phase (resize-preserves-session-state design D1); the accepted + // snapshot is unchanged by construction (design D5). + let state = State { undersized: true, term_size: (40, 10), ..State::empty(Theme::classic()) }; insta::assert_snapshot!("terminal_too_small_placeholder", render(&state, (40, 10))); } @@ -149,7 +156,7 @@ fn splash_renders_at_60x16() { fn placeholder_below_the_floor() { // application-shell "Placeholder below the floor": one column below // the new 60x16 floor still shows the placeholder, naming 60x16. - let state = State { phase: UiPhase::Placeholder, term_size: (59, 16), ..State::empty(Theme::classic()) }; + let state = State { undersized: true, term_size: (59, 16), ..State::empty(Theme::classic()) }; let text = render(&state, (59, 16)); assert!(text.contains("60x16"), "expected the placeholder to name the 60x16 floor:\n{text}"); insta::assert_snapshot!("placeholder_below_the_60x16_floor", text); diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_0ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_0ms.snap new file mode 100644 index 0000000..162d327 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_0ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 0))" +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ y: ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_100ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_100ms.snap new file mode 100644 index 0000000..3ec2986 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_100ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 100))" +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔══════════════ Make Directory ══════════════╗ ║ +║ ║ ║ ║ +║ ║ Name of new directory: ║ ║ +║ ║ [........................................] ║ ║ +║ ║ ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_50ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_50ms.snap new file mode 100644 index 0000000..30c7cc2 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__dialog_iris_50ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 50))" +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║ +║ ew directory: ║ +║ ........................ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__menu_bar_sweep_50ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__menu_bar_sweep_50ms.snap new file mode 100644 index 0000000..0e6db44 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__menu_bar_sweep_50ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 50))" +--- + Left Files Commands Option╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__panel_wipe_75ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__panel_wipe_75ms.snap new file mode 100644 index 0000000..0487e91 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__panel_wipe_75ms.snap @@ -0,0 +1,16 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 75))" +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_0ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_0ms.snap new file mode 100644 index 0000000..648611f --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_0ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 0))" +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║┌─────────────────────────┐e Time ║║Name↓ │Size Date Time ║ +║└─────────────────────────┘ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_100ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_100ms.snap new file mode 100644 index 0000000..edc6472 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_100ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 100))" +--- + Left Files Commands Options Right +║┌─────────────────────────┐e Time ║║Name↓ │Size Date Time ║ +║│ Brief │ ║║.. │▶UP--DIR◄ ║ +║│ Full │02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║│ Tree │02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║│ Quick view │02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║│ Info Ctrl-L │ ║║ ║ +║├─────────────────────────┤ ║║ ║ +║│ Name Ctrl-F3 │ ║║ ║ +║│ Extension Ctrl-F4 │ ║║ ║ +║│ Modif. time Ctrl-F5 │ ║║ ║ +║│ Size Ctrl-F6 │ ║║ ║ +║│ Unsorted Ctrl-F7 │ ║║ ║ +║├─────────────────────────┤ ║║ ║ +║│ Filter Ctrl-P │ ║║ ║ +║│ Re-read Ctrl-R │ ║║ ║ +║│ Drive select Alt-F1/F2 │ ║║ ║ +║├─────────────────────────┤ ║║ ║ +║│ New tab Ctrl-T │ ║║ ║ +║│ Close tab Ctrl-W │ ║║ ║ +║└─────────────────────────┘ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_50ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_50ms.snap new file mode 100644 index 0000000..ebc72b7 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__pulldown_unfold_50ms.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 50))" +--- + Left Files Commands Option╔════════ C:\Users\demo\right ═══3:04 PM +║┌─────────────────────────┐e Time ║║Name↓ │Size Date Time ║ +║│ Brief │ ║║.. │▶UP--DIR◄ ║ +║│ Full │02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║│ Tree │02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║│ Quick view │02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║│ Info Ctrl-L │ ║║ ║ +║├─────────────────────────┤ ║║ ║ +║│ Name Ctrl-F3 │ ║║ ║ +║│ Extension Ctrl-F4 │ ║║ ║ +║│ Modif. time Ctrl-F5 │ ║║ ║ +║└─────────────────────────┘ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_0ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_0ms.snap new file mode 100644 index 0000000..6ae1e7c --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_0ms.snap @@ -0,0 +1,5 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 0))" +--- + diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_100ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_100ms.snap new file mode 100644 index 0000000..859e37a --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_100ms.snap @@ -0,0 +1,19 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 100))" +--- + + + + + + + + ╔══════════════════════════════════════════════╗ + ║ + ║ + ║ + ║ + ║ + ║ + ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_400ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_400ms.snap new file mode 100644 index 0000000..0d8f8b4 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_400ms.snap @@ -0,0 +1,20 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 400))" +--- + + + + + + + + ╔══════════════════════════════════════════════╗ + ║ ║ + ║ FileCommand ║ + ║ Version 0.1.0 ║ + ║ ║ + ║ 2026 The FileC ║ + ║ ║ + ║ ║ + ╚══════════════════════════════════════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_650ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_650ms.snap new file mode 100644 index 0000000..f95329a --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_650ms.snap @@ -0,0 +1,20 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 650))" +--- + + + + + + + + ╔══════════════════════════════════════════════╗ + ║ ║ + ║ FileCommand ║ + ║ Version 0.1.0 ║ + ║ ║ + ║ Copyright (C) 2026 The FileCommand Authors ║ + ║ Inspired by the Norton Commander, 1986-1998 ║ + ║ ║ + ╚══════════════════════════════════════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_800ms.snap b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_800ms.snap new file mode 100644 index 0000000..8d67d5a --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_animations__splash_entrance_800ms.snap @@ -0,0 +1,20 @@ +--- +source: crates/filecommand-tui/tests/snapshot_animations.rs +expression: "render_text(&at(&state, 800))" +--- + + + + + + + + ╔══════════════════════════════════════════════╗ + ║ ║ + ║ FileCommand ║ + ║ Version 0.1.0 ║ + ║ ║ + ║ Copyright (C) 2026 The FileCommand Authors ║ + ║ Inspired by the Norton Commander, 1986-1998 ║ + ║ ║ + ╚══════════════════════════════════════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_120x30.snap b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_120x30.snap index e2c14ce..a4b0899 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_120x30.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_120x30.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_matrix.rs expression: text --- ╔═══════════ C:\Users\demo\projects\filecommand ═══════════╗ -║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║ ║ ║ ║ ║ ║ ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_60x16.snap b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_60x16.snap index 08cd55d..338170c 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_60x16.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_60x16.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_matrix.rs -assertion_line: 97 expression: text --- ╔ C:\Users\demo\projects\file╗ -║Name↓ │ Size ║ -║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI ║ -║Cargo.toml │ 612 ║ -║readme.txt │ 12K ║ +║Name↓ │Size ║ +║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ ║ +║Cargo.toml │ 612 ║ +║readme.txt │ 12K ║ ║ ║ ║ ║ ║ ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_70x20.snap b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_70x20.snap index 7398217..7e8118e 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_70x20.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_70x20.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_matrix.rs expression: text --- ╔ C:\Users\demo\projects\filecomma╗ -║Name↓ │ Size Date ║ -║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 ║ -║Cargo.toml │ 612 01-02-2 ║ -║readme.txt │ 12K 01-02-2 ║ +║Name↓ │Size Date ║ +║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 ║ +║Cargo.toml │ 612 01-02-26 ║ +║readme.txt │ 12K 01-02-26 ║ ║ ║ ║ ║ ║ ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_80x24.snap b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_80x24.snap index f4346eb..cd9fbc5 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_80x24.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_matrix__matrix_full_panel_80x24.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_matrix.rs expression: text --- ╔═ C:\Users\demo\projects\filecommand ═╗ -║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║ ║ ║ ║ ║ ║ ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_bottom.snap b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_bottom.snap index 0722db4..b0b4fed 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_bottom.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_bottom.snap @@ -3,24 +3,24 @@ source: crates/filecommand-tui/tests/snapshot_scrolling.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗ -║Name↓ │ Size Date Time ║ -║file12 │ 0 ░ -║file13 │ 0 ░ -║file14 │ 0 ░ -║file15 │ 0 ░ -║file16 │ 0 ░ -║file17 │ 0 ░ -║file18 │ 0 ░ -║file19 │ 0 ░ -║file20 │ 0 █ -║file21 │ 0 █ -║file22 │ 0 █ -║file23 │ 0 █ -║file24 │ 0 █ -║file25 │ 0 █ -║file26 │ 0 █ -║file27 │ 0 █ -║file28 │ 0 █ -║file29 │ 0 █ -║file30 │ 0 █ +║Name↓ │Size Date Time ║ +║file12 │ 0 ░ +║file13 │ 0 ░ +║file14 │ 0 ░ +║file15 │ 0 ░ +║file16 │ 0 ░ +║file17 │ 0 ░ +║file18 │ 0 ░ +║file19 │ 0 ░ +║file20 │ 0 █ +║file21 │ 0 █ +║file22 │ 0 █ +║file23 │ 0 █ +║file24 │ 0 █ +║file25 │ 0 █ +║file26 │ 0 █ +║file27 │ 0 █ +║file28 │ 0 █ +║file29 │ 0 █ +║file30 │ 0 █ ╚═════════════ file30 0 ══════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_middle_scrolled_contents.snap b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_middle_scrolled_contents.snap index 796de83..9d64793 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_middle_scrolled_contents.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_middle_scrolled_contents.snap @@ -3,24 +3,24 @@ source: crates/filecommand-tui/tests/snapshot_scrolling.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗ -║Name↓ │ Size Date Time ║ -║file05 │ 0 ░ -║file06 │ 0 ░ -║file07 │ 0 ░ -║file08 │ 0 ░ -║file09 │ 0 █ -║file10 │ 0 █ -║file11 │ 0 █ -║file12 │ 0 █ -║file13 │ 0 █ -║file14 │ 0 █ -║file15 │ 0 █ -║file16 │ 0 █ -║file17 │ 0 █ -║file18 │ 0 █ -║file19 │ 0 █ -║file20 │ 0 ░ -║file21 │ 0 ░ -║file22 │ 0 ░ -║file23 │ 0 ░ +║Name↓ │Size Date Time ║ +║file05 │ 0 ░ +║file06 │ 0 ░ +║file07 │ 0 ░ +║file08 │ 0 ░ +║file09 │ 0 █ +║file10 │ 0 █ +║file11 │ 0 █ +║file12 │ 0 █ +║file13 │ 0 █ +║file14 │ 0 █ +║file15 │ 0 █ +║file16 │ 0 █ +║file17 │ 0 █ +║file18 │ 0 █ +║file19 │ 0 █ +║file20 │ 0 ░ +║file21 │ 0 ░ +║file22 │ 0 ░ +║file23 │ 0 ░ ╚═════════════ file23 0 ══════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_top.snap b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_top.snap index c349577..7c9850e 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_top.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_scrolling__full_mode_overflow_thumb_top.snap @@ -3,24 +3,24 @@ source: crates/filecommand-tui/tests/snapshot_scrolling.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗ -║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI █ -║file00 │ 0 █ -║file01 │ 0 █ -║file02 │ 0 █ -║file03 │ 0 █ -║file04 │ 0 █ -║file05 │ 0 █ -║file06 │ 0 █ -║file07 │ 0 █ -║file08 │ 0 █ -║file09 │ 0 █ -║file10 │ 0 ░ -║file11 │ 0 ░ -║file12 │ 0 ░ -║file13 │ 0 ░ -║file14 │ 0 ░ -║file15 │ 0 ░ -║file16 │ 0 ░ -║file17 │ 0 ░ +║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ █ +║file00 │ 0 █ +║file01 │ 0 █ +║file02 │ 0 █ +║file03 │ 0 █ +║file04 │ 0 █ +║file05 │ 0 █ +║file06 │ 0 █ +║file07 │ 0 █ +║file08 │ 0 █ +║file09 │ 0 █ +║file10 │ 0 ░ +║file11 │ 0 ░ +║file12 │ 0 ░ +║file13 │ 0 ░ +║file14 │ 0 ░ +║file15 │ 0 ░ +║file16 │ 0 ░ +║file17 │ 0 ░ ╚════════════ .. UP--DIR ═════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__about_dialog_over_help_window.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__about_dialog_over_help_window.snap index 3bd9a3b..6034a78 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__about_dialog_over_help_window.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__about_dialog_over_help_window.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ ║.. ╔═══════════════════════════ Help ═══════════════════════════╗ ║ -║docs ║ FileCommand Version 0.1.0 ║ 03:0 ║ -║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║ 03:0 ║ -║readme.t║ Inspired by the Norton Commander, 1986-1998 ║ 03:0 ║ +║docs ║ FileCommand Version 0.1.0 ║26 03:04║ +║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║26 03:04║ +║readme.t║ Inspired by the Norton Commander, 1986-1998 ║26 03:04║ ║ ╠────────────────────────────────────────────────────────────╣ ║ ║ ║ Abo┌─────────────── About FileCommand ────────────────┐ ║ ║ ║ ║ Key│ FileCommand │ ║ ║ @@ -16,7 +16,7 @@ expression: text ║ ║ Fil│ Inspired by the Norton Commander, 1986-1998 │ ║ ║ ║ ║ Men│ │ ║ ║ ║ ║ Vie│ License: MIT OR Apache-2.0 │ ║ ║ -║ ║ Edi│ https://github.com/example/filecommand │ ║ ║ +║ ║ Edi│ https://github.com/kkaminsk/FileCommand │ ║ ║ ║ ║ Com│ OK │ ║ ║ ║ ║ Mod└──────────────────────────────────────────────────┘ ║ ║ ║ ║ Configuration ║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║ Help Cancel ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__brief_mode_three_columns.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__brief_mode_three_columns.snap index 56b7ee1..8ad4f38 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__brief_mode_three_columns.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__brief_mode_three_columns.snap @@ -3,12 +3,12 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║▶UP--DIR◄ f5.txt ║║Name↓ │ Size Date Time ║ -║f0.txt f6.txt ║║.. │ ▶UP--DI ║ -║f1.txt f7.txt ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║f2.txt ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║f3.txt ║║readme.txt │ 12K 01-02-2 03:0 ║ +║▶UP--DIR◄ f5.txt ║║Name↓ │Size Date Time ║ +║f0.txt f6.txt ║║.. │▶UP--DIR◄ ║ +║f1.txt f7.txt ║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║f2.txt ║║Cargo.toml │ 612 01-02-26 03:04║ +║f3.txt ║║readme.txt │ 12K 01-02-26 03:04║ ║f4.txt ║║ ║ ╚════════════ .. UP--DIR ═════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_caret_mid_text.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_caret_mid_text.snap new file mode 100644 index 0000000..da6899a --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_caret_mid_text.snap @@ -0,0 +1,5 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: row +--- +C:\Users\demo\left>cd scrv diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_history_recall.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_history_recall.snap index 483787d..71d2a56 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_history_recall.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_history_recall.snap @@ -2,4 +2,4 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: command_line_row(&text) --- -C:\Users\demo\left>type readme.txt_ +C:\Users\demo\left>type readme.txt diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_with_prompt.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_with_prompt.snap index c4f3a54..3f5f5bd 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_with_prompt.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__command_line_with_prompt.snap @@ -2,4 +2,4 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: row --- -C:\Users\demo\left>dir *.txt_ +C:\Users\demo\left>dir *.txt diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_rename_field_open.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_rename_field_open.snap new file mode 100644 index 0000000..7987e29 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_rename_field_open.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ╔════════════════ File Exists ═════════════════╗ ║ +║ ║report.txt already exists ║ ║ +║ ║Source: 2K 01-02-26 03:04 ║ ║ +║ ║Target: 900 01-01-26 12:30 ║ ║ +║ ║ ║ ║ +║ ║New name: [report2.txt] ║ ║ +║ ║ ║ ║ +║ ║ ║ ║ +║ ╚══════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_with_cancel_row.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_with_cancel_row.snap new file mode 100644 index 0000000..0dea1d5 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__conflict_dialog_with_cancel_row.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ╔════════════════ File Exists ═════════════════╗ ║ +║ ║report.txt already exists ║ ║ +║ ║Source: 2K 01-02-26 03:04 ║ ║ +║ ║Target: 900 01-01-26 12:30 ║ ║ +║ ║ ║ ║ +║ ║(O)verwrite (S)kip (R)ename Over(w)rite Al…║ ║ +║ ║(C)ancel ║ ║ +║ ║ ║ ║ +║ ╚══════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_by_filter.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_by_filter.snap new file mode 100644 index 0000000..9aaccd4 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_by_filter.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ┌────────────────────────────────────────┐ ║ +║ │ Delete 5 files? This cannot be undone. │ ║ +║ │ 2 of 5 selected hidden by filter │ ║ +║ │ (Y)es (N)o │ ║ +║ └────────────────────────────────────────┘ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_single.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_single.snap new file mode 100644 index 0000000..c6fb3cb --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_hidden_single.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ┌───────────────────────────────────────┐ ║ +║ │ Delete f0.txt? This cannot be undone. │ ║ +║ │ Selected item is hidden by filter │ ║ +║ │ (Y)es (N)o │ ║ +║ └───────────────────────────────────────┘ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_no_hidden.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_no_hidden.snap new file mode 100644 index 0000000..1f5eeee --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__delete_confirm_no_hidden.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ┌────────────────────────────────────────┐ ║ +║ │ Delete 5 files? This cannot be undone. │ ║ +║ │ (Y)es (N)o │ ║ +║ └────────────────────────────────────────┘ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_field_scrolled_to_caret.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_field_scrolled_to_caret.snap new file mode 100644 index 0000000..657284f --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_field_scrolled_to_caret.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═══════════════════ Copy ═══════════════════╗ ║ +║ ║ ║ ║ +║ ║ Copy/move to: ║║ ║ ║ +║ ║ [ts\archive\final\q3\old\verylong\name\xy] ║ ║ +║ ║ ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_by_filter.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_by_filter.snap new file mode 100644 index 0000000..4ba2bd7 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_by_filter.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═══════════════════ Copy ═══════════════════╗ ║ +║ ║ ║ ║ +║ ║ Copy/move to: ║║ ║ ║ +║ ║ [C:\Users\demo\right.....................] ║ ║ +║ ║ 2 of 5 selected hidden by filter ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_single.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_single.snap new file mode 100644 index 0000000..e789181 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_hidden_single.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═══════════════════ Copy ═══════════════════╗ ║ +║ ║ ║ ║ +║ ║ Copy/move to: ║║ ║ ║ +║ ║ [C:\Users\demo\right.....................] ║ ║ +║ ║ Selected item is hidden by filter ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_no_hidden.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_no_hidden.snap new file mode 100644 index 0000000..bde1c82 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__destination_input_no_hidden.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═══════════════════ Copy ═══════════════════╗ ║ +║ ║ ║ ║ +║ ║ Copy/move to: ║║ ║ ║ +║ ║ [C:\Users\demo\right.....................] ║ ║ +║ ║ ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_invalid_target.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_invalid_target.snap index db2c321..0906f80 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_invalid_target.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_invalid_target.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚══════════ Can't drop here ═══════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> Drop=Copy Shift/RightBtn=Move Esc=Cancel diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_target_feedback.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_target_feedback.snap index ffd2e60..acf9312 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_target_feedback.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drag_in_progress_target_feedback.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════ Copy 1 file ► docs\ ═════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> Drop=Copy Shift/RightBtn=Move Esc=Cancel diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_pending.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_pending.snap index 5be5149..7ae8658 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_pending.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_pending.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_resolved.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_resolved.snap index 1392fe2..8846ab7 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_resolved.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drive_select_labels_resolved.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_copy_focused.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_copy_focused.snap index 110ac67..3cf95a0 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_copy_focused.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_copy_focused.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ╔═══════════════ Copy 1 file ════════════════╗ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_move_focused.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_move_focused.snap index b525234..470f13a 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_move_focused.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__drop_dialog_move_focused.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ╔═══════════════ Move 1 file ════════════════╗ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_save_failed.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_save_failed.snap new file mode 100644 index 0000000..85cd023 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_save_failed.snap @@ -0,0 +1,29 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +assertion_line: 922 +expression: text +--- + Edit: C:\notes.txt * Line 1/2 Col 2 5 +!abc + + + + + + + + + + + + + + + + + + + + +Save failed: Cannot save notes.txt: notes.txt is read-only — not saved +1Help 2Save 3Mark 4Replac 5 6 7Search 8 9 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_saving.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_saving.snap new file mode 100644 index 0000000..5af66d7 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__editor_saving.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- + Edit: C:\notes.txt * Line 1/2 Col 2 5 +!abc + + + + + + + + + + + + + + + + + + + + +Saving… +1Help 2Save 3Mark 4Replac 5 6 7Search 8 9 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__error_dialog_full_path_above_message.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__error_dialog_full_path_above_message.snap new file mode 100644 index 0000000..c34167c --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__error_dialog_full_path_above_message.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔════════════ Error ═════════════╗ ║ +║ ║D:\data\reports\q3.xlsx ║ ║ +║ ║Access is denied. (os error 5) ║ ║ +║ ║ ║ ║ +║ ║(R)etry (S)kip Skip (A)ll A(…║ ║ +║ ╚════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_directory.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_directory.snap new file mode 100644 index 0000000..58636d3 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_directory.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ╔══════ docs ═══════╗ ║ +║ ║ Open ║ ║ +║ ║ Send to clipboard ║ ║ +║ ║ Copy ║ ║ +║ ║ Rename ║ ║ +║ ║ Move ║ ║ +║ ║ Delete ║ ║ +║ ╚═══════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_executable.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_executable.snap index 1ea320f..d879268 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_executable.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_executable.snap @@ -3,14 +3,14 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ -║ ║║ ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ╔════ setup.exe ════╗ ║ ║ ║ Run ║ ║ +║ ║ Open ║ ║ ║ ║ View ║ ║ ║ ║ Edit ║ ║ ║ ║ Send to clipboard ║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_non_executable.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_non_executable.snap index 2c520a8..061ce14 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_non_executable.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__file_action_menu_dialog_non_executable.snap @@ -3,13 +3,14 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ╔═══ readme.txt ════╗ ║ +║ ║ Open ║ ║ ║ ║ View ║ ║ ║ ║ Edit ║ ║ ║ ║ Send to clipboard ║ ║ @@ -22,7 +23,6 @@ expression: text ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ -║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_no_matches.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_no_matches.snap index 45445c6..250b11a 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_no_matches.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_no_matches.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 912 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_with_results.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_with_results.snap index 02c63f5..87e739c 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_with_results.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__find_file_dialog_with_results.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 899 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_left.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_left.snap index b457471..b5bfca8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_left.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_left.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_right.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_right.snap index a8b5674..b8032c8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_right.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_active_right.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\right>_ +C:\Users\demo\right> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_inverted.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_inverted.snap index b457471..b5bfca8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_inverted.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_inverted.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_purple_lights.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_purple_lights.snap index b457471..b5bfca8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_purple_lights.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_purple_lights.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_terminal_green.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_terminal_green.snap index b457471..b5bfca8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_terminal_green.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_terminal_green.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_yellow_storm.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_yellow_storm.snap index b457471..b5bfca8 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_yellow_storm.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__full_panels_yellow_storm.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__fuzzy_jump_dialog.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__fuzzy_jump_dialog.snap index 12f055a..d1f7287 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__fuzzy_jump_dialog.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__fuzzy_jump_dialog.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 877 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__git_branch_suffix_and_marker_column.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__git_branch_suffix_and_marker_column.snap index f4ecf76..51cef1a 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__git_branch_suffix_and_marker_column.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__git_branch_suffix_and_marker_column.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════ C:\Users\demo\left (main) ══════╗╔════════ C:\Users\demo\right ═══3:04 PM -║ Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║ .. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║ docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║MCargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║?readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║ Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║ .. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║ docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║MCargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║?readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__header_sort_arrows.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__header_sort_arrows.snap index 8c200c4..f9c2d50 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__header_sort_arrows.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__header_sort_arrows.snap @@ -3,12 +3,12 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: rendered --- Name -║Name↓ │ Size Date Time ║ +║Name↓ │Size Date Time ║ Extension -║Name↓ │ Size Date Time ║ +║Name↓ │Size Date Time ║ Size -║Name │ Size↓ Date Time ║ +║Name │Size↓ Date Time ║ Time -║Name │ Size Date↓ Time ║ +║Name │Size Date↓ Time ║ Unsorted -║Name │ Size Date Time ║ +║Name │Size Date Time ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_list.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_list.snap index 465afb1..69ae811 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_list.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_list.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ ║.. ╔═══════════════════════════ Help ═══════════════════════════╗ ║ -║docs ║ FileCommand Version 0.1.0 ║ 03:0 ║ -║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║ 03:0 ║ -║readme.t║ Inspired by the Norton Commander, 1986-1998 ║ 03:0 ║ +║docs ║ FileCommand Version 0.1.0 ║26 03:04║ +║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║26 03:04║ +║readme.t║ Inspired by the Norton Commander, 1986-1998 ║26 03:04║ ║ ╠────────────────────────────────────────────────────────────╣ ║ ║ ║ About FileCommand ║ ║ ║ ║ Keyboard reference ║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║ Help Cancel ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_page.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_page.snap index 586f733..ecf3fab 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_page.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__help_window_topic_page.snap @@ -1,19 +1,18 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 1106 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ ║.. ╔═══════════════════════════ Help ═══════════════════════════╗ ║ -║docs ║ FileCommand Version 0.1.0 ║ 03:0 ║ -║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║ 03:0 ║ -║readme.t║ Inspired by the Norton Commander, 1986-1998 ║ 03:0 ║ +║docs ║ FileCommand Version 0.1.0 ║26 03:04║ +║Cargo.to║ Copyright (C) 2026 The FileCommand Authors ║26 03:04║ +║readme.t║ Inspired by the Norton Commander, 1986-1998 ║26 03:04║ ║ ╠────────────────────────────────────────────────────────────╣ ║ ║ ║F-key bar modifier variants: ║ ║ ║ ║ Ctrl+F3..F7 Sort by name/ext/time/size/unsorted ║ ║ ║ ║ Alt+F1/F2 Drive select (left/right panel) ║ ║ -║ ║ Alt+F7 Find file ║ ║ +║ ║ Alt+F7/F8 Find file / command history ║ ║ ║ ║ ║ ║ ║ ║Plain F-keys: F1 Help F2 User menu F3 View F4 Edit ║ ║ ║ ║F5 Copy F6 Move/rename F7 Mkdir F8 Delete ║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║ Help Cancel ║ ║ ║ ╚════════════════════════════════════════════════════════════╝ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__hidden_entries_full_and_brief.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__hidden_entries_full_and_brief.snap new file mode 100644 index 0000000..1928cb1 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__hidden_entries_full_and_brief.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║▶UP--DIR◄ ║ +║.. │▶UP--DIR◄ ║║.git ║ +║.git │▶SUB-DIR◄ 01-02-26 03:04║║docs ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║desktop.ini ║ +║desktop.ini │ 87 01-02-26 03:04║║secret.txt ║ +║secret.txt │ 35K 01-02-26 03:04║║readme.txt ║ +║readme.txt │ 612 01-02-26 03:04║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══ 1 files selected, 35,840 bytes ═══╝╚═══ 1 files selected, 35,840 bytes ═══╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_empty.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_empty.snap new file mode 100644 index 0000000..4db61be --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_empty.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═════ History ══════╗ ║ +║ ║(no history) ║ ║ +║ ╚════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_populated.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_populated.snap new file mode 100644 index 0000000..2bf9bbf --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__history_dialog_populated.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═════ History ══════╗ ║ +║ ║ cd D:\ ║ ║ +║ ║ del old.log ║ ║ +║ ║ cd C:\work ║ ║ +║ ╚════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_pending.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_pending.snap index f7a8c12..31d5717 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_pending.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_pending.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║┌────────────────────────────────────┐║║Name↓ │ Size Date Time ║ -║│ FileCommand │║║.. │ ▶UP--DI ║ -║│ Version 0.1.0 │║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║│Copyright (C) 2026 The FileCommand A│║║Cargo.toml │ 612 01-02-2 03:0 ║ -║│Inspired by the Norton Commander, 19│║║readme.txt │ 12K 01-02-2 03:0 ║ +║┌────────────────────────────────────┐║║Name↓ │Size Date Time ║ +║│ FileCommand │║║.. │▶UP--DIR◄ ║ +║│ Version 0.1.0 │║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║│Copyright (C) 2026 The FileCommand A│║║Cargo.toml │ 612 01-02-26 03:04║ +║│Inspired by the Norton Commander, 19│║║readme.txt │ 12K 01-02-26 03:04║ ║└────────────────────────────────────┘║║ ║ ║┌─ System ───────────────────────────┐║║ ║ ║│ Memory free …│║║ ║ @@ -24,5 +24,5 @@ expression: text ║└────────────────────────────────────┘║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_resolved.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_resolved.snap index 067b385..2e11bfd 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_resolved.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__info_panel_resolved.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║┌────────────────────────────────────┐║║Name↓ │ Size Date Time ║ -║│ FileCommand │║║.. │ ▶UP--DI ║ -║│ Version 0.1.0 │║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║│Copyright (C) 2026 The FileCommand A│║║Cargo.toml │ 612 01-02-2 03:0 ║ -║│Inspired by the Norton Commander, 19│║║readme.txt │ 12K 01-02-2 03:0 ║ +║┌────────────────────────────────────┐║║Name↓ │Size Date Time ║ +║│ FileCommand │║║.. │▶UP--DIR◄ ║ +║│ Version 0.1.0 │║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║│Copyright (C) 2026 The FileCommand A│║║Cargo.toml │ 612 01-02-26 03:04║ +║│Inspired by the Norton Commander, 19│║║readme.txt │ 12K 01-02-26 03:04║ ║└────────────────────────────────────┘║║ ║ ║┌─ System ───────────────────────────┐║║ ║ ║│ Memory free 8,589,934,592 bytes│║║ ║ @@ -24,5 +24,5 @@ expression: text ║└────────────────────────────────────┘║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_files_pulldown.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_files_pulldown.snap index 7e250a5..0aaa818 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_files_pulldown.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_files_pulldown.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- Left Files Commands Options Right -║Name↓ ┌───────────────────────────┐║║Name↓ │ Size Date Time ║ -║.. │ View F3 │║║.. │ ▶UP--DI ║ -║docs │ Edit F4 │║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.tom│ Copy F5 │║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.tx│ Rename/Move F6 │║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ ┌───────────────────────────┐║║Name↓ │Size Date Time ║ +║.. │ View F3 │║║.. │▶UP--DIR◄ ║ +║docs │ Edit F4 │║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.tom│ Copy F5 │║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.tx│ Rename/Move F6 │║║readme.txt │ 12K 01-02-26 03:04║ ║ │ Make directory F7 │║║ ║ ║ │ Delete F8 │║║ ║ ║ ├───────────────────────────┤║║ ║ @@ -15,14 +15,14 @@ expression: text ║ │ Copy path(s) Ctrl-Sh-Ins │║║ ║ ║ │ Copy name(s) │║║ ║ ║ ├───────────────────────────┤║║ ║ -║ │ Attributes │║║ ║ -║ ├───────────────────────────┤║║ ║ ║ │ Select group + │║║ ║ ║ │ Deselect group - │║║ ║ ║ │ Invert selection * │║║ ║ ║ ├───────────────────────────┤║║ ║ ║ │ Quit F10 │║║ ║ ║ └───────────────────────────┘║║ ║ +║ ║║ ║ +║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_left_pulldown.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_left_pulldown.snap index 929976a..433db72 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_left_pulldown.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__menu_bar_left_pulldown.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 262 expression: text --- Left Files Commands Options Right -║┌─────────────────────────┐ Time ║║Name↓ │ Size Date Time ║ -║│ Brief │ ║║.. │ ▶UP--DI ║ -║│ Full │2-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║│ Tree │2-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║│ Quick view │2-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║┌─────────────────────────┐e Time ║║Name↓ │Size Date Time ║ +║│ Brief │ ║║.. │▶UP--DIR◄ ║ +║│ Full │02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║│ Tree │02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║│ Quick view │02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║│ Info Ctrl-L │ ║║ ║ ║├─────────────────────────┤ ║║ ║ ║│ Name Ctrl-F3 │ ║║ ║ @@ -17,7 +16,7 @@ expression: text ║│ Size Ctrl-F6 │ ║║ ║ ║│ Unsorted Ctrl-F7 │ ║║ ║ ║├─────────────────────────┤ ║║ ║ -║│ Filter │ ║║ ║ +║│ Filter Ctrl-P │ ║║ ║ ║│ Re-read Ctrl-R │ ║║ ║ ║│ Drive select Alt-F1/F2 │ ║║ ║ ║├─────────────────────────┤ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║│ Close tab Ctrl-W │ ║║ ║ ║└─────────────────────────┘ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__panels_after_swap.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__panels_after_swap.snap new file mode 100644 index 0000000..6e7b78c --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__panels_after_swap.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════ C:\Users\demo\right (main) ═════╗╔═════════ C:\Users\demo\left ═══3:04 PM +║ Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║ .. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║ docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║ Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║Mreadme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚════════════ .. UP--DIR ═════════════╝╚═══════════ docs SUB-DIR ════════════╝ +C:\Users\demo\right> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_checking_mini_status.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_checking_mini_status.snap new file mode 100644 index 0000000..0c59c00 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_checking_mini_status.snap @@ -0,0 +1,5 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: row +--- +╚═══════ Checking \\nas\share… ════════╝╚════════════ .. UP--DIR ═════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_opening_mini_status.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_opening_mini_status.snap new file mode 100644 index 0000000..96ce63c --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__pending_note_opening_mini_status.snap @@ -0,0 +1,5 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: row +--- +╚═════════ Opening notes.txt… ═════════╝╚════════════ .. UP--DIR ═════════════╝ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__placeholder_over_editor.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__placeholder_over_editor.snap new file mode 100644 index 0000000..04650a6 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__placeholder_over_editor.snap @@ -0,0 +1,10 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: over_editor +--- + + + + + +Terminal too small — resize to at least diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quick_view_sub_dir_indicator.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quick_view_sub_dir_indicator.snap index bcb00ad..2e14a5e 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quick_view_sub_dir_indicator.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quick_view_sub_dir_indicator.snap @@ -3,8 +3,8 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════════ Quick view ═════════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║ ║║Name↓ │ Size Date Time ║ -║ ║║docs │ ▶SUB-DI ║ +║ ║║Name↓ │Size Date Time ║ +║ ║║docs │▶SUB-DIR◄ ║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚═══════════ docs SUB-DIR ════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog.snap index 78b08a9..fbb7759 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_inverted.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_inverted.snap index 78b08a9..fbb7759 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_inverted.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_inverted.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_placeholder.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_placeholder.snap new file mode 100644 index 0000000..7fabe02 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_placeholder.snap @@ -0,0 +1,11 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- + + + + + ┌─────────────────────────┐ + │ Quit FileCommand? (Y/N) │ + Terminal └─────────────────────────┘ast 60x16 diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_pulldown_menu.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_pulldown_menu.snap index 751339b..71d79b3 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_pulldown_menu.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_over_pulldown_menu.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- Left Files Commands Options Right -║Name↓ ┌───────────────────────────┐║║Name↓ │ Size Date Time ║ -║.. │ View F3 │║║.. │ ▶UP--DI ║ -║docs │ Edit F4 │║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.tom│ Copy F5 │║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.tx│ Rename/Move F6 │║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ ┌───────────────────────────┐║║Name↓ │Size Date Time ║ +║.. │ View F3 │║║.. │▶UP--DIR◄ ║ +║docs │ Edit F4 │║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.tom│ Copy F5 │║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.tx│ Rename/Move F6 │║║readme.txt │ 12K 01-02-26 03:04║ ║ │ Make directory F7 │║║ ║ ║ │ Delete F8 │║║ ║ ║ ├───────────────────────────┤║║ ║ @@ -15,14 +15,14 @@ expression: text ║ │ Copy path(s) ┌─────────────────────────┐ ║ ║ │ Copy name(s) │ Quit FileCommand? (Y/N) │ ║ ║ ├───────────────└─────────────────────────┘ ║ -║ │ Attributes │║║ ║ -║ ├───────────────────────────┤║║ ║ ║ │ Select group + │║║ ║ ║ │ Deselect group - │║║ ║ ║ │ Invert selection * │║║ ║ ║ ├───────────────────────────┤║║ ║ ║ │ Quit F10 │║║ ║ ║ └───────────────────────────┘║║ ║ +║ ║║ ║ +║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_purple_lights.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_purple_lights.snap index 78b08a9..fbb7759 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_purple_lights.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_purple_lights.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_terminal_green.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_terminal_green.snap index 78b08a9..fbb7759 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_terminal_green.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_terminal_green.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_yellow_storm.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_yellow_storm.snap index 78b08a9..fbb7759 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_yellow_storm.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__quit_confirm_dialog_yellow_storm.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_and_warnings.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_and_warnings.snap new file mode 100644 index 0000000..e8af9e1 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_and_warnings.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ╔════════════ Skipped 2 item(s), 1 warning(s) ════════════╗ ║ +║ ║D:\data\a.txt: target already exists ║ ║ +║ ║D:\data\b.txt: target already exists ║ ║ +║ ║Warnings ║ ║ +║ ║D:\data\c.txt: read-only attribute not restored: denied ║ ║ +║ ║Press any key to continue ║ ║ +║ ╚═════════════════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_items.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_items.snap new file mode 100644 index 0000000..0190d7a --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_skipped_items.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔════════════ Skipped 2 item(s) ═════════════╗ ║ +║ ║D:\data\old.log: target already exists ║ ║ +║ ║D:\data\archive.zip: target already exists ║ ║ +║ ║Press any key to continue ║ ║ +║ ╚════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_warnings_only.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_warnings_only.snap new file mode 100644 index 0000000..d9a0c40 --- /dev/null +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__skipped_summary_warnings_only.snap @@ -0,0 +1,28 @@ +--- +source: crates/filecommand-tui/tests/snapshot_views.rs +expression: text +--- +╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ╔═══════════════════════ 1 warning(s) ═══════════════════════╗ ║ +║ ║Warnings ║ ║ +║ ║C:\dst\report.docx: modification time not restored: denied ║ ║ +║ ║Press any key to continue ║ ║ +║ ╚════════════════════════════════════════════════════════════╝ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +║ ║║ ║ +╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ +C:\Users\demo\left> +1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__startup_warning_dialog.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__startup_warning_dialog.snap index 39b1f4b..6bf56b5 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__startup_warning_dialog.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__startup_warning_dialog.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 1028 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__streaming_ministatus_reading_n.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__streaming_ministatus_reading_n.snap index ba5ed94..033f6d2 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__streaming_ministatus_reading_n.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__streaming_ministatus_reading_n.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║ ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║ ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║ ║║Cargo.toml │ 612 01-02-26 03:04║ +║ ║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═════════════ Reading… 2 ═════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__tab_strip_full_labels.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__tab_strip_full_labels.snap index 0f1e777..6c5b84a 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__tab_strip_full_labels.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__tab_strip_full_labels.snap @@ -2,4 +2,4 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: strip_row --- - 1:ALPHA 2:BETA ║Name↓ │ Size Date Time ║ + 1:ALPHA 2:BETA ║Name↓ │Size Date Time ║ diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_active_marked.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_active_marked.snap index 1b312af..14ec835 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_active_marked.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_active_marked.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 993 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ╔════ Themes ═════╗ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_previews_purple_lights.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_previews_purple_lights.snap index 0923fb1..dad908d 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_previews_purple_lights.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__theme_picker_dialog_previews_purple_lights.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 1104 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ╔════ Themes ═════╗ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__tree_mode_branches.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__tree_mode_branches.snap index 811f6fd..02ec718 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__tree_mode_branches.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__tree_mode_branches.snap @@ -3,11 +3,11 @@ source: crates/filecommand-tui/tests/snapshot_views.rs expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Tree ║║Name↓ │ Size Date Time ║ -║C:\ ║║.. │ ▶UP--DI ║ -║├─ALPHA ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║└─BETA ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║ ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Tree ║║Name↓ │Size Date Time ║ +║C:\ ║║.. │▶UP--DIR◄ ║ +║├─ALPHA ║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║└─BETA ║║Cargo.toml │ 612 01-02-26 03:04║ +║ ║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚══════════════ C:\Beta ═══════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_empty.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_empty.snap index b410042..45f930d 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_empty.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_empty.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 979 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_with_entries.snap b/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_with_entries.snap index f09c230..e44703b 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_with_entries.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshot_views__user_menu_dialog_with_entries.snap @@ -1,14 +1,13 @@ --- source: crates/filecommand-tui/tests/snapshot_views.rs -assertion_line: 970 expression: text --- ╔═════════ C:\Users\demo\left ═════════╗╔════════ C:\Users\demo\right ═══3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║.. │ ▶UP--DI ║ -║docs │ ▶SUB-DI 01-02-2 03:0 ║║docs │ ▶SUB-DI 01-02-2 03:0 ║ -║Cargo.toml │ 612 01-02-2 03:0 ║║Cargo.toml │ 612 01-02-2 03:0 ║ -║readme.txt │ 12K 01-02-2 03:0 ║║readme.txt │ 12K 01-02-2 03:0 ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║.. │▶UP--DIR◄ ║ +║docs │▶SUB-DIR◄ 01-02-26 03:04║║docs │▶SUB-DIR◄ 01-02-26 03:04║ +║Cargo.toml │ 612 01-02-26 03:04║║Cargo.toml │ 612 01-02-26 03:04║ +║readme.txt │ 12K 01-02-26 03:04║║readme.txt │ 12K 01-02-26 03:04║ ║ ║║ ║ ║ ║║ ║ ║ ╔════ User menu ═════╗ ║ @@ -25,5 +24,5 @@ expression: text ║ ║║ ║ ║ ║║ ║ ╚═══════════ docs SUB-DIR ════════════╝╚════════════ .. UP--DIR ═════════════╝ -C:\Users\demo\left>_ +C:\Users\demo\left> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/crates/filecommand-tui/tests/snapshots/snapshots__full_panel_active_inactive_nc_classic.snap b/crates/filecommand-tui/tests/snapshots/snapshots__full_panel_active_inactive_nc_classic.snap index a22d9d3..37edd6e 100644 --- a/crates/filecommand-tui/tests/snapshots/snapshots__full_panel_active_inactive_nc_classic.snap +++ b/crates/filecommand-tui/tests/snapshots/snapshots__full_panel_active_inactive_nc_classic.snap @@ -3,10 +3,10 @@ source: crates/filecommand-tui/tests/snapshots.rs expression: "render(&state, (80, 24))" --- ╔═══════════ C:\Users\demo ════════════╗╔══════ C:\Users\demo\Documents ═3:04 PM -║Name↓ │ Size Date Time ║║Name↓ │ Size Date Time ║ -║.. │ ▶UP--DI ║║ ║ -║Documents │ ▶SUB-DI 08-07-2 14:3 ║║ ║ -║notes.txt │ 2K 08-07-2 09:0 ║║ ║ +║Name↓ │Size Date Time ║║Name↓ │Size Date Time ║ +║.. │▶UP--DIR◄ ║║ ║ +║Documents │▶SUB-DIR◄ 08-07-26 14:36║║ ║ +║notes.txt │ 2K 08-07-26 09:05║║ ║ ║ ║║ ║ ║ ║║ ║ ║ ║║ ║ @@ -24,5 +24,5 @@ expression: "render(&state, (80, 24))" ║ ║║ ║ ║ ║║ ║ ╚═════════ Documents SUB-DIR ═════════╝╚══════════ Reading… 12,345 ═══════════╝ -C:\Users\demo>_ +C:\Users\demo> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit diff --git a/docs/spikes/c-100k-entry-render-benchmark.md b/docs/spikes/c-100k-entry-render-benchmark.md index 8738fc2..91483e9 100644 --- a/docs/spikes/c-100k-entry-render-benchmark.md +++ b/docs/spikes/c-100k-entry-render-benchmark.md @@ -1,65 +1,168 @@ -# Spike (c): 100k-entry directory render benchmark +# Spike (c): 100k-entry directory benchmark **Question:** does FileCommand stay responsive against a directory with -100,000 entries — both rendering a panel and streaming the listing in? This -is the scenario that would expose an accidentally-O(n) render loop or an -accidentally-O(n²) streaming-insert loop before a real user hits it. +100,000 entries — streaming the listing in, re-sorting it, and rendering a +panel over it, with and without a quick filter? This is the scenario that +exposes an accidentally-O(n) per-frame loop or an O(n²) streaming path before +a real user hits it. + +**History.** The first version of this spike (M1) measured a pre-sorted +100k insert at ~248 ms against a ~14.5 ms bulk sort and a 0.209 ms frame, +and concluded that rendering was "independent of directory size" and that the +per-entry insert was a forward-looking risk only. The audit of 2026-10-12 +re-ran it (257.8 ms vs 13.1 ms, 1.237 ms/frame) and found the drift +(P-01..P-04): the reader collected the whole directory before anything +reached the panel, every entry was inserted one at a time, every +comparison lowercased two names, and the visible-index list was rebuilt +several times per frame. Two things about the original measurement were +also wrong, and this rewrite corrects them: + +- The insert benchmark fed `insert_sorted` names that were **already in + sorted order**, so every insert landed at the end and nothing shifted. Its + 248 ms was almost entirely comparator allocation, not the quadratic shift it + was meant to measure. With shuffled arrival — the order Unix `readdir`, + ReFS and network redirectors actually produce — the same code takes + **over four seconds** (below). +- "Render cost is independent of directory size" was true only while no + filter was active and `visible_indices()` was cheap; the per-frame + allocation it did was O(n), which the audit's 1.237 ms/frame exposed. + +`streaming-listing-performance` replaced those paths; this document records +what the replacement measures. ## Method -`crates/filecommand-tui/tests/bench_100k_ignored.rs` exercises the actual -production code paths (not a synthetic microbenchmark) against 100,000 -synthetic `Entry` values: +`crates/filecommand-tui/tests/bench_100k_ignored.rs` exercises the production +code paths — not synthetic microbenchmarks — against 100,000 synthetic +`Entry` values (`file-000000.txt` …, distinct sizes), built through +`Entry::new` so each carries its precomputed lowercase key. "Shuffled arrival" +is a fixed xorshift Fisher–Yates permutation, identical on every run and +machine. Run with: + +```sh +cargo test --release -p filecommand-tui --test bench_100k_ignored -- --ignored --nocapture +``` + +`bench_streaming_listing_performance_100k` measures, in one process: + +1. **Bulk sort** — `sort_by(cmp_entries)` over the 100k entries, once on + already-sorted input (the original spike's baseline, kept for comparison) + and once on the shuffled input. The spec's "one bulk sort of the same + 100,000 entries" is the shuffled one: the same entries in the same order + the stream delivers them. +2. **Per-batch sort share** — sorting each policy batch on its own. +3. **Old per-entry insert** — `insert_sorted` once per entry, with sorted + and with shuffled arrival (reference only; production no longer does this). +4. **Streamed merge** — `PanelState::merge_streamed` fed the batches the + reader's `BatchPolicy` produces on a fast enumeration (`clamp(sent / 4, + 256, 16_384)` entries each: 25 batches for 100k), sorted and shuffled + arrival, with the worst single merge recorded. +5. **End to end through the reducer** — the same shuffled batches applied as + `Command::ListingChunk`s plus `ListingComplete` through `update()`, which + also runs the re-read anchor and viewport reconciliation per batch. +6. **Re-sort** — `set_sort_mode` for every mode at 100k entries. +7. **Render** — 200 frames of `views::render` at 80×24 through a real + `ratatui::backend::TestBackend`, cursor mid-list, against a 1,000-entry + and a 100,000-entry panel, each with no filter and with the quick filter + `9` active. + +It prints the ratios the panel-navigation requirement "Large directories +stream progressively and stay responsive" states and asserts them: streamed +merge ≤ 3× the bulk sort, 100k frame ≤ 2× the 1k frame. Because both sides of +each ratio are measured in the same run, the check holds on any machine. -1. `bench_bulk_sort_100k` — sort 100k entries once with - `filecommand_core::panel::cmp_entries` (what a "sort on completion" - strategy would cost). -2. `bench_streamed_sorted_insert_100k` — insert 100k entries one at a time via - `filecommand_core::panel::insert_sorted`, exactly as - `update::apply_listing_event` does today for every entry in every worker - chunk (what the *current* streaming strategy costs). -3. `bench_render_frame_with_100k_entries` — draw 200 frames of - `views::render` against a fully-populated 100k-entry panel through a real - `ratatui::backend::TestBackend`, at the cursor parked mid-list. +`bench_1m_streamed_merge` is a separate, heavier one-off (a million entries, +several hundred MB) that settles the design's open question about the +million-entry range. -Run via `cargo test --release -p filecommand-tui --test bench_100k_ignored -- ---ignored --nocapture` (release mode — debug-build timings are not -representative and were not used for the numbers below). +## Results -## Results (this machine, release build) +Release build, Windows 11 Enterprise, AMD Ryzen 7 5800H, 31 GB RAM, three +runs of the 100k benchmark (run-to-run noise is roughly ±15 ms on the +larger figures; ranges below span the runs). -| Benchmark | Result | +| Benchmark (N = 100,000) | Result | |---|---| -| Bulk `sort_by` over 100k entries | **14.5 ms** | -| Streamed `insert_sorted` × 100k (current production path) | **248 ms** total (~17× slower than one bulk sort) | -| Render one frame against a 100k-entry panel (80×24, cursor mid-list) | **0.209 ms/frame** average over 200 frames | +| Bulk `sort_by`, already-sorted input (original baseline) | 2.3–3.3 ms | +| Bulk `sort_by`, shuffled input (the spec's baseline) | **50–53 ms** | +| Sorting each policy batch on its own, shuffled | 25.6 ms | +| Old per-entry `insert_sorted`, sorted arrival (what the original spike timed) | 16 ms | +| Old per-entry `insert_sorted`, shuffled arrival | **4.1–4.8 s** | +| `merge_streamed`, 25 policy batches, sorted arrival | 32–46 ms (worst single merge 7–11 ms) | +| `merge_streamed`, 25 policy batches, shuffled arrival | **70–76 ms** (worst single merge 11–14 ms) | +| `update()`: 25 `ListingChunk`s + `ListingComplete`, shuffled | 71–92 ms (worst single chunk 11–13 ms) | +| `set_sort_mode(Extension)` | 5.7–6.8 ms | +| `set_sort_mode(Time)` | 3.4–3.9 ms | +| `set_sort_mode(Size)` | 0.8–1.5 ms | +| `set_sort_mode(Unsorted)` | 0.6–1.2 ms | +| `set_sort_mode(Name)` | 2.8–3.1 ms | +| Render one frame, 1k entries, no filter | 0.32–0.42 ms | +| Render one frame, 100k entries, no filter | **0.35–0.44 ms** | +| Render one frame, 1k entries, quick filter `9` | 0.38–0.43 ms | +| Render one frame, 100k entries, quick filter `9` | **0.30–0.46 ms** | + +| Ratio | Measured | Required | +|---|---|---| +| Streamed merge (shuffled) / bulk sort (shuffled) | **1.3–2.1×** | ≤ 3× | +| 100k frame / 1k frame, no filter | **1.04–1.09×** | ≤ 2× | +| 100k frame / 1k frame, quick filter active | **0.7–1.2×** | ≤ 2× | +| Old per-entry insert (shuffled) / streamed merge | 41–66× | — | + +### One-off 1M-entry measurement + +| Benchmark (N = 1,000,000, shuffled arrival) | Result | +|---|---| +| Bulk `sort_by`, shuffled input | 626 ms | +| `merge_streamed`, 80 policy batches | 2,115 ms total; worst single merge **55 ms** | +| Render one frame, 1M entries, no filter | 0.32 ms | +| Streamed merge / bulk sort | 3.4× | + +(Before the merge kept its output buffer between batches, the same run took +2,743 ms with an 85 ms worst merge; the retained buffer saves the per-batch +allocation and page-faulting of a fresh multi-megabyte vector.) ## Findings -- **Rendering is not the risk.** `views::panel::render_panel` only iterates - the visible viewport rows (`area.height`-bounded, ~20 rows at 80×24), never - the full entry list, so render cost is independent of directory size. A - 100k-entry panel renders in the same sub-millisecond ballpark as an - empty one. No change needed here for M1 or later. -- **Streaming insert is the risk.** `PanelState::insert_streamed` → - `insert_sorted` does a binary-search + `Vec::insert`, which shifts every - element after the insertion point. Called once per entry (not once per - worker chunk) for every entry in every `ListingChunk`, this is O(n²) in the - worst case (reverse-sorted or shuffled arrival order). At 100k entries it's - already the dominant cost in the streaming pipeline — 17× the cost of just - sorting the same data once — even though it's still comfortably - sub-second and doesn't threaten M1's target directory sizes. It will not - scale gracefully to directories an order of magnitude larger (e.g. a - node_modules-style tree with 500k–1M entries), where the same approach - extrapolates toward multi-second insert stalls. - -## Conclusion for M1 - -No action required — M1's realistic directories (thousands, not hundreds of -thousands, of entries) are well within budget, and the render path has no -scaling risk at all. This is filed as a known, *measured* forward-looking -risk for whichever milestone first targets very large directories: prefer -batch-appending each worker chunk and re-sorting (or merging two sorted runs) -over calling `insert_sorted` per entry, since the benchmark shows a single -bulk sort of the same 100k entries is ~17× cheaper than the current -per-entry insertion strategy. +- **Streaming now costs about one sort.** Merging 25 geometrically growing + batches into the panel costs 1.3–2.1× a single bulk sort of the same + shuffled entries, against the requirement's 3×. Roughly a third of that is + the per-batch sorts themselves (25.6 ms); the rest is the 25 linear merges + (Σ batch-so-far ≈ 5n entry moves with the policy's growth factor) and the + reducer's per-batch anchor and viewport work. The old per-entry insert with + realistic (shuffled) arrival is 41–66× slower — four seconds of reducer + time during which no key was processed. The worst single merge at 100k is + 11–14 ms, inside one frame, and the event loop's drain budget (~8 ms or 64 + messages) interleaves input between merges in any case. +- **Frame cost no longer depends on directory size — with or without a + filter.** A 100k-entry frame costs within 10% of a 1k-entry frame, filtered + or not, because rendering, hit-testing and cursor movement now walk only + the window of rows shown through the cached visible-index list and the + comparators and filter read precomputed keys. The original spike's claim + was accidentally right for the unfiltered case it measured and wrong in + general; it is now true by construction and checked by the benchmark. +- **Re-sorting is cheap.** Every Ctrl+F3..F7 re-sort of 100k entries takes + under 7 ms (Extension, which splits each key, is the slowest), down from + the two allocations per comparison the old comparators made. +- **The million-entry range (design open question).** At 1M entries the + 16,384-entry batch cap holds the batch count to 80 and the worst single + merge to ~55 ms, with ~2.1 s of total reducer time spread across the + listing. The panel stays usable — input and redraws interleave between + merges under the drain budget — but each late merge is a visible hitch of a + few frames. The listing itself is still delivered progressively and renders + in 0.32 ms/frame at 1M. **Conclusion:** no structural change is made for + this range now; it is a known, measured cost. If million-entry directories + become a target, the follow-up is either a larger late-batch cap (fewer, + bigger merges — fewer total moves) or an unsorted tail merged on a timer, + decided against the requirement that entries appear in sorted position as + they arrive. + +## Conclusion + +The three audit findings this spike covers are closed by measurement: +streaming a 100k-entry directory costs about one sort and never inserts +entries one at a time (P-02), the first entries reach the panel while the +reader is still enumerating (P-01, see the `list_dir_streaming` tests), the +per-frame visible-list allocation is gone (P-03), and the comparators no +longer allocate (P-04). The `bench_100k_ignored` test remains the manual +acceptance measurement for the two ratio scenarios; a CI performance gate is +left to `ci-and-build-tooling`. diff --git a/installer/Bundle.wxs b/installer/Bundle.wxs index 7870ef4..66baff2 100644 --- a/installer/Bundle.wxs +++ b/installer/Bundle.wxs @@ -14,8 +14,9 @@ PerUserMsiPath - path to the built PackagePerUser.msi PerMachineMsiPath - path to the built PackagePerMachine.msi - Requires the WixToolset.BootstrapperApplications.wixext extension - (installer/build.ps1 passes -ext for this). + Requires the WixToolset.BootstrapperApplications.wixext and + WixToolset.Util.wixext extensions (installer/build.ps1 passes -ext for + both; Util provides the util:ProductSearch searches below). Scope control for silent/automated installs (design D4, winget mapping): FileCommandSetup.exe -> per-user, no elevation @@ -29,7 +30,8 @@ provided by the engine; no custom wiring is needed for those. --> + xmlns:bal="http://wixtoolset.org/schemas/v4/wxs/bal" + xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util"> + + + + + + + + diff --git a/installer/README.md b/installer/README.md index 85dc76c..9ef8876 100644 --- a/installer/README.md +++ b/installer/README.md @@ -19,6 +19,10 @@ winget manifest template and Chocolatey package used to distribute it. - `License.rtf` — license text shown by the bootstrapper UI (FileCommand is dual-licensed MIT / Apache-2.0; see `LICENSE-MIT` / `LICENSE-APACHE` at the repo root). +- `theme/` — the bundle's custom WixStdBA theme: a copy of the built-in + `rtfLicense` theme (`RtfTheme.xml` / `RtfTheme.wxl`) extended with a + scope-coexistence warning on the install page (see + [Scope semantics](#scope-semantics) below). - `build.ps1` — builds the release binary and both WiX artifacts. - `winget/` — the winget package manifest template. @@ -36,12 +40,23 @@ Burn scope switch, and versioning). ```powershell dotnet tool install --global wix wix extension add WixToolset.BootstrapperApplications.wixext/5.0.2 + wix extension add WixToolset.Util.wixext/5.0.2 ``` - Pin the extension to `5.0.2` explicitly — `wix extension add` without a + Pin the extensions to `5.0.2` explicitly — `wix extension add` without a version resolves the latest release (currently 7.0.0), which is not compatible with the WiX v5 CLI and fails the bundle build with - `WIX0144: The extension ... could not be found`. + `WIX0144: The extension ... could not be found`. The bundle build uses + both extensions: BootstrapperApplications for the WixStdBA UI, and Util + for the `util:ProductSearch` searches that detect the other install + scope (see [Scope semantics](#scope-semantics)). + + The custom theme under `theme/` is copied from the built-in `rtfLicense` + theme embedded in `WixToolset.BootstrapperApplications.wixext` **5.0.2**. + If either extension pin is bumped, re-derive `theme/RtfTheme.xml` and + `theme/RtfTheme.wxl` from the new extension version and re-apply the + warning-label addition (each file's header comment describes the + change), then re-test the bundle UI. `build.ps1` checks for `dotnet`, `wix`, and `cargo` on `PATH` up front and fails fast with an install hint if any are missing. @@ -84,6 +99,14 @@ Per-user is the default for both the interactive bootstrapper UI and silent installs — the first-run experience never prompts for credentials unless per-machine is explicitly requested. +The installer itself ships no configuration files: at runtime FileCommand +keeps its per-user settings (`config.toml`, `history.json`, +`usermenu.toml`) under `%APPDATA%\FileCommand` by default, regardless of +install scope. See +[Uninstalling and removing settings](#uninstalling-and-removing-settings) +for the full profile-directory resolution order and how to remove those +files. + ### Selecting scope - **Interactive**: `FileCommandSetup.exe` — installs per-user by default; @@ -110,6 +133,25 @@ MSIs with different UpgradeCodes, so installing at the other scope will **coexist** with (not replace) the first install. To change scope: uninstall the existing install, then install fresh at the new scope. +When both scopes are installed, both install directories are on `PATH` +— and because Windows builds a process's `PATH` as the **machine** `PATH` +followed by the **user** `PATH`, typing `filecommand` always starts the +per-machine copy (`%ProgramFiles%\BigHatGroup\FileCommand`), whichever +scope was installed last. A per-user install or upgrade made after a +per-machine one is therefore shadowed: the user upgrades, and the old +per-machine version keeps starting. + +The bundle detects this at install time: before planning, it checks by +`UpgradeCode` whether the *other* scope's MSI is already installed, and +on an interactive run the install page warns which copy will run. The +warning never blocks — Install proceeds as usual. Quiet and passive runs +(`/quiet`, `/passive`, winget, Chocolatey) are never prompted; the +detection result is simply recorded in the Burn log (run with +`/log ` to see it). Mixing package managers is the most common way +to end up with both scopes: Chocolatey installs per-machine by default +(see `chocolatey/`), while winget's default maps to a per-user install +(see `winget/`). + ### PATH refresh in open shells Windows Installer updates the registry PATH value and broadcasts @@ -120,6 +162,66 @@ applications (Explorer, new shell instances) pick up automatically. installer. Open a new terminal window to get `filecommand` on `PATH` after installing. +## Uninstalling and removing settings + +Uninstalling FileCommand — from **Apps & features**, or with +`FileCommandSetup.exe /uninstall` (add `/quiet` for a silent uninstall) — +removes the program files, the Start Menu shortcut, and the `PATH` entry +the install added, but **keeps your settings**, so a later reinstall +picks them up. The installer never deletes user data; to remove +FileCommand completely, delete the settings directory by hand as +described below. + +### Where settings live + +FileCommand resolves its profile directory once at startup, in this +order: + +1. `--config-dir ` — whatever directory was passed on the command + line; +2. a file named `portable` beside `filecommand.exe` — portable mode: + the settings live in the executable's own directory (the install + directory); +3. otherwise `%APPDATA%\FileCommand` (the default on Windows). + +The default profile directory holds `config.toml`, `history.json`, and +`usermenu.toml`. Because `%APPDATA%` is per-account, a **per-machine** +install leaves one copy under `%APPDATA%\FileCommand` for **each Windows +account that ran FileCommand** — uninstalling from one account cannot +(and does not) touch the others. + +### Removing the default settings + +Run as each affected user: + +```powershell +# PowerShell +Remove-Item -Recurse -Force "$env:APPDATA\FileCommand" +``` + +```cmd +:: cmd.exe +rmdir /s /q "%APPDATA%\FileCommand" +``` + +### Portable mode + +If a `portable` file sits beside `filecommand.exe`, the settings files +live in the install directory instead of `%APPDATA%`. Uninstall removes +only what the installer put there, so it leaves the settings files, the +`portable` marker, and therefore the folder itself. Delete the folder by +hand: + +- per-user: `%LOCALAPPDATA%\Programs\BigHatGroup\FileCommand` +- per-machine: `%ProgramFiles%\BigHatGroup\FileCommand` (requires + elevation) + +### `--config-dir` + +If FileCommand was launched with `--config-dir `, the settings live +wherever that pointed. The installer never touches that location; delete +it by hand if you want it gone. + ## Production signing Plain `build.ps1` produces **unsigned** development builds. Pass `-Sign` diff --git a/installer/build.ps1 b/installer/build.ps1 index 07d5b05..bb2c432 100644 --- a/installer/build.ps1 +++ b/installer/build.ps1 @@ -233,7 +233,9 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null # `wix extension add` (see README.md prerequisites) restores extensions into # a `.wix\extensions` cache relative to the current directory, so `wix # build` must run from $InstallerDir regardless of where this script was -# invoked from, or it won't find WixToolset.BootstrapperApplications.wixext. +# invoked from, or it won't find WixToolset.BootstrapperApplications.wixext +# and WixToolset.Util.wixext (the bundle build uses both; both are pinned to +# 5.0.2 in the README prerequisites). Push-Location $InstallerDir try { @@ -270,6 +272,7 @@ try { -d "PerUserMsiPath=$PerUserMsi" ` -d "PerMachineMsiPath=$PerMachineMsi" ` -ext WixToolset.BootstrapperApplications.wixext ` + -ext WixToolset.Util.wixext ` -arch x64 ` -o $BundleExe if ($LASTEXITCODE -ne 0) { diff --git a/installer/theme/RtfTheme.wxl b/installer/theme/RtfTheme.wxl new file mode 100644 index 0000000..bb5e338 --- /dev/null +++ b/installer/theme/RtfTheme.wxl @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/theme/RtfTheme.xml b/installer/theme/RtfTheme.xml new file mode 100644 index 0000000..9120ce7 --- /dev/null +++ b/installer/theme/RtfTheme.xml @@ -0,0 +1,143 @@ + + + + + + + Segoe UI + Segoe UI + Segoe UI + Segoe UI + + + + + + + + + + + + + + + + + #(loc.InstallAcceptCheckbox) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #(loc.FailureHyperlinkLogText) + + + + + + + diff --git a/openspec/changes/reread-preserves-cursor/.openspec.yaml b/openspec/changes/archive/2026-10-07-ui-animations/.openspec.yaml similarity index 100% rename from openspec/changes/reread-preserves-cursor/.openspec.yaml rename to openspec/changes/archive/2026-10-07-ui-animations/.openspec.yaml diff --git a/openspec/changes/ui-animations/design.md b/openspec/changes/archive/2026-10-07-ui-animations/design.md similarity index 100% rename from openspec/changes/ui-animations/design.md rename to openspec/changes/archive/2026-10-07-ui-animations/design.md diff --git a/openspec/changes/ui-animations/proposal.md b/openspec/changes/archive/2026-10-07-ui-animations/proposal.md similarity index 100% rename from openspec/changes/ui-animations/proposal.md rename to openspec/changes/archive/2026-10-07-ui-animations/proposal.md diff --git a/openspec/changes/ui-animations/specs/pulldown-menus/spec.md b/openspec/changes/archive/2026-10-07-ui-animations/specs/pulldown-menus/spec.md similarity index 100% rename from openspec/changes/ui-animations/specs/pulldown-menus/spec.md rename to openspec/changes/archive/2026-10-07-ui-animations/specs/pulldown-menus/spec.md diff --git a/openspec/changes/ui-animations/specs/startup-splash/spec.md b/openspec/changes/archive/2026-10-07-ui-animations/specs/startup-splash/spec.md similarity index 100% rename from openspec/changes/ui-animations/specs/startup-splash/spec.md rename to openspec/changes/archive/2026-10-07-ui-animations/specs/startup-splash/spec.md diff --git a/openspec/changes/ui-animations/specs/ui-animations/spec.md b/openspec/changes/archive/2026-10-07-ui-animations/specs/ui-animations/spec.md similarity index 100% rename from openspec/changes/ui-animations/specs/ui-animations/spec.md rename to openspec/changes/archive/2026-10-07-ui-animations/specs/ui-animations/spec.md diff --git a/openspec/changes/ui-animations/tasks.md b/openspec/changes/archive/2026-10-07-ui-animations/tasks.md similarity index 71% rename from openspec/changes/ui-animations/tasks.md rename to openspec/changes/archive/2026-10-07-ui-animations/tasks.md index 77a0a02..8c62aba 100644 --- a/openspec/changes/ui-animations/tasks.md +++ b/openspec/changes/archive/2026-10-07-ui-animations/tasks.md @@ -2,37 +2,37 @@ ## 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 (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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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` (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") +- [x] 2.1 Parse `animations = ` in `config::parse` (general options; default `true`) (ui-animations: "Animations are disabled by config or launch flag") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 4.1 Menu bar left-to-right sweep in `views/menubar.rs` (pulldown-menus: "Menu bar entrance sweep") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 6.3 Config/flag tests: default on, `animations = false`, `--noanimations` wins over `animations = true` (ui-animations: "Animations are disabled by config or launch flag") +- [x] 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) +- [x] 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") diff --git a/openspec/changes/command-line-clear-key/.openspec.yaml b/openspec/changes/archive/2026-10-12-command-line-clear-key/.openspec.yaml similarity index 100% rename from openspec/changes/command-line-clear-key/.openspec.yaml rename to openspec/changes/archive/2026-10-12-command-line-clear-key/.openspec.yaml diff --git a/openspec/changes/command-line-clear-key/design.md b/openspec/changes/archive/2026-10-12-command-line-clear-key/design.md similarity index 100% rename from openspec/changes/command-line-clear-key/design.md rename to openspec/changes/archive/2026-10-12-command-line-clear-key/design.md diff --git a/openspec/changes/command-line-clear-key/proposal.md b/openspec/changes/archive/2026-10-12-command-line-clear-key/proposal.md similarity index 100% rename from openspec/changes/command-line-clear-key/proposal.md rename to openspec/changes/archive/2026-10-12-command-line-clear-key/proposal.md diff --git a/openspec/changes/command-line-clear-key/specs/command-line/spec.md b/openspec/changes/archive/2026-10-12-command-line-clear-key/specs/command-line/spec.md similarity index 100% rename from openspec/changes/command-line-clear-key/specs/command-line/spec.md rename to openspec/changes/archive/2026-10-12-command-line-clear-key/specs/command-line/spec.md diff --git a/openspec/changes/command-line-clear-key/tasks.md b/openspec/changes/archive/2026-10-12-command-line-clear-key/tasks.md similarity index 67% rename from openspec/changes/command-line-clear-key/tasks.md rename to openspec/changes/archive/2026-10-12-command-line-clear-key/tasks.md index abcc6f3..98f9570 100644 --- a/openspec/changes/command-line-clear-key/tasks.md +++ b/openspec/changes/archive/2026-10-12-command-line-clear-key/tasks.md @@ -2,24 +2,24 @@ ## 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 +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 2.2 `app.rs` `apply_config`: wire `keys.clear_line` like the other overridable chords +- [x] 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") +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/config-profile-directory/.openspec.yaml b/openspec/changes/archive/2026-10-12-config-profile-directory/.openspec.yaml similarity index 100% rename from openspec/changes/config-profile-directory/.openspec.yaml rename to openspec/changes/archive/2026-10-12-config-profile-directory/.openspec.yaml diff --git a/openspec/changes/config-profile-directory/design.md b/openspec/changes/archive/2026-10-12-config-profile-directory/design.md similarity index 100% rename from openspec/changes/config-profile-directory/design.md rename to openspec/changes/archive/2026-10-12-config-profile-directory/design.md diff --git a/openspec/changes/config-profile-directory/proposal.md b/openspec/changes/archive/2026-10-12-config-profile-directory/proposal.md similarity index 100% rename from openspec/changes/config-profile-directory/proposal.md rename to openspec/changes/archive/2026-10-12-config-profile-directory/proposal.md diff --git a/openspec/changes/config-profile-directory/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-12-config-profile-directory/specs/application-shell/spec.md similarity index 100% rename from openspec/changes/config-profile-directory/specs/application-shell/spec.md rename to openspec/changes/archive/2026-10-12-config-profile-directory/specs/application-shell/spec.md diff --git a/openspec/changes/config-profile-directory/tasks.md b/openspec/changes/archive/2026-10-12-config-profile-directory/tasks.md similarity index 66% rename from openspec/changes/config-profile-directory/tasks.md rename to openspec/changes/archive/2026-10-12-config-profile-directory/tasks.md index b8a41a2..64e457f 100644 --- a/openspec/changes/config-profile-directory/tasks.md +++ b/openspec/changes/archive/2026-10-12-config-profile-directory/tasks.md @@ -2,34 +2,34 @@ ## 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) +- [x] 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") +- [x] 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 +- [x] 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) +- [x] 2.2 `create_dir_all`; on failure set `persist_enabled = false` and the D2 warning (scenario "Unwritable profile falls back without losing the session") +- [x] 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") +- [x] 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") +- [x] 3.1 Add pure `legacy_files_present(cwd: &Path, profile: &Path) -> Vec<&'static str>` (empty when the profile already has `config.toml`) (design D3) +- [x] 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) +- [x] 4.1 `main.rs`: `--config-dir` parsing (both forms; missing value) +- [x] 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))) +- [x] 4.3 `config.rs`: `legacy_files_present` with temp dirs — each file individually, none, and profile-already-populated +- [x] 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 +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/full-mode-column-widths/.openspec.yaml b/openspec/changes/archive/2026-10-12-full-mode-column-widths/.openspec.yaml similarity index 100% rename from openspec/changes/full-mode-column-widths/.openspec.yaml rename to openspec/changes/archive/2026-10-12-full-mode-column-widths/.openspec.yaml diff --git a/openspec/changes/full-mode-column-widths/design.md b/openspec/changes/archive/2026-10-12-full-mode-column-widths/design.md similarity index 100% rename from openspec/changes/full-mode-column-widths/design.md rename to openspec/changes/archive/2026-10-12-full-mode-column-widths/design.md diff --git a/openspec/changes/full-mode-column-widths/proposal.md b/openspec/changes/archive/2026-10-12-full-mode-column-widths/proposal.md similarity index 100% rename from openspec/changes/full-mode-column-widths/proposal.md rename to openspec/changes/archive/2026-10-12-full-mode-column-widths/proposal.md diff --git a/openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-12-full-mode-column-widths/specs/panel-navigation/spec.md similarity index 100% rename from openspec/changes/full-mode-column-widths/specs/panel-navigation/spec.md rename to openspec/changes/archive/2026-10-12-full-mode-column-widths/specs/panel-navigation/spec.md diff --git a/openspec/changes/full-mode-column-widths/tasks.md b/openspec/changes/archive/2026-10-12-full-mode-column-widths/tasks.md similarity index 66% rename from openspec/changes/full-mode-column-widths/tasks.md rename to openspec/changes/archive/2026-10-12-full-mode-column-widths/tasks.md index 501421c..bed4bc3 100644 --- a/openspec/changes/full-mode-column-widths/tasks.md +++ b/openspec/changes/archive/2026-10-12-full-mode-column-widths/tasks.md @@ -2,22 +2,22 @@ ## 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 +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 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") +- [x] 2.2 `panel.rs` unit test: file sizes `87` and `35K` are right-aligned in the Size column (scenario "Sizes are right-aligned") +- [x] 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` +- [x] 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 +- [x] 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 diff --git a/openspec/changes/hidden-entry-styling/.openspec.yaml b/openspec/changes/archive/2026-10-12-hidden-entry-styling/.openspec.yaml similarity index 100% rename from openspec/changes/hidden-entry-styling/.openspec.yaml rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/.openspec.yaml diff --git a/openspec/changes/hidden-entry-styling/design.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/design.md similarity index 100% rename from openspec/changes/hidden-entry-styling/design.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/design.md diff --git a/openspec/changes/hidden-entry-styling/proposal.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/proposal.md similarity index 100% rename from openspec/changes/hidden-entry-styling/proposal.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/proposal.md diff --git a/openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/additional-panel-modes/spec.md similarity index 100% rename from openspec/changes/hidden-entry-styling/specs/additional-panel-modes/spec.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/additional-panel-modes/spec.md diff --git a/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/panel-navigation/spec.md similarity index 64% rename from openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/panel-navigation/spec.md index 630c917..f838340 100644 --- a/openspec/changes/hidden-entry-styling/specs/panel-navigation/spec.md +++ b/openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/panel-navigation/spec.md @@ -2,12 +2,12 @@ ### 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. +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. 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◀` +- **THEN** its name SHALL render in the `panel.directory` style and its Size column SHALL read `▶SUB-DIR◀` in full #### Scenario: Parent entry styling diff --git a/openspec/changes/hidden-entry-styling/specs/theme-system/spec.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/theme-system/spec.md similarity index 100% rename from openspec/changes/hidden-entry-styling/specs/theme-system/spec.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/specs/theme-system/spec.md diff --git a/openspec/changes/hidden-entry-styling/tasks.md b/openspec/changes/archive/2026-10-12-hidden-entry-styling/tasks.md similarity index 65% rename from openspec/changes/hidden-entry-styling/tasks.md rename to openspec/changes/archive/2026-10-12-hidden-entry-styling/tasks.md index 262e88a..15341d4 100644 --- a/openspec/changes/hidden-entry-styling/tasks.md +++ b/openspec/changes/archive/2026-10-12-hidden-entry-styling/tasks.md @@ -2,25 +2,25 @@ ## 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 +- [x] 1.1 `RawDirEntry.hidden`, `Entry.hidden` (`Entry::parent_dir()` → `false`); `From` copies it (panel-navigation "Entry row rendering": "Hidden entries render dimmed") +- [x] 1.2 `StdFsReader::read_dir`: `cfg(windows)` attribute test (`0x2 | 0x4`), else dot-prefix (design D1) +- [x] 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") +- [x] 2.1 `Role::PanelHidden` → `"panel.hidden"`; add to `ALL_ROLES`; six built-in tables per design D2 (theme-system "`panel.hidden` role") +- [x] 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) +- [x] 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") +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/line-editing-in-text-fields/.openspec.yaml b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/.openspec.yaml similarity index 100% rename from openspec/changes/line-editing-in-text-fields/.openspec.yaml rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/.openspec.yaml diff --git a/openspec/changes/line-editing-in-text-fields/design.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/design.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/design.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/design.md diff --git a/openspec/changes/line-editing-in-text-fields/proposal.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/proposal.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/proposal.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/proposal.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/command-line/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/command-line/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/command-line/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/file-action-menu/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/file-action-menu/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/file-action-menu/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/find-file/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/find-file/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/find-file/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/fuzzy-jump/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/fuzzy-jump/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/fuzzy-jump/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/operation-dialogs/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/operation-dialogs/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/operation-dialogs/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/text-field-editing/spec.md similarity index 100% rename from openspec/changes/line-editing-in-text-fields/specs/text-field-editing/spec.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/specs/text-field-editing/spec.md diff --git a/openspec/changes/line-editing-in-text-fields/tasks.md b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/tasks.md similarity index 71% rename from openspec/changes/line-editing-in-text-fields/tasks.md rename to openspec/changes/archive/2026-10-12-line-editing-in-text-fields/tasks.md index 8a8340d..ba18e46 100644 --- a/openspec/changes/line-editing-in-text-fields/tasks.md +++ b/openspec/changes/archive/2026-10-12-line-editing-in-text-fields/tasks.md @@ -2,35 +2,35 @@ ## 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 +- [x] 1.1 `LineEdit` per design D1 with all operations and `window(width)` +- [x] 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 +- [x] 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") +- [x] 2.2 `find_file.rs` `pattern`, `quicksearch.rs` `FuzzyJumpState.pattern` become `LineEdit`; search-narrowing reads `text()` +- [x] 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) +- [x] 2.4 `Command::RequestPaste` → `Effect::ReadClipboardText`; `TextEdit(Paste)` inserts at the caret (text-field-editing "Paste into a text field"; design D4) +- [x] 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" +- [x] 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 +- [x] 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) +- [x] 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 +- [x] 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) +- [x] 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) +- [x] 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` +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/open-with-associated-app/.openspec.yaml b/openspec/changes/archive/2026-10-12-open-with-associated-app/.openspec.yaml similarity index 100% rename from openspec/changes/open-with-associated-app/.openspec.yaml rename to openspec/changes/archive/2026-10-12-open-with-associated-app/.openspec.yaml diff --git a/openspec/changes/open-with-associated-app/design.md b/openspec/changes/archive/2026-10-12-open-with-associated-app/design.md similarity index 100% rename from openspec/changes/open-with-associated-app/design.md rename to openspec/changes/archive/2026-10-12-open-with-associated-app/design.md diff --git a/openspec/changes/open-with-associated-app/proposal.md b/openspec/changes/archive/2026-10-12-open-with-associated-app/proposal.md similarity index 100% rename from openspec/changes/open-with-associated-app/proposal.md rename to openspec/changes/archive/2026-10-12-open-with-associated-app/proposal.md diff --git a/openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md b/openspec/changes/archive/2026-10-12-open-with-associated-app/specs/file-action-menu/spec.md similarity index 100% rename from openspec/changes/open-with-associated-app/specs/file-action-menu/spec.md rename to openspec/changes/archive/2026-10-12-open-with-associated-app/specs/file-action-menu/spec.md diff --git a/openspec/changes/open-with-associated-app/tasks.md b/openspec/changes/archive/2026-10-12-open-with-associated-app/tasks.md similarity index 71% rename from openspec/changes/open-with-associated-app/tasks.md rename to openspec/changes/archive/2026-10-12-open-with-associated-app/tasks.md index 63894b3..ce28e47 100644 --- a/openspec/changes/open-with-associated-app/tasks.md +++ b/openspec/changes/archive/2026-10-12-open-with-associated-app/tasks.md @@ -2,22 +2,22 @@ ## 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` +- [x] 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") +- [x] 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") +- [x] 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 +- [x] 2.1 `Cargo.toml`: `[target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_UI_Shell", "Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }` +- [x] 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` +- [x] 2.3 Dispatch the effect without suspending the terminal; map `Err` to `Command::OpenWithAssociationFailed` +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/pulldown-menu-completion/.openspec.yaml b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/.openspec.yaml similarity index 100% rename from openspec/changes/pulldown-menu-completion/.openspec.yaml rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/.openspec.yaml diff --git a/openspec/changes/pulldown-menu-completion/design.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/design.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/design.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/design.md diff --git a/openspec/changes/pulldown-menu-completion/proposal.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/proposal.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/proposal.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/proposal.md diff --git a/openspec/changes/pulldown-menu-completion/specs/command-line/spec.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/command-line/spec.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/specs/command-line/spec.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/command-line/spec.md diff --git a/openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/panel-navigation/spec.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/specs/panel-navigation/spec.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/panel-navigation/spec.md diff --git a/openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/pulldown-menus/spec.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/specs/pulldown-menus/spec.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/pulldown-menus/spec.md diff --git a/openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/user-menu/spec.md similarity index 100% rename from openspec/changes/pulldown-menu-completion/specs/user-menu/spec.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/specs/user-menu/spec.md diff --git a/openspec/changes/pulldown-menu-completion/tasks.md b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/tasks.md similarity index 70% rename from openspec/changes/pulldown-menu-completion/tasks.md rename to openspec/changes/archive/2026-10-12-pulldown-menu-completion/tasks.md index a6e8b2f..28f63d3 100644 --- a/openspec/changes/pulldown-menu-completion/tasks.md +++ b/openspec/changes/archive/2026-10-12-pulldown-menu-completion/tasks.md @@ -2,30 +2,30 @@ ## 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) +- [x] 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") +- [x] 1.2 Remove Attributes, Compare directories, Configuration, Editor selection, Save setup; Options = `Themes` (pulldown-menus "Menu contents"; design D5) +- [x] 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) +- [x] 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") +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 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 +- [x] 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 +- [x] 3.2 `views/history_dialog.rs`: primary-style list (reuse the user-menu renderer's geometry); snapshot tests (populated, empty) +- [x] 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` +- [x] 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 +- [x] 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 +- [x] 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 diff --git a/openspec/changes/ui-animations/.openspec.yaml b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/.openspec.yaml similarity index 100% rename from openspec/changes/ui-animations/.openspec.yaml rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/.openspec.yaml diff --git a/openspec/changes/reread-preserves-cursor/design.md b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/design.md similarity index 100% rename from openspec/changes/reread-preserves-cursor/design.md rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/design.md diff --git a/openspec/changes/reread-preserves-cursor/proposal.md b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/proposal.md similarity index 100% rename from openspec/changes/reread-preserves-cursor/proposal.md rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/proposal.md diff --git a/openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/specs/file-operations/spec.md similarity index 100% rename from openspec/changes/reread-preserves-cursor/specs/file-operations/spec.md rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/specs/file-operations/spec.md diff --git a/openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/specs/panel-navigation/spec.md similarity index 100% rename from openspec/changes/reread-preserves-cursor/specs/panel-navigation/spec.md rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/specs/panel-navigation/spec.md diff --git a/openspec/changes/reread-preserves-cursor/tasks.md b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/tasks.md similarity index 70% rename from openspec/changes/reread-preserves-cursor/tasks.md rename to openspec/changes/archive/2026-10-12-reread-preserves-cursor/tasks.md index e34b69d..d1339ed 100644 --- a/openspec/changes/reread-preserves-cursor/tasks.md +++ b/openspec/changes/archive/2026-10-12-reread-preserves-cursor/tasks.md @@ -2,36 +2,36 @@ ## 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 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` (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) +- [x] 1.1 Add `RereadAnchor { names: Vec, cursor: usize, scroll_offset: usize }` and `Panel::reread_anchor: Option`, initialized to `None` (design D1, D4) +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 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` 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) +- [x] 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) +- [x] 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) +- [x] 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) +- [x] 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 +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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") +- [x] 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) +- [x] 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) +- [x] 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 +- [x] 4.1 `cargo test --workspace` green +- [x] 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 diff --git a/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/.openspec.yaml b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/design.md b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/design.md new file mode 100644 index 0000000..2165a49 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/design.md @@ -0,0 +1,70 @@ +# Design: copy-onto-itself-guard + +## Context + +All of the following was verified on `build/open-proposals-2026-10` @ `1bada6f`. + +**How the destination reaches the worker.** F5 and F6 open the destination dialog pre-filled with the opposite panel's directory (`enter_file_op_setup`). `confirm_destination_input` builds `Job { dest_dir: PathBuf::from(trimmed), .. }` with no comparison against `source_dir`. With both panels on the same directory, Enter therefore starts a job whose destination is the source directory. + +**How the worker decides there is no conflict.** In `run_copy_or_move` (`fs_ops/worker.rs`), each planned file's target is `job.dest_dir.join(rel_path)`. A conflict is raised only when `fs.metadata(&target)` succeeds **and** `!identity_matches(fs, &planned.src_abs, target_md.file_id)`. `identity_matches` compares the source's followed identity with the target's no-follow identity, and answers `false` unless both are known. A target with the same identity as the source is therefore "no conflict". That rule exists for case-only renames, but here it sends the file straight to `copy_one_file(src, src)`. + +**What the copy primitive does to a file copied onto itself.** The Windows `copy_chunked_impl` and the portable `copy_file_chunked` both run `File::open(from)` and then `File::create(to)`. `File::create` truncates the existing file, because Rust's default share mode lets the second handle open a file the first handle is reading. The read loop then sees end-of-file at once. A probe compiled with the audited toolchain (`rustc 1.98.1`), which replicates `copy_chunked_impl` on a 2,048-byte file under a `\\?\` path, printed `before=2048 result=Ok(0) after=0`. The worker then runs its best-effort metadata restore against that same, now-empty file and reports `Done`. + +**When the identity is unknown.** The Windows `RealFs` reads identity through `file_id_via_handle`, which returns `None` when the handle cannot be opened. In that case: + +- `identity_matches` answers `false`, so the same file raises an overwrite conflict against itself, and Overwrite truncates it as above. +- `RealFs::same_volume` answers `false`, so a same-directory Move skips `run_move_same_volume` and takes the copy-then-delete path. After the self-copy, `let _ = fs.remove_file(src)` deletes the file. + +**Aliases.** `FakeFs::metadata` resolves ancestor links, so a target reached through a junction to the source directory reports the source's identity. NTFS does the same. A file symlink as the target is different: its no-follow identity is the link's, so today it raises a conflict, and Overwrite truncates the source through the link. + +## Goals / Non-Goals + +**Goals:** + +- No Copy, and no copy half of a move, ever opens its own source for writing. +- The user learns why the file was not copied. The item appears in the end-of-job skipped-files summary. +- Regression tests that fail against today's code, using `FakeFs` and `RealFs`. + +**Non-Goals:** + +- Temporary-file landing, flushing and metadata preservation (`durable-file-copy`). +- Rejecting a same-directory destination in the destination dialog before the job starts. This could be added later on top of the worker check. +- Copying a directory into its own subdirectory, for example `C:\work\sub` to `C:\work\sub\inner`. That is a different defect and is not changed here. +- Same-volume moves and in-place renames. `run_move_same_volume` and `run_rename` keep their identity-equals-no-conflict rule, which case-only renames rely on (file-operations "Identity-aware case-only rename"). + +## Decisions + +### D1: The worker checks for "same file" before the conflict check + +For each planned file, `run_copy_or_move` calls a new `same_file(fs, src, target)` helper before computing the conflict. The helper answers `true` when either of these holds: + +- **Identity.** `fs.metadata_follow(target)` and `fs.metadata_follow(src)` both report a file identity, and the two are equal. The target is *followed* here, unlike the conflict check, so that a file symlink pointing at the source also counts as the same file. +- **Path fallback.** Either identity is unknown, and the two paths are equal after `path::lexically_normalize`. The comparison is case-insensitive on Windows, where a case-insensitive volume is the norm, and exact elsewhere. + +A target that does not exist is never the same file. + +When the helper answers `true`, the item gets `SkippedItem { path: src, reason: "source and destination are the same file" }`. Nothing is written, no conflict dialog is shown, and in move mode the source is not removed. The loop then continues with the next file. As with a conflict Skip, `files_done` and `bytes_done` do not advance for the skipped item. + +*Alternative considered:* rely on `durable-file-copy`'s temp-and-rename. Copying onto itself through a temporary file keeps the content, but a copy would still rewrite the file pointlessly, and a copy-then-delete move would then delete it. That change is also much larger and should not hold up a Critical fix. + +*Alternative considered:* refuse in the reducer when the destination equals the source directory. Rejected as the only guard, because it misses aliases that only identity catches: junctions, `subst` drives, and a UNC path to a local share. A dialog-level check can be added later on top. + +*Alternative considered:* raise the overwrite-conflict dialog for the same file. Rejected, because every choice in that dialog either destroys the file (Overwrite) or means "skip" anyway. A rename to a new name is the one useful outcome, and the user can get that by typing a different destination. + +### D2: The path fallback assumes an absolute destination + +The path comparison is meaningful only for absolute paths. `relative-destination-resolution` (R-06) makes the job's destination absolute. Until it lands, a relative destination reaches the worker unchanged. The identity comparison is unaffected, because the OS resolves both paths before the identity is read. The fallback then simply does not match, which is no worse than today. + +## Risks / Trade-offs + +- [The path fallback is lexical] Two different spellings of the same file, such as a `subst` drive versus its real path, are not caught when identity is unknown. → Identity is normally readable on local NTFS volumes, the common case. The fallback only covers the rare unknown case. +- [Case-insensitive comparison on a case-sensitive directory] On a Windows directory with per-directory case sensitivity enabled, `a.txt` and `A.txt` can be distinct files. → The fallback runs only when identity is unknown, and then the cost is a skipped item listed in the summary, not data loss. +- [A file symlink target is now skipped instead of raising a conflict] Overwriting a file symlink with a copy of its own target is no longer possible through F5. → That operation truncated the source through the link anyway, so no working behavior is lost. + +## Migration Plan + +None. No persisted data or configuration changes. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/proposal.md b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/proposal.md new file mode 100644 index 0000000..35e4466 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/proposal.md @@ -0,0 +1,45 @@ +# Change: copy-onto-itself-guard + +## Why + +**Copying a file onto itself truncates it to zero bytes (Critical, data loss).** When both panels show the same directory, pressing F5 and accepting the pre-filled destination copies every selected file onto itself. `run_copy_or_move` sees that the target has the same file identity as the source and treats it as "no conflict". That rule exists so that case-only *renames* are not reported as overwrites. The worker then calls `copy_file_chunked(src, src)`. Its `File::open(from)` is followed by `File::create(to)` on the same path, and the create truncates the file before the first byte is read. A compiled probe of that exact sequence on a 2,048-byte file returned `Ok(0)` and left the file at 0 bytes. The job reports success, and no dialog appears. A directory copied into its own parent loses the content of every file in it the same way. + +When a file identity cannot be read, the result is worse. The worker then raises an overwrite conflict for the file against itself, and choosing Overwrite truncates it the same way. `RealFs::same_volume` also answers `false` in that case, so a same-directory F6 Move takes the copy-then-delete path: the file is truncated by the self-copy and then deleted as the move's source. + +The code audit does not list this defect. It was found while `durable-file-copy` (R-07, R-08) was being authored. It is tracked separately because it is a one-check fix that should not wait for the larger copy-engine rework. + +## What Changes + +- **A copy never writes to its own source.** Before copying a planned file, the copy/move worker checks whether the target resolves to the same file as the source. The target counts as the same file when its file identity equals the source's. When either identity cannot be read, it counts as the same file when the two normalized absolute paths are equal, compared case-insensitively on Windows. +- **Same-file items are skipped and reported.** Such an item is skipped without writing anything and without raising the overwrite-conflict dialog. It is listed in the end-of-job skipped-files summary with the reason "source and destination are the same file". A move performed as copy-then-delete also skips it and never deletes the source. +- **Regression tests:** `FakeFs` worker tests for a same-directory copy, a destination reached through a junction alias, a copy-then-delete move onto the same file, and an unknown identity; a unit test for the path fallback; and a `RealFs` integration test that copies a file into its own directory on a real temporary directory. +- Non-breaking. The case-only rename rule in `run_move_same_volume` and `run_rename` is unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `file-operations`: new requirement "A copy never overwrites its own source." + +## Impact + +- **Specs:** `file-operations` (one added requirement). +- **Code:** + - `crates/filecommand-core/src/fs_ops/worker.rs`: + - `run_copy_or_move` (lines 363-427): a same-file check before the conflict check, using a new helper next to `identity_matches`. The helper compares the followed identities of source and target, and falls back to `path::lexically_normalize` plus a case-insensitive comparison on Windows when an identity is unknown. + - New tests in the `#[cfg(test)]` module. + - `crates/filecommand-core/tests/realfs.rs`: a same-directory copy test. This change creates the file with a minimal harness if `durable-file-copy` and `move-reparse-point-safety` have not created it yet. + - No `filecommand-tui` changes. The existing skipped-files summary renders the new reason. +- **Dependencies:** none. + +**Audit findings:** none — discovered while authoring durable-file-copy (same-identity copy path, related to R-08) + +**Coordination:** +- `durable-file-copy` rewrites `copy_one_file` around a temporary partial file and renames over the target, and edits the same `run_copy_or_move` loop. This change should land first because it is the smaller fix for a Critical data loss. `durable-file-copy` then keeps this check ahead of choosing the temporary file. If `durable-file-copy` lands first, this change rebases onto its loop unchanged. Both changes share `crates/filecommand-core/tests/realfs.rs`, and whichever lands first creates it. +- `move-reparse-point-safety` edits the same `run_copy_or_move` loop and also adds tests to `realfs.rs`. A same-file skip beneath a directory link counts as "not copied", so that change keeps the link in place. Whichever change lands second rebases. +- `relative-destination-resolution` (R-06) makes the job's destination absolute. Until it lands, a relative destination still reaches the worker, and the identity comparison catches the same-file case on its own. The path fallback relies on absolute paths. +- `error-diagnostics` (B-06) MODIFIES operation-dialogs "End-of-job skipped-files summary" so that its list of skip sources reads "for example". The same-file skip is one more skip recorded by the engine and fits that wording. There is no ordering constraint. diff --git a/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/specs/file-operations/spec.md b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/specs/file-operations/spec.md new file mode 100644 index 0000000..7de43f8 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/specs/file-operations/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: A copy never overwrites its own source + +When a planned copy's target resolves to the same file as its source, the copy SHALL NOT write to the target. This applies to a Copy and to the copy half of a move performed as copy-then-delete. The target SHALL count as the same file when either of these holds: + +- after following links, it has the same file identity as the source; +- the identity of either one cannot be determined, and the two normalized absolute paths are equal, compared case-insensitively on Windows. + +Such an item SHALL NOT raise the overwrite-conflict dialog. It SHALL be skipped and listed in the end-of-job skipped-files summary with a reason stating that the source and destination are the same file. A move in that situation SHALL NOT delete the source. + +#### Scenario: F5 into the directory the file is already in leaves it unchanged +- **WHEN** both panels show `C:\work`, the cursor is on `report.txt` (2,048 bytes), and the user presses F5 and accepts the pre-filled destination `C:\work` +- **THEN** `report.txt` still holds its original 2,048 bytes +- **AND** the end-of-job summary lists `report.txt` as skipped because the source and destination are the same file + +#### Scenario: The same file reached through a link is not overwritten +- **WHEN** `C:\alias` is a junction to `C:\work` and the user copies `C:\work\report.txt` to `C:\alias` +- **THEN** `report.txt` is not modified and is listed as skipped because the source and destination are the same file + +#### Scenario: A copy-then-delete move onto the same file keeps the source +- **WHEN** a move that runs as copy-then-delete reaches `report.txt` and its target is the same file as the source +- **THEN** `report.txt` is neither modified nor deleted +- **AND** the end-of-job summary lists `report.txt` as skipped because the source and destination are the same file diff --git a/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/tasks.md b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/tasks.md new file mode 100644 index 0000000..a64bad8 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-copy-onto-itself-guard/tasks.md @@ -0,0 +1,28 @@ +# Tasks: copy-onto-itself-guard + +## 1. Regression tests first (`crates/filecommand-core/src/fs_ops/worker.rs`, `#[cfg(test)]`) + +- [x] 1.1 Same-directory copy with `FakeFs`: `/work/report.txt` is created with `add_file_with_stream` and given a last-write time with `set_modified`. A Copy job runs with `source_dir == dest_dir == /work`. Assert that the job completes with `report.txt` in `skipped`, with a reason containing "same file". Assert that the node keeps its size, stream and last-write time, and that `RecordingSink` saw no conflict. `FakeFs` does not model truncation, so the stream and time are what detect today's self-copy. Confirm the test FAILS against the unmodified worker (file-operations: "F5 into the directory the file is already in leaves it unchanged") +- [x] 1.2 Junction alias with `FakeFs`: junction `/alias` → `/work`, then copy `/work/report.txt` to `/alias`. Same assertions as 1.1 (file-operations: "The same file reached through a link is not overwritten") +- [x] 1.3 Copy-then-delete move with `FakeFs`: `set_volume("/v2", 2)` and a junction `/v2/alias` → `/v1/work`, then Move `/v1/work/report.txt` to `/v2/alias`. `same_volume` answers `false`, so the copy-then-delete path runs. Assert that `/v1/work/report.txt` still exists unchanged and is listed as skipped for "same file". Today's code deletes it (file-operations: "A copy-then-delete move onto the same file keeps the source") +- [x] 1.4 Identity unknown: a test-local `NoIdentity` wrapper delegates every call and clears `file_id`. A same-directory Copy through it must skip `report.txt` for "same file" without raising a conflict. Today it raises a conflict, and the sink's default Overwrite rewrites the file (design D1) + +## 2. Worker guard (`crates/filecommand-core/src/fs_ops/worker.rs`) + +- [x] 2.1 Add `same_file(fs, src, target) -> bool` next to `identity_matches`. It answers `true` when the followed identities of `src` and `target` are both known and equal. When either is unknown, it answers `true` when `path::lexically_normalize` of the two paths are equal, compared case-insensitively under `cfg(windows)` and exactly elsewhere. A missing target answers `false` (design D1) +- [x] 2.2 In `run_copy_or_move`'s file loop, before the conflict check, skip a `same_file` item: push `SkippedItem { path: planned.src_abs, reason: "source and destination are the same file" }` and continue. Nothing is written, no conflict is raised, the move's source removal is not reached, and `files_done`/`bytes_done` do not advance (file-operations: "A copy never overwrites its own source"; design D1) +- [x] 2.3 Leave `run_move_same_volume` and `run_rename` unchanged. Their identity-equals-no-conflict rule stays for case-only renames. Add a comment at the new check pointing to that difference +- [x] 2.4 Unit-test the path fallback directly: on Windows, `C:\Work\Report.txt` and `c:\work\.\report.txt` are the same file; `C:\work\a.txt` and `C:\work\b.txt` are not. On other platforms the comparison is exact (design D1) + +## 3. RealFs integration test (`crates/filecommand-core/tests/realfs.rs`) + +- [x] 3.1 If the file does not exist yet, because `durable-file-copy` and `move-reparse-point-safety` have not landed, create it with a minimal harness: a per-test directory under `std::env::temp_dir()` named with the process id and test name, a drop guard that removes it, and a recording `JobSink` that answers conflicts with Overwrite. Otherwise reuse the existing harness +- [x] 3.2 Cross-platform: write a 2,048-byte `report.txt` with known content into a temp directory, then run a Copy job through `run_job` with `RealFs` and `source_dir == dest_dir`. Assert that the file still holds the same 2,048 bytes, and that the outcome lists it as skipped for "same file" (file-operations: "F5 into the directory the file is already in leaves it unchanged") + +## 4. Verify + +- [x] 4.1 1.1-1.4 pass, and the existing `case_only_rename_*`, `move_cross_volume_*`, `overwrite_all_*` and `skip_all_*` worker tests still pass +- [x] 4.2 `cargo test --workspace` green, including `tests/realfs.rs` +- [x] 4.3 `cargo clippy --workspace --all-targets` introduces no new warnings +- [x] 4.4 `openspec validate copy-onto-itself-guard --strict` clean +- [ ] 4.5 Manual (Windows): open the same directory in both panels, select a file and a directory, press F5 and accept the pre-filled destination. Confirm that every file keeps its size and content and that the summary lists them as "same file" diff --git a/openspec/changes/archive/2026-10-13-installer-scope-coexistence/.openspec.yaml b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-13-installer-scope-coexistence/design.md b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/design.md new file mode 100644 index 0000000..3092dfa --- /dev/null +++ b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/design.md @@ -0,0 +1,146 @@ +# Design: installer-scope-coexistence + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`). + +**Packages.** +- `installer/PackagePerUser.wxs`: + - `Scope="perUser"`, `UpgradeCode="E4FA877A-C5DE-4BB5-A56E-530939D32718"`; + - installs to `%LocalAppData%\Programs\BigHatGroup\FileCommand`; + - appends that directory to the **user** PATH (``); + - writes HKCU keypath values. +- `installer/PackagePerMachine.wxs`: + - `Scope="perMachine"`, `UpgradeCode="4E088CA3-DA52-42AB-8620-DF5E41B57C7A"`; + - installs to `%ProgramFiles%\BigHatGroup\FileCommand`; + - appends that directory to the **machine** PATH; + - writes HKLM keypath values. +- Both packages have `MajorUpgrade` for same-scope upgrades, and `RemoveFolder` for the application and Start Menu folders. + +**Bundle.** `installer/Bundle.wxs`: +- `UpgradeCode="83A047C2-CBA3-4DA9-96EF-1481CD2D0A6A"`; +- ``; +- `bal:WixStandardBootstrapperApplication` with `Theme="rtfLicense"`, `LicenseFile="License.rtf"`, `SuppressOptionsUI="yes"`, `ShowVersion="yes"`; +- a two-package chain gated by `InstallCondition` on `InstallScope`. + +The bundle has no searches today. `installer/build.ps1` builds the bundle with `-ext WixToolset.BootstrapperApplications.wixext` only, and the README pins that extension with `wix extension add WixToolset.BootstrapperApplications.wixext/5.0.2`. + +**Coexistence today.** `installer/README.md` § "Same-scope upgrades only" says that installing at the other scope "will **coexist** with (not replace) the first install". Nothing detects it at install time. + +Windows composes a new process's `PATH` from the machine `Path` value followed by the user `Path` value. So when both directories are present, `filecommand` always resolves to `%ProgramFiles%\BigHatGroup\FileCommand\filecommand.exe`, whichever scope was installed last. + +**Settings.** On this branch, `config-profile-directory` is implemented (still in flight on `Spec`). FileCommand resolves its profile directory once at startup: +1. `--config-dir `; +2. otherwise, if a file named `portable` sits beside the executable, the executable's directory; +3. otherwise `%APPDATA%\FileCommand` on Windows. + +`installer/README.md` § "Scope semantics" already says that settings (`config.toml`, `history.json`, `usermenu.toml`) live under `%APPDATA%\FileCommand` "regardless of install scope". The windows-installer spec already requires that uninstall leaves those files untouched ("Uninstall leaves user data alone"). Nothing says how to remove them. + +**Spec.** `windows-installer` has "Dual-scope MSI package", "Install directory joins PATH at the matching scope", "Burn bootstrapper", "Winget deployability", "Versioned in-place upgrades", and "Reproducible installer build". No in-flight change has a delta on this capability. + +## Goals / Non-Goals + +**Goals:** + +- A user starting an interactive install learns, before clicking Install, that the other scope is already installed, and which copy will run afterwards. +- Unattended installs (winget, Chocolatey, `/quiet`, `/passive`) behave exactly as today, with the detection visible in the log. +- A user who wants to remove FileCommand completely can find out where its settings live and how to delete them. + +**Non-Goals:** + +- Blocking, or automatically migrating, an install at the other scope. Switching scope stays uninstall-then-reinstall, as documented. +- Changing either MSI, PATH handling, install locations, or upgrade codes. +- A "remove settings" option in the bundle UI or the MSIs (D4). +- Detecting per-user installs that belong to *other* Windows accounts (D5). +- A scope picker in the bundle UI (`SuppressOptionsUI` stays). + +## Decisions + +### D1: Detect the other scope with `util:ProductSearch` by UpgradeCode + +`Bundle.wxs` declares two searches, using the WiX Util extension (`xmlns:util="http://wixtoolset.org/schemas/v4/wxs/util"`): + +```xml + + +``` + +A `state` of `5` (`INSTALLSTATE_DEFAULT`) means installed; a product that is not installed reports `2` (`INSTALLSTATE_ABSENT`). + +A `SetVariable` element (`After="SearchPerUserProduct"`) sets a numeric `FileCommandOtherScopePresent` to `1` when "other scope present" holds, and the theme tests that one variable: +- `InstallScope <> "perMachine" AND FileCommandPerMachineState = 5`, or +- `InstallScope = "perMachine" AND FileCommandPerUserState = 5`. + +Checked with the WiX 5.0.2 CLI and `WixToolset.Util.wixext` 5.0.2 while reviewing this change: a test bundle with exactly these two searches, the `SetVariable`, and a custom `ThemeFile`/`LocalizationFile` compiles, and its Burn log shows `Setting numeric variable 'FileCommandPerMachineState' to value 2` and `… 'FileCommandPerUserState' to value 5` on a machine with only the per-user MSI installed, followed by the condition's evaluation. + +Burn runs searches during Detect, in the bundle's own (non-elevated) process, before any plan or elevation. The per-user search therefore sees the per-user product of the account that launched setup. + +Searching by UpgradeCode, not ProductCode, is what makes this version-independent. Each MSI's ProductCode changes with every build, but its UpgradeCode is fixed (windows-installer "Winget deployability" — identity is stable across versions). + +*Alternative considered: `util:RegistrySearch` on the packages' keypath values (`HKCU`/`HKLM\Software\BigHatGroup\FileCommand`).* Those values are implementation details of the component layout, and could change without anyone thinking of this search. The UpgradeCode is the packages' declared, stable identity. **Rejected.** + +*Alternative considered: searching the PATH for `filecommand.exe`.* That would also flag unrelated copies, such as a `cargo install` binary or a portable copy. Scope coexistence is specifically about the two MSIs. **Rejected.** + +### D2: Warn on the install page through a custom WixStdBA theme; never block + +The warning is a `Label` control on WixStdBA's `Install` page (the page with the license text, the accept checkbox, and the Install button), visible only when "other scope present" is true. + +To get it, the bundle supplies its own theme: `ThemeFile` and `LocalizationFile` on `bal:WixStandardBootstrapperApplication`, both stored in `installer/theme/` (`Theme="rtfLicense"` can stay; the test bundle compiled with both). The theme is copied from the built-in `rtfLicense` theme (`RtfTheme.xml` and `RtfTheme.wxl`, which `WixToolset.BootstrapperApplications.wixext` **5.0.2**, the pinned version, embeds in its wixlib) and extended with that one control. In the built-in theme the license `Richedit` fills the page from `Y="80"` down to the button row, so the label takes the top of that area and the `Richedit` starts below it. The text is in the `.wxl` so it can be translated like the rest of the UI. + +The wording depends on which scope is running: +- **Per-user run, per-machine present:** "FileCommand is already installed for all users of this computer. If you also install it for your account, both copies are on PATH and typing `filecommand` starts the all-users copy, not this one. To switch to a per-user install, uninstall the all-users copy first." +- **Per-machine run, per-user present:** "FileCommand is already installed for your account. If you also install it for all users, both copies are on PATH and typing `filecommand` starts this all-users copy; your per-user copy will no longer be used until this one is removed." + +The Install button and the rest of the flow are unchanged. Clicking Install proceeds exactly as today. + +The label's `VisibleCondition="FileCommandOtherScopePresent"` drives visibility, and two `` children on `InstallScope` pick the wording. Both are patterns the built-in 5.0.2 `rtfLicense` theme already uses: its `Install` page hides the Options button with `VisibleCondition="NOT WixStdBASuppressOptionsUI"`, and its Success and Failure labels choose their text with ``. Task 2.4 still checks the result on a test VM; if the label does not show or hide as expected, the fallback is a label whose content is a bundle variable (`[FileCommandScopeWarning]`), set to the warning text by `SetVariable` elements conditioned on the same expressions and empty otherwise. + +*Alternative considered: `bal:Condition` with a message.* WixStdBA treats a false `bal:Condition` as a **blocking** launch condition: the message is shown and the install cannot proceed. The requirement is to warn, not refuse. **Rejected.** + +*Alternative considered: a custom bootstrapper application (a managed or native BA).* That is a large new component, with its own build, signing, and runtime prerequisites, for one line of text. **Rejected.** + +### D3: Silent runs are never interrupted; the log carries the result + +With `/quiet` or `/passive` (winget and Chocolatey use `/quiet`), WixStdBA shows no install page, so there is no warning to show. Nothing new can block or prompt. Burn already writes every search result to its log (for example `Setting numeric variable 'FileCommandPerMachineState' to value 5`), and that line is the record for unattended installs. + +The Chocolatey package installs per-machine unless `/Scope:user` is passed (`chocolateyInstall.ps1`), while the winget manifest lists the per-user installer first and maps `--scope machine` to `InstallScope=perMachine`. Mixing the two package managers is therefore the most likely way to end up with both scopes. The README's scope section says so. + +### D4: Settings removal is documentation, not an installer option + +`installer/README.md` gains a section "Uninstalling and removing settings". It says: +- Uninstalling (from Apps & features, or with `FileCommandSetup.exe /uninstall`) removes the program, its shortcut, and its PATH entry, but **keeps** the settings in the profile directory, so a reinstall picks them up. +- The profile directory is `%APPDATA%\FileCommand` (`config.toml`, `history.json`, `usermenu.toml`). For a per-machine install, every Windows account that ran FileCommand has its own copy. +- Removal commands, run as each affected user: + - PowerShell: `Remove-Item -Recurse -Force "$env:APPDATA\FileCommand"` + - cmd: `rmdir /s /q "%APPDATA%\FileCommand"` +- **Portable mode.** If a `portable` file was placed beside `filecommand.exe` in the install directory, the settings files live there instead. Uninstall leaves those files, the `portable` marker, and therefore the folder itself, because the MSI removes only what it installed. Delete the folder by hand. +- **`--config-dir`.** Settings live wherever the user pointed it, and the installer never touches that location. + +*Alternative considered: a "Remove my settings" checkbox on the uninstall page, or an MSI custom action.* +- For a per-machine install, the uninstalling account cannot (and should not) reach other users' `%APPDATA%`. +- Silent uninstalls through winget or Chocolatey would need a new switch. +- A destructive, user-data-deleting custom action in an installer that has none today is a poor trade for a rare need. +- The existing spec rule "Uninstall … SHALL NOT delete user-created runtime files" would also have to be weakened. + +**Rejected** in favor of documentation. + +### D5: Scope of detection + +The per-user search sees only the per-user product of the account running setup. A per-machine install launched by an administrator in a different account will not see a standard user's per-user install. This is a Windows Installer property: per-user products of other users are not enumerable without their profile. It is accepted and documented, because the realistic coexistence case is one person installing both scopes from their own account. + +## Risks / Trade-offs + +- [The custom theme copy drifts from the built-in theme when the WiX extension is upgraded.] → The theme is copied from the pinned `5.0.2` extension, and its header names that version. Any extension bump already requires re-testing (the README documents the 7.0.0 incompatibility). The README notes that the theme must be re-derived on a bump. +- [`VisibleCondition` behaves differently than expected on the install page.] → Unlikely, since the built-in theme uses it on the same page; still verified on a test VM before merge (task 2.4), with the variable-text fallback in D2. +- [Running the same bundle version again shows WixStdBA's maintenance page (Repair or Uninstall) instead of the install page, so no warning is shown.] → No install happens in that case, so there is nothing to warn about. The warning targets the install page, where a second-scope install actually begins. +- [The Util extension adds a build prerequisite.] → It is pinned and documented like the bootstrapper extension, and `build.ps1` passes it explicitly. + +## Migration Plan + +Developers building the installer run `wix extension add WixToolset.Util.wixext/5.0.2` once, as the README prerequisites will say. There is no impact on existing installs. Upgrades behave exactly as before, and an upgrade in a coexistence situation now shows the warning. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-13-installer-scope-coexistence/proposal.md b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/proposal.md new file mode 100644 index 0000000..537dae7 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/proposal.md @@ -0,0 +1,57 @@ +# Change: installer-scope-coexistence + +## Why + +Audit finding **B-10** (Info). The installer design is sound, with two gaps: + +- **The two install scopes coexist silently.** + - The per-user and per-machine MSIs have different UpgradeCodes on purpose (per-user `E4FA877A-…`, per-machine `4E088CA3-…`), because switching scope is uninstall-then-reinstall (windows-installer "Versioned in-place upgrades"). + - The bundle never checks whether the *other* scope is already installed. A user who installed for all users, and later runs the default per-user setup (or the reverse), ends up with two copies and two PATH entries. + - Windows builds a process's `PATH` as the machine `PATH` followed by the user `PATH`, so the per-machine copy always runs when the user types `filecommand`. A per-user install or upgrade made after a per-machine one is silently shadowed: the user upgrades, and the old version keeps starting. + - `installer/README.md` mentions coexistence, but nothing tells the user at install time. +- **Settings survive uninstall, and nothing says how to remove them.** Uninstall deliberately keeps the profile directory (`%APPDATA%\FileCommand`: `config.toml`, `history.json`, `usermenu.toml`; windows-installer "Uninstall leaves user data alone"). Nothing in the installer documentation says where those files are or how to remove them, so a user who wants a clean removal has to guess. + +## What Changes + +- **Detection.** The Burn bundle checks, before planning, whether the other scope's MSI is installed, by its UpgradeCode. A per-user run looks for the per-machine product, and a per-machine run looks for the per-user product of the current user. +- **Warning, not refusal.** On an interactive run, when the other scope is present, the bundle's install page shows a warning. It names the scope already installed, says that both copies would be on PATH, and says which one will run (always the per-machine copy). The install stays allowed: there is no extra prompt and the Install button is unchanged. +- **Silent runs are unchanged.** `/quiet`, `/passive`, winget, and Chocolatey runs are never blocked or prompted. The detection result is recorded in the Burn log. +- **Documented removal of settings.** `installer/README.md` gains an "Uninstalling and removing settings" section. It names the profile directory, states that uninstall keeps it (for a per-machine install, one per user who ran FileCommand), and gives the commands that remove it. It also covers portable mode (settings beside the executable in the install directory) and `--config-dir`. The section also explains the shadowing rule and how to switch scope cleanly. +- **Build changes.** `installer/build.ps1` passes the WiX Util extension (needed for the product search) to the bundle build. The README prerequisites pin it to the same `5.0.2` as the bootstrapper extension. +- **No change** to the MSIs, the install paths, PATH handling, upgrade behavior, or what uninstall removes. No "remove settings" checkbox is added (design D4). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `windows-installer`: adds the requirement "Bundle warns when the other installation scope is present" (detection by UpgradeCode, interactive warning naming the shadowing copy, install still allowed, silent runs unaffected and logged). It also adds "Settings removal is documented" (profile location, kept on uninstall, per-user and portable cases, removal commands). + +## Impact + +- **`installer/Bundle.wxs`:** + - adds the `util` namespace; + - adds two `util:ProductSearch` elements by UpgradeCode (per-machine `4E088CA3-DA52-42AB-8620-DF5E41B57C7A` and per-user `E4FA877A-C5DE-4BB5-A56E-530939D32718`), each writing a bundle variable; + - adds a derived "other scope present" condition; + - points the WixStdBA at a custom theme file and localization. +- **New `installer/theme/` files:** a copy of the built-in `rtfLicense` theme (`RtfTheme.xml` and `RtfTheme.wxl`, embedded in `WixToolset.BootstrapperApplications.wixext` 5.0.2), extended with one warning `Label` on the install page. +- **`installer/build.ps1`:** `-ext WixToolset.Util.wixext` on the bundle build, and a comment noting the pinned version. +- **`installer/README.md`:** + - prerequisites: `wix extension add WixToolset.Util.wixext/5.0.2`; + - "Scope semantics": the warning and the PATH-precedence rule; + - a new "Uninstalling and removing settings" section. +- **Not touched:** `installer/PackagePerUser.wxs`, `installer/PackagePerMachine.wxs`, the winget and Chocolatey manifests, and Rust code. + +**Audit findings:** B-10 + +**Coordination:** +- `config-profile-directory` (in flight on `Spec`; already implemented on `build/open-proposals-2026-10`) fixes the profile directory: + - `--config-dir ` first; + - then portable mode (a file named `portable` beside the executable means the executable's directory); + - otherwise `%APPDATA%\FileCommand` on Windows. + + That change also adds the sentence in `installer/README.md` saying settings live under `%APPDATA%\FileCommand`. The new removal section builds on that sentence and uses the same resolution order. If `config-profile-directory` changes its default location before archiving, this section follows it. +- No other audit proposal touches `installer/`. diff --git a/openspec/changes/archive/2026-10-13-installer-scope-coexistence/specs/windows-installer/spec.md b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/specs/windows-installer/spec.md new file mode 100644 index 0000000..ceb4861 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/specs/windows-installer/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Bundle warns when the other installation scope is present + +Before planning an install, the Burn bundle SHALL detect whether the MSI of the other scope is already installed, identifying it by that package's stable UpgradeCode: +- a run that will install per-user SHALL look for the per-machine package; +- a run that will install per-machine SHALL look for the per-user package of the account running setup. + +When the other scope is present on an interactive run, the bundle's install page SHALL show a warning before the user starts the install. The warning SHALL name the scope that is already installed, SHALL state that both copies will be on PATH, and SHALL state which copy runs when the user types `filecommand`, which is always the per-machine copy, because the machine PATH precedes the user PATH. The install SHALL remain allowed, with no additional prompt and no change to the Install action. Quiet and passive runs SHALL NOT be blocked or prompted because of the other scope, and the detection result SHALL appear in the bundle's log. + +#### Scenario: Per-user setup over an existing per-machine install + +- **WHEN** FileCommand is installed per-machine and a user runs `FileCommandSetup.exe` interactively with the default per-user scope +- **THEN** the install page warns that FileCommand is already installed for all users, and that the all-users copy will start when `filecommand` is typed +- **AND** clicking Install completes a per-user install as it does today + +#### Scenario: Per-machine setup over an existing per-user install + +- **WHEN** FileCommand is installed per-user for the current account and the same account runs `FileCommandSetup.exe InstallScope=perMachine` interactively from an elevated context +- **THEN** the install page warns that FileCommand is already installed for this account, and that the new all-users copy will start when `filecommand` is typed +- **AND** the per-machine install proceeds when the user clicks Install + +#### Scenario: No warning without the other scope + +- **WHEN** neither scope is installed, or only the scope being installed or upgraded is present +- **THEN** the install page shows no scope warning + +#### Scenario: Silent installs are unaffected + +- **WHEN** `FileCommandSetup.exe /quiet` runs on a machine that already has the per-machine install +- **THEN** the per-user install completes with no UI and no prompt, exactly as before +- **AND** the bundle log records that the per-machine package was detected + +### Requirement: Settings removal is documented + +The installer documentation SHALL describe what uninstall leaves behind and how to remove it. It SHALL state that uninstalling either scope keeps the user's settings files (`config.toml`, `history.json`, `usermenu.toml`). It SHALL name their default location (`%APPDATA%\FileCommand`) and note that every account that ran FileCommand has its own copy. It SHALL give commands that delete that directory. It SHALL also describe the two non-default locations: +- portable mode, where the files and the `portable` marker live beside the executable in the install directory and remain after uninstall; +- `--config-dir`, where the files live in a user-chosen directory the installer never touches. + +The installer itself SHALL NOT delete settings. + +#### Scenario: A user looks up how to remove everything + +- **WHEN** a user reads the uninstall section of `installer/README.md` +- **THEN** it states that uninstall keeps the settings, names `%APPDATA%\FileCommand`, and gives a PowerShell and a cmd command that remove it +- **AND** it explains the per-account copies of a per-machine install, and the portable-mode and `--config-dir` locations + +#### Scenario: Uninstall still keeps settings + +- **WHEN** either scope is uninstalled after the user has run FileCommand +- **THEN** `%APPDATA%\FileCommand` and its files are still present, and a later reinstall uses them diff --git a/openspec/changes/archive/2026-10-13-installer-scope-coexistence/tasks.md b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/tasks.md new file mode 100644 index 0000000..d879d23 --- /dev/null +++ b/openspec/changes/archive/2026-10-13-installer-scope-coexistence/tasks.md @@ -0,0 +1,40 @@ +# Tasks: installer-scope-coexistence + +## 1. Detection in the bundle (`installer/Bundle.wxs`) + +- [x] 1.1 Add the `util` namespace and two `util:ProductSearch` elements by UpgradeCode: per-machine `4E088CA3-DA52-42AB-8620-DF5E41B57C7A` to `FileCommandPerMachineState`, and per-user `E4FA877A-C5DE-4BB5-A56E-530939D32718` to `FileCommandPerUserState`, both with `Result="state"` (windows-installer: "Bundle warns when the other installation scope is present"; design D1) +- [x] 1.2 Add a `SetVariable` (`After="SearchPerUserProduct"`) that sets numeric `FileCommandOtherScopePresent` to `1` when `(InstallScope <> "perMachine" AND FileCommandPerMachineState = 5) OR (InstallScope = "perMachine" AND FileCommandPerUserState = 5)`. Comment it in `Bundle.wxs` with the PATH-precedence reason (design D1, D2) +- [x] 1.3 Leave the chain, `InstallCondition`s, `InstallScope`, and the bundle `UpgradeCode` unchanged, and do not add a `bal:Condition` (design D2) + +## 2. Warning UI (custom WixStdBA theme) + +- [x] 2.1 Copy the built-in `rtfLicense` theme (`RtfTheme.xml`, `RtfTheme.wxl`) from `WixToolset.BootstrapperApplications.wixext` 5.0.2 into `installer/theme/`, with a header comment naming the source version (design D2; Risks) +- [x] 2.2 Add one `Label` at the top of the `Install` page with `VisibleCondition="FileCommandOtherScopePresent"` and two `` children on `InstallScope`, moving the license `Richedit` down to make room. Add the two scope-specific messages to the `.wxl`, naming the installed scope and the copy that will start from PATH (windows-installer: "Per-user setup over an existing per-machine install", "Per-machine setup over an existing per-user install") +- [x] 2.3 Point `bal:WixStandardBootstrapperApplication` at the custom `ThemeFile` and `LocalizationFile`, keeping `LicenseFile`, `SuppressOptionsUI="yes"`, and `ShowVersion="yes"` +- [ ] 2.4 On a test VM, verify that `VisibleCondition` shows and hides the control on the install page. If it does not, switch to the D2 fallback (a text control bound to `[FileCommandScopeWarning]`, set by conditioned `SetVariable` elements) (design D2) + +## 3. Build script and prerequisites + +- [x] 3.1 `installer/build.ps1`: add `-ext WixToolset.Util.wixext` to the `wix build Bundle.wxs` call, and update the extension-cache comment to name both extensions (proposal: Impact) +- [x] 3.2 `installer/README.md` § Prerequisites: add `wix extension add WixToolset.Util.wixext/5.0.2` with the same pin rationale as the bootstrapper extension, plus a note that the custom theme must be re-derived when the extension version changes (design: Risks) + +## 4. Documentation (`installer/README.md`) + +- [x] 4.1 § Scope semantics, "Same-scope upgrades only": state that the machine PATH precedes the user PATH, so the per-machine copy always wins. Say the bundle now warns when the other scope is present, silent runs only log it, and mixing Chocolatey (per-machine default) with winget (per-user default) is the common way to end up with both (windows-installer: "Bundle warns when the other installation scope is present"; design D3) +- [x] 4.2 New section "Uninstalling and removing settings" (windows-installer: "Settings removal is documented"; design D4). It should cover: + - what uninstall removes and keeps; + - `%APPDATA%\FileCommand` and its three files, with one copy per account for per-machine installs; + - `Remove-Item -Recurse -Force "$env:APPDATA\FileCommand"` and `rmdir /s /q "%APPDATA%\FileCommand"`; + - portable mode in the install directory (files plus the marker remain, so the folder remains); + - `--config-dir`. +- [x] 4.3 Make sure the profile-directory wording matches `config-profile-directory`'s resolution order (`--config-dir`, then portable marker, then `%APPDATA%\FileCommand`) as it stands when this change lands (proposal: Coordination) + +## 5. Verification (Windows test VM) + +- [x] 5.1 `installer/build.ps1` builds all three artifacts cleanly with the Util extension installed. Without it, the bundle step fails with WiX's extension-not-found error (`WIX0144`), which the README prerequisites already explain for the bootstrapper extension (windows-installer "Reproducible installer build") +- [ ] 5.2 Install per-machine, then run the bundle interactively (per-user): the warning appears with the all-users wording, Install completes, and both PATH entries exist. A new shell's `where filecommand` lists the Program Files copy first (windows-installer: "Per-user setup over an existing per-machine install") +- [ ] 5.3 Install per-user, then run `FileCommandSetup.exe InstallScope=perMachine` from an elevated prompt as the same user: the warning appears with the per-account wording, and the install completes (windows-installer: "Per-machine setup over an existing per-user install") +- [ ] 5.4 Clean machine, interactive per-user install: no warning. Same-scope upgrade from version N to N+1 with only that scope present: no warning (windows-installer: "No warning without the other scope") +- [ ] 5.5 Per-machine present, then `FileCommandSetup.exe /quiet /log out.log`: the install completes with no UI, and `out.log` shows `FileCommandPerMachineState` set to 5 (windows-installer: "Silent installs are unaffected") +- [ ] 5.6 Uninstall each scope after running FileCommand once: `%APPDATA%\FileCommand` and its files remain, and a reinstall picks them up (windows-installer: "Uninstall still keeps settings") +- [x] 5.7 `openspec validate installer-scope-coexistence --strict` clean diff --git a/openspec/changes/archive/2026-10-14-ci-and-build-tooling/.openspec.yaml b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-ci-and-build-tooling/design.md b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/design.md new file mode 100644 index 0000000..3da0fe0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/design.md @@ -0,0 +1,210 @@ +# Design: ci-and-build-tooling + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`) unless noted. + +**Repository tooling today.** +- The repository root has no `.github/`, `rustfmt.toml`, `clippy.toml`, `rust-toolchain.toml`, or `.gitattributes`. +- The workspace `Cargo.toml` has `[workspace.package]` (version 0.1.4, edition 2021) and `[workspace.dependencies]`, but no `rust-version`, no `[profile.*]`, and no `[workspace.lints]`. +- `Cargo.lock` is committed. +- The audit environment was `rustc 1.98.1`. + +**Formatting: what config reproduces the code.** I exported the build branch's `crates/` tree with `git archive` into a scratch directory and ran `rustfmt 1.9.0 --check --edition 2021` with candidate configurations: + +| Configuration | Diff hunks | Files with diffs | +|---|---|---| +| defaults (`max_width = 100`) | 2,151 | 86 (matches the audit) | +| `max_width = 150` | 1,142 | — | +| `max_width = 140`, `use_small_heuristics = "Max"` | 242 | — | +| **`max_width = 150`, `use_small_heuristics = "Max"`** | **195** | **44** | +| `max_width = 160`, `use_small_heuristics = "Max"` | 231 | — | + +Line lengths agree. `update.rs` peaks at 148 columns, `panel.rs` at 150, and `app.rs` at 152 (one line). Across all `.rs` files, 1,997 lines exceed 100 columns but only 57 exceed 150. Most of those are long string literals and test assertions, which rustfmt does not break. + +The audit's guess of `max_width = 150` is therefore right, but it is not enough by itself. The code also packs struct literals and call arguments up to the full width, which is `use_small_heuristics = "Max"`. + +The remaining 195 hunks come from rules rustfmt does not let you configure. An example: if one struct-like variant of an enum spans several lines, rustfmt formats every struct-like variant of that enum on several lines. This affects `UiPhase` and `Command`. No configuration reaches zero diffs, so a one-time format commit is unavoidable. With this configuration it is 195 hunks instead of 2,151. + +**Lint state.** The audit counted 26 clippy warnings and no errors, all low-severity. A review re-run of `cargo clippy --workspace --all-targets -- -W clippy::all` on `1bada6f` (rustc 1.98.1) printed 29 distinct warnings, among them `useless_vec` ×4, `cmp_owned` ×3, `unnecessary_lazy_evaluations` ×2, `collapsible_if` ×2, and `upper_case_acronyms` ×2 (one for each of the two hand-written `HANDLE` aliases in `fs_ops/fs.rs`). The exact count does not matter: the baseline fixes every warning the gate's own command reports. `cargo doc --workspace --no-deps` reports 13 warnings, all in `filecommand-core` (confirmed by the same re-run): 3 unresolved links, 7 links to private items, 2 unclosed HTML tags (``, ``), and 1 ambiguous link (`update`, function vs module). + +**`git2` and its features.** `git2 = "0.19"` is used with default features `["ssh", "https", "ssh_key_from_memory"]`. `crates/filecommand-core/src/git_info.rs` uses only `Repository::discover`, `Repository::head().shorthand()`, and `Repository::statuses` with `StatusOptions` (plus `Repository::init` in tests). None of these needs a network transport. + +From the registry sources of `git2 0.19.0` and `libgit2-sys 0.17.0+1.8.1`: +- `ssh` pulls in `libssh2-sys`, a C build. +- `https` pulls in `openssl-sys` and `openssl-probe`, on Unix only (`git2` declares both for Unix other than macOS; `libgit2-sys` declares `openssl-sys` for every Unix). On Windows `https` uses WinHTTP and adds no crate. +- **`url` is an unconditional dependency of `git2 0.19`**, and `libz-sys` is an unconditional dependency of `libgit2-sys`. + +**`build.rs`.** `crates/filecommand-core/build.rs` emits `cargo:rustc-link-lib=advapi32` on Windows. `libgit2-sys 0.17`'s build script links `winhttp`, `rpcrt4`, `ole32`, `crypt32`, and `secur32` on Windows. That list does not depend on any feature and does not include `advapi32`. The libgit2 sources that need `advapi32` (`fs_path.c`, `sysdir.c`, `rand.c`) are compiled whatever the features are. Trimming features therefore does not make the workaround unnecessary. + +**Windows FFI and `unsafe`.** +- `windows-sys 0.59.0` is in `Cargo.lock` and is a direct Windows dependency of `filecommand-tui` (features `Win32_UI_Shell`, `Win32_Foundation`, `Win32_UI_WindowsAndMessaging`), used for `ShellExecuteW`. `windows-sys 0.61.2` is also locked, through newer transitive crates. +- Core has no `windows-sys` dependency. It declares `#[link(name = "kernel32")] extern "system"` blocks in `drives.rs` (`GetLogicalDrives`, `SetThreadErrorMode`, `GetVolumeInformationW`, `GetDiskFreeSpaceExW`, `GlobalMemoryStatusEx` with a hand-written `MemoryStatusEx`) and in `fs_ops/fs.rs`: + - `file_id_via_handle` declares `CreateFileW`, `GetFileInformationByHandle`, and `CloseHandle`, with hand-written `ByHandleFileInformation`, `FileTime`, and `HANDLE`, the last of which triggers clippy `upper_case_acronyms`. + - `stream_enum` declares `FindFirstStreamW`, `FindNextStreamW`, `FindClose`, and `GetLastError`, with a hand-written `Win32FindStreamData` (296 `u16`s). +- The TUI declares `GetLocalTime` with a hand-written `SystemTime` (`clock.rs`). +- There are 11 `unsafe` blocks in the workspace: + - 6 in `drives.rs` + - 2 in `fs_ops/fs.rs` + - 1 in `viewer/byte_source.rs` + - 1 in `clock.rs` + - 1 in `app.rs`, the only one with a `// SAFETY:` comment. + +**README.** +- "Building from source" says only "Requires the Rust toolchain (stable)". +- "Testing" (`README.md:601-611`) says core tests cover "file ops against temp directories". The engine's tests all run against `FakeFs` (audit B-01). + +**Panic strategy.** There is no `panic` setting, so release builds use the default, `unwind`. Two things depend on unwinding: +- The terminal is restored by an RAII guard's `Drop` and by a chained panic hook (`terminal.rs`). +- Worker threads (listing, jobs, queries) are plain `std::thread`s whose panics today end only that thread. + +## Goals / Non-Goals + +**Goals:** + +- Every pull request is checked mechanically on Windows: format, lints, tests, docs, and the declared toolchain floor. +- One committed formatter configuration that matches the existing style, applied once, with the noise of that commit isolated. +- A smaller, faster release binary without changing panic behavior. +- A Linux build that works on a stock machine, best-effort, with no OpenSSL headers. +- No hand-written Windows struct layouts, and a documented invariant on every `unsafe` block, enforced by a lint. +- Docs that build without warnings, and a README that does not claim tests that do not exist. + +**Non-Goals:** + +- Replacing `git2` with `gix`, or removing the `url`/ICU dependency chain. `url` is not optional in `git2 0.19` (see Context), so feature trimming cannot remove it. +- The 100k-entry spike document refresh, which is `streaming-listing-performance`. The README `[mouse]` example order, which is `config-parsing-robustness`. +- Adding the `RealFs` test suite, which is `durable-file-copy` (B-01). Moving `FakeFs` behind `cfg(test)` also belongs there. +- Adding new tests beyond what the gates need. +- `cargo audit` or `cargo deny`, coverage, release automation, code signing in CI, or building the installer in CI. Installer packaging stays in `windows-installer`. +- Committing a `rust-toolchain.toml` to pin contributors' local toolchains. +- Changing the source-formatting conventions themselves beyond what the chosen configuration produces. + +## Decisions + +### D1: GitHub Actions workflow, Windows required, Linux best-effort + +`.github/workflows/ci.yml` runs on `pull_request` (any target branch) and on `push` to `main`. It has three jobs: + +1. **`windows`** (required), on `windows-latest`. It runs, in order: + - `cargo fmt --all -- --check` + - `cargo clippy --workspace --all-targets --locked -- -D warnings` + - `cargo test --workspace --locked` + - `cargo doc --workspace --no-deps --locked` with `RUSTDOCFLAGS="-D warnings"` + + Fast checks run first so a formatting slip fails in seconds. +2. **`msrv`** (required), on `windows-latest`: `cargo check --workspace --all-targets --locked` on the `rust-version` toolchain (D3). +3. **`linux`** (best-effort), on `ubuntu-latest` with `continue-on-error: true`: `cargo build --workspace --locked` and `cargo test --workspace --locked`. It reports results but never blocks a merge. Non-Windows behavior is best-effort per the project charter. This is the first time anything compiles the `cfg(not(windows))` paths. + +Every job uses `--locked`, so CI tests the committed `Cargo.lock`. The toolchain is installed with a toolchain action and cached with a cargo cache action. + +"Required" means a branch-protection rule on `main` that lists the `windows` and `msrv` checks. That is a repository setting the user applies. It is not a file in the repo, and tasks.md lists it as a manual step. + +CI does not change the Git workflow. Nothing reaches `main` without the user's explicit confirmation (CLAUDE.md). CI adds a precondition; it grants no permission. + +*Alternative considered: a floating `stable` toolchain for the lint job.* A new Rust release can add clippy lints that turn `-D warnings` red on an unchanged branch, in the middle of an agent batch. **Rejected.** The `windows` job instead pins the stable version it runs on in one workflow variable (initially the version the clean baseline was verified with). It is bumped by a deliberate PR that also fixes any new lints. + +*Alternative considered: Linux as a required job.* The non-Windows code has never been compiled in CI (audit §9), and the charter calls it best-effort. **Rejected for now.** It can be promoted once it has been green for a while. + +### D2: `rustfmt.toml` = `max_width = 150` plus `use_small_heuristics = "Max"`, then one format commit + +Both options are stable rustfmt options. Measured against the current code (Context), this pair leaves 195 hunks in 44 files, against 2,151 in 86 at the defaults. That is the closest stable configuration found. `newline_style` stays at its default (`Auto`), so working trees checked out with `core.autocrlf = true`, the local setting on the author's machine, pass the check unchanged. + +The reformat lands as **one commit with no logic changes**, made by running `cargo fmt --all` and nothing else. Its hash is then added to a new `.git-blame-ignore-revs`, so `git blame` (and GitHub's blame view) skips it. The commit goes on this change's `build/` branch ahead of the CI workflow commit, so the first CI run is green. + +*Alternative considered: `max_width = 150` alone.* The audit suggested it. It leaves 1,142 hunks, because the code packs struct literals and arguments to full width. **Rejected.** + +*Alternative considered: keep the default width and reformat everything.* That is 2,151 hunks, and every line wider than 100 columns would wrap, which conflicts with the evident house style. **Rejected.** + +### D3: `rust-version` = the lowest toolchain that builds the locked workspace, verified by CI + +The floor goes in `[workspace.package] rust-version` and both crates inherit it with `rust-version.workspace = true`. + +The value is determined empirically during implementation, not guessed. The locked dependencies already require at least **1.88**: `instability 0.3.13`, reached through `ratatui`, and `darling 0.24` declare `rust-version = "1.88"`. The ICU 2.2 crates declare 1.86. Two dev-dependencies, `getrandom 0.4.3` and `idna_adapter 1.2.2`, use edition 2024. Start at 1.88 and run `cargo +1.88 check --workspace --all-targets --locked`. If the workspace's own code uses a newer standard-library API, raise the floor to the first version that passes. The `msrv` CI job (D1) keeps the declaration honest from then on. + +### D4: `[profile.release]` with thin LTO, one codegen unit, stripped symbols, and `panic = "unwind"` + +```toml +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "symbols" +# panic stays "unwind" (the default): see design D4. +``` + +Thin LTO and a single codegen unit give a smaller, faster binary at a modest cost in release build time; on Windows they are where the size gain comes from. `strip = "symbols"` matters mostly for the best-effort ELF and Mach-O builds, where it drops the symbol table. An MSVC `.exe` carries no COFF symbol table to begin with, and its debug info lives in the separate `.pdb`. Since Rust 1.79, `strip` no longer suppresses that `.pdb` on MSVC (rustc book, `-C strip`), and the installer does not ship it. + +`panic` stays `"unwind"`, with a comment saying why: +- Under `abort`, a panic on any worker thread would kill the whole process instead of ending that thread. +- The terminal-restoring `Drop` guard would never run on the main thread. +- `process-lifecycle-hardening` (R-12) relies on `catch_unwind` in worker bodies. + +The release-profile change is checked by building `--release` and running the `panic_restoration` integration test (task 4.4). + +*Alternative considered: `lto = "fat"`.* It gives a marginally smaller binary at a noticeably slower build. Thin LTO with one codegen unit captures most of the gain. **Rejected.** + +### D5: `git2` with `default-features = false`; keep the `advapi32` workaround + +`[workspace.dependencies] git2 = { version = "0.19", default-features = false }`. This removes: +- `libssh2-sys` on every platform, a C build; +- `openssl-sys` and `openssl-probe` on Unix, which is what made Linux builds need OpenSSL headers. + +It does **not** remove `url`/`idna`/ICU or `libz-sys`. Both are unconditional (Context). This corrects the audit, which listed URL/ICU among the default-feature costs. + +The `git_info` tests, which create real repositories with `Repository::init`, and the git-info behavior are unchanged, because no remote operation is used. + +`build.rs` stays. Its comment is updated to say the link list of `libgit2-sys 0.17` does not depend on features, so the workaround is needed with or without `default-features`. As a check, task 3.3 removes the `advapi32` line once on the build branch and confirms that `cargo test` fails to link without it, then restores it. + +### D6: Windows FFI through `windows-sys`, SAFETY comments, and a lint + +- `windows-sys` moves to `[workspace.dependencies]` at `0.59`, the version already locked for the TUI, so both crates share one copy. The feature list is the union of what both crates need: + - `Win32_Foundation`, which is already used (`CloseHandle`, `GetLastError`, `INVALID_HANDLE_VALUE`, `FILETIME`, `SYSTEMTIME`); + - `Win32_Storage_FileSystem`, for `CreateFileW`, `GetFileInformationByHandle`, `FindFirstStreamW`, `FindNextStreamW`, `FindClose`, `GetLogicalDrives`, `GetVolumeInformationW`, and `GetDiskFreeSpaceExW`; + - `Win32_Security`, because `windows-sys 0.59` compiles `CreateFileW` only with this feature (its `lpSecurityAttributes` parameter is a `*const SECURITY_ATTRIBUTES`; the binding is `#[cfg(feature = "Win32_Security")]`); + - `Win32_System_Diagnostics_Debug`, for `SetThreadErrorMode` and `SEM_FAILCRITICALERRORS`; + - `Win32_System_SystemInformation`, for `GlobalMemoryStatusEx`, `MEMORYSTATUSEX`, and `GetLocalTime`; + - plus the TUI's existing `Win32_UI_Shell` and `Win32_UI_WindowsAndMessaging`; + - plus any feature another audit change has added by the time this one lands, for example `Win32_System_Console` (`process-lifecycle-hardening`) and `Win32_System_Com` (`offload-blocking-io`). The list is the union at landing time, not a fixed set. + + The module of each symbol above was checked against the `windows-sys 0.59.0` sources in the cargo registry; it is re-confirmed by compilation while implementing. +- Core adds `[target.'cfg(windows)'.dependencies] windows-sys = { workspace = true }`. Non-Windows builds pull in nothing new. +- The hand-written `extern` blocks, struct layouts (`MemoryStatusEx`, `ByHandleFileInformation`, `FileTime`, `Win32FindStreamData`, `SystemTime`), constants, and the `HANDLE`/`c_void` aliases are deleted in favor of the `windows-sys` definitions. Behavior does not change. The same calls are made with the same arguments: share flags, `FILE_FLAG_BACKUP_SEMANTICS`, the optional `FILE_FLAG_OPEN_REPARSE_POINT`, the 261-`u16` volume-name buffer, and `SEM_FAILCRITICALERRORS`. +- The rule targets *declarations*: `extern "system" { … }` blocks that write out a Windows prototype, and `#[repr(C)]` copies of Windows structures. A Rust *callback* that Windows calls, defined as `extern "system" fn …` (a console control handler from `process-lifecycle-hardening`, a `CopyFileExW` progress routine from `durable-file-copy`), stays allowed. Its signature is checked against the `windows-sys` type it is passed as (`PHANDLER_ROUTINE`, `LPPROGRESS_ROUTINE`). +- Every remaining `unsafe` block gets a `// SAFETY:` comment stating its invariant. The audit's appendix gives the invariants: buffers are sized and their lengths passed, wide strings are NUL-terminated and outlive the call, handles are closed on every path, `dwLength` is preset, and out-pointers are valid. +- `[workspace.lints.clippy] undocumented_unsafe_blocks = "warn"` with `[lints] workspace = true` in both crates. Together with `-D warnings` in CI, a new undocumented `unsafe` block fails the PR. This is the only lint added at workspace level. Broader lint policy is out of scope. + +*Alternative considered: the `windows` crate (safe-ish wrappers).* It is heavier, it would be a second Microsoft binding crate next to the TUI's `windows-sys`, and its COM-oriented API buys nothing for these calls. **Rejected.** + +### D7: Clean baseline first, then the gates + +The gates go live only once they pass: +- Every clippy warning (26 in the audit's count, 29 in the review re-run; see Context) is fixed in place. Each fix is mechanical. `large_enum_variant` on `editor::LoadResult` is fixed by boxing the large variant. `too_many_arguments` may be allowed locally with a reason when a renderer genuinely takes that many inputs. The existing `#[allow(clippy::too_many_arguments)]` in `views/panel.rs` is the precedent. +- The rustdoc warnings are fixed by correcting paths, by turning links to private items into plain code spans (public docs must not point at private items), by escaping `` and `` as code, and by disambiguating `update` (`fn@update` or `mod@update`). `--document-private-items` is not used, so the published docs stay public-API docs. + +Commit order on the build branch: +1. the format commit; +2. the baseline fixes; +3. manifests and FFI; +4. the CI workflow; +5. `.git-blame-ignore-revs` with the format commit's hash. + +### D8: README corrections + +- **"Building from source"** names the minimum Rust version (from D3). It notes that Linux and macOS builds are best-effort and need only a C compiler (for the vendored libgit2 and zlib), not OpenSSL headers. +- **"Testing"** describes what the suites actually cover: core unit and property tests, mostly against the in-memory `FakeFs`; the git-info tests against fixture repositories created in temporary directories; and the TUI snapshot tests. It names the commands CI runs, so a contributor can reproduce CI locally. If `durable-file-copy`'s `RealFs` suite has landed by then, the section says so. + +## Risks / Trade-offs + +- [The format commit conflicts with every open `build/` branch.] → Land it when few branches are open (proposal: Coordination). It contains no logic, so a branch that conflicts can resolve by re-running `cargo fmt` after rebasing. `.git-blame-ignore-revs` keeps blame useful. +- [A pinned CI toolchain falls behind local stable, so contributors on a newer stable may see lints CI does not.] → Bump it deliberately. Local `cargo clippy` warnings are advisory until the bump. +- [Release builds take longer with thin LTO and one codegen unit.] → Only `--release` is affected. `installer/build.ps1` builds release once per installer build. +- [The `msrv` job adds a second Windows build per PR.] → It is `cargo check` only (no codegen or tests) and cached. +- [`windows-sys` feature names are wrong for a symbol.] → Compilation fails loudly. There is no undefined-behavior risk, which is the point of the move. +- [The Linux job may be red from day one if the `cfg(not(windows))` paths do not compile.] → It is allowed to fail. A red result is useful information, not a blocker. + +## Migration Plan + +Contributors run `cargo fmt --all` before committing, as CI now checks it. Branches opened before the format commit rebase and re-run `cargo fmt`. The `main` branch-protection rule (required `windows` and `msrv` checks) is enabled by the user after the workflow's first green run on the PR. There are no runtime or data changes. + +## Open Questions + +None. The `rust-version` value is fixed by the empirical procedure in D3 during implementation. diff --git a/openspec/changes/archive/2026-10-14-ci-and-build-tooling/proposal.md b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/proposal.md new file mode 100644 index 0000000..5d5696f --- /dev/null +++ b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/proposal.md @@ -0,0 +1,76 @@ +# Change: ci-and-build-tooling + +## Why + +FileCommand is built mostly in agent-driven batches on `build/` branches, and nothing mechanical checks a branch before it reaches `main`. The 2026-10-12 audit scored Tooling/CI 1 out of 5: + +- **B-03 (Medium): no CI, failing format check, no lint gates.** + - The repository has no `.github/` workflow, no `rustfmt.toml`, no crate lint configuration, no `rust-version`, and no `[profile.release]`. + - `cargo fmt --all -- --check` fails with 2,151 hunks in 86 files, because the code is written to about 150 columns with a formatter configuration that was never committed. + - Clippy reports 26 warnings. + - A PR that breaks the build or the tests can be merged without anyone noticing, and every contributor or agent run reformats code differently. +- **B-04 (Medium): oversized `git2`.** `git2` is built with its default features (SSH, HTTPS), although `git_info.rs` only uses `Repository::discover`, `head()`, and `statuses()`. This compiles libssh2 from C on every clean build. On Linux it also pulls in `openssl-sys`, which needs OpenSSL development headers, so the "best-effort" Linux build *fails* on a stock machine. +- **B-07 (Low): hand-written Windows FFI.** Core hand-declares `extern "system"` prototypes and struct layouts for `CreateFileW`, `GetFileInformationByHandle`, `FindFirstStreamW`, `GetVolumeInformationW`, and others, although `windows-sys` is already in the lockfile and used by the TUI. One wrong field type would be undefined behavior with no help from the compiler. Only 1 of the 11 `unsafe` blocks has a `// SAFETY:` comment. +- **B-09 (Low, rustdoc and README part): stale documentation.** `cargo doc` reports 13 warnings: broken links, links to private items, unclosed HTML tags, and an ambiguous link. The README's Testing section says file operations are tested "against temp directories". That is not true of the real filesystem engine. + +## What Changes + +- **CI workflow.** A GitHub Actions workflow runs on every pull request and every push to `main`. + - Required job on `windows-latest`: format check, clippy with `-D warnings` on all targets, the full test suite, and rustdoc with `-D warnings`. + - Required job: `cargo check` on the declared minimum toolchain. + - Best-effort job on `ubuntu-latest`, allowed to fail without blocking. +- **Committed formatting configuration.** A `rustfmt.toml` that reproduces the existing style as closely as rustfmt allows (`max_width = 150`, `use_small_heuristics = "Max"`). A one-time `cargo fmt --all` follows as its own commit with no logic changes, and it is recorded in `.git-blame-ignore-revs`. +- **Toolchain floor and release profile.** + - `rust-version` is set in `[workspace.package]` and inherited by both crates. + - `[profile.release]` sets `lto = "thin"`, `codegen-units = 1`, and `strip = "symbols"`. + - `panic` stays `"unwind"`, because worker-thread isolation and the terminal-restoring guard rely on unwinding. +- **Trimmed `git2`.** The dependency is declared with `default-features = false`. libssh2 and, on Unix, OpenSSL drop out of the build, so Linux no longer needs OpenSSL headers. The `advapi32` link workaround in `crates/filecommand-core/build.rs` stays, because `libgit2-sys` still does not declare it. +- **Windows FFI through `windows-sys`.** Core's hand-declared prototypes and layouts (`drives.rs`, `fs_ops/fs.rs`) and the TUI's `GetLocalTime` (`clock.rs`) move to `windows-sys` as a Windows-only dependency. Every `unsafe` block gets a `// SAFETY:` comment, and a workspace lint (`clippy::undocumented_unsafe_blocks`) keeps it that way. +- **Clean baseline.** Fix every clippy warning (26 by the audit's count) and the 13 rustdoc warnings so the new gates start green. Correct the README's Testing claim, and document the toolchain floor and the Linux build in "Building from source". + +## Capabilities + +### New Capabilities + +- `build-tooling`: how the repository is checked and built. It covers the CI gates on pull requests, the committed formatter configuration, the declared toolchain floor and release profile, the `git2` feature set, and the rule that Windows FFI goes through `windows-sys` with a documented invariant on every `unsafe` block. Installer packaging stays in `windows-installer` and is not duplicated here. + +### Modified Capabilities + +None. + +## Impact + +- **New files:** + - `.github/workflows/ci.yml` + - `rustfmt.toml` + - `.git-blame-ignore-revs` +- **`Cargo.toml` (workspace):** + - `[workspace.package] rust-version` + - `[profile.release]` + - `[workspace.dependencies]`: `git2 = { version = "0.19", default-features = false }` and `windows-sys = { version = "0.59", features = [...] }` + - `[workspace.lints.clippy] undocumented_unsafe_blocks = "warn"` +- **Crate manifests:** `crates/filecommand-core/Cargo.toml` adds `rust-version.workspace`, `[lints] workspace = true`, and `[target.'cfg(windows)'.dependencies] windows-sys`. `crates/filecommand-tui/Cargo.toml` adds the same two keys, and its `windows-sys` entry switches to the workspace dependency. +- **`Cargo.lock`:** `libssh2-sys`, `openssl-sys`, and `openssl-probe` are no longer reachable. +- **Windows FFI:** + - `crates/filecommand-core/src/drives.rs` (`platform` module: `GetLogicalDrives`, `SetThreadErrorMode`, `GetVolumeInformationW`, `GetDiskFreeSpaceExW`, `GlobalMemoryStatusEx`, and the hand-written `MemoryStatusEx`) + - `crates/filecommand-core/src/fs_ops/fs.rs` (`file_id_via_handle`'s `CreateFileW`, `GetFileInformationByHandle`, and `CloseHandle`, the hand-written `ByHandleFileInformation` and `HANDLE`, and `stream_enum`'s `FindFirstStreamW`, `FindNextStreamW`, `FindClose`, and `GetLastError` with `Win32FindStreamData`) + - `crates/filecommand-tui/src/clock.rs` (`GetLocalTime` and `SystemTime`) + - `crates/filecommand-tui/src/app.rs` (`ShellExecuteW`, which already has a SAFETY comment) +- **Code formatting:** every `.rs` file is reformatted once, in a dedicated commit. +- **Warning fixes:** + - The clippy warnings are spread across both crates. Among them are `useless_vec`, `cmp_owned`, `large_enum_variant` on `editor::LoadResult`, `too_many_arguments`, and `upper_case_acronyms` on the hand-written `HANDLE`, which disappears with `windows-sys`. + - The rustdoc warnings include the links to `Theme::resolve`, `close_tab`, `switch_tab`, `update_impl`, `drag_allowed`, `State::next_request_id`, and `Injection`, the `` and `` tags, and the ambiguous `update`. +- **`README.md`:** "Building from source" (toolchain floor, Linux notes) and "Testing" (accurate scope, local CI commands). +- **Not touched:** `installer/` (covered by `windows-installer`). + +**Audit findings:** B-03, B-04, B-07, B-09 (rustdoc and README-accuracy parts only) + +**Coordination:** +- **Landing order.** Every other audit proposal touches Rust sources, so the one-time `cargo fmt` commit conflicts with any `build/` branch open at the same time. Land this change, or at least its format commit, **when few build branches are open**. A good point is right after the current Critical and High batch merges, before the Low and refactor batch starts. Branches opened later start from formatted code. +- `durable-file-copy` (R-07, R-08, R-14, B-01) adds the `RealFs` test suite on temporary directories. The README Testing claim is corrected here to describe whatever suites exist when this change lands. If `durable-file-copy` has already landed, the "file operations against temp directories" wording becomes true and stays. +- **Windows FFI added by other changes.** `durable-file-copy` (R-14) adds `CopyFileExW` and a progress-routine callback in core, hand-declared if it lands first. Whichever of the two lands second moves that prototype to `windows-sys` (`Win32_Storage_FileSystem`). `editor-encoding-safety` (R-02) adds a core `windows-sys` entry for `ReplaceFileW`; this change converts that entry to the workspace dependency rather than adding a second one. `process-lifecycle-hardening` (R-10, `Win32_System_Console`) and `offload-blocking-io` (P-09, `Win32_System_Com`) add TUI features, which join the workspace feature union. Callback definitions such as the console control handler are allowed under the no-hand-declared-prototypes rule. +- `viewer-mapping-safety` (R-20) removes `memmap2` and the `unsafe { Mmap::map }` block. If it lands first, this change has one `unsafe` block fewer to document. If this change lands first, the block gets an interim SAFETY comment. +- `streaming-listing-performance` (P-01 to P-04) owns refreshing the 100k-entry spike document (`docs/spikes/c-100k-entry-render-benchmark.md`), the other part of B-09. It is not touched here. +- `config-parsing-robustness` (R-15, R-16, B-05) owns the misordered `[mouse]` example in the README configuration section. It is not touched here. +- `editor-encoding-safety` (R-02) reshapes editor loading. The clippy `large_enum_variant` fix on `editor::LoadResult` must be rebased onto whichever of the two lands second. +- `reducer-overlay-structure` (B-02, B-08) moves code out of `update.rs`, which contains the private items that two of the rustdoc warnings point at (`update_impl`, `drag_allowed`). Whichever lands second re-checks `cargo doc` under `-D warnings`. CI then enforces it. diff --git a/openspec/changes/archive/2026-10-14-ci-and-build-tooling/specs/build-tooling/spec.md b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/specs/build-tooling/spec.md new file mode 100644 index 0000000..c4c894e --- /dev/null +++ b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/specs/build-tooling/spec.md @@ -0,0 +1,131 @@ +## ADDED Requirements + +### Requirement: Continuous integration gates every pull request + +The repository SHALL contain a GitHub Actions workflow that runs on every pull request and on every push to `main`. A required job on `windows-latest` MUST run, against the committed `Cargo.lock`: +- `cargo fmt --all -- --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings` + +The job MUST fail if any of these steps fails. A job on `ubuntu-latest` SHALL build and test the workspace on a best-effort basis and MUST be marked allowed-to-fail, so that its result is reported but never blocks a merge. The toolchain used by the required Windows job SHALL be pinned to a specific stable release in the workflow and changed only deliberately. The README SHALL list the commands the required job runs, so a contributor can reproduce it locally. + +#### Scenario: A formatting slip fails the pull request + +- **WHEN** a pull request changes a `.rs` file so that `cargo fmt --all -- --check` reports a diff +- **THEN** the required Windows job fails and the pull request shows a failing check + +#### Scenario: A new clippy warning fails the pull request + +- **WHEN** a pull request introduces code that produces any clippy warning on any target, tests included +- **THEN** the required Windows job fails + +#### Scenario: A failing test fails the pull request + +- **WHEN** any test in the workspace fails on `windows-latest` +- **THEN** the required Windows job fails + +#### Scenario: A broken doc link fails the pull request + +- **WHEN** a doc comment links to an item that does not exist or is private +- **THEN** `cargo doc` with `-D warnings` fails and the required Windows job fails + +#### Scenario: Linux results never block a merge + +- **WHEN** the `ubuntu-latest` job fails to build or test +- **THEN** its failure is visible on the pull request +- **AND** the pull request's required checks are unaffected + +#### Scenario: Clean baseline + +- **WHEN** the workflow first runs on the branch that introduces it +- **THEN** every step of the required Windows job passes, with no formatting diffs, clippy warnings, test failures, or rustdoc warnings + +### Requirement: Formatting configuration is committed + +The repository SHALL commit a `rustfmt.toml` at its root that reproduces the existing source style as closely as stable rustfmt options allow: `max_width = 150` and `use_small_heuristics = "Max"`. The whole workspace SHALL be formatted with it once, in a commit that contains only formatting changes, and that commit's hash SHALL be listed in a committed `.git-blame-ignore-revs` file. After that commit, `cargo fmt --all -- --check` MUST report no differences on `main`. + +#### Scenario: Format check is clean on main + +- **WHEN** `cargo fmt --all -- --check` runs on a fresh checkout of `main` +- **THEN** it reports no differences and exits successfully + +#### Scenario: The reformat commit is isolated + +- **WHEN** the reformat commit is inspected +- **THEN** it changes only whitespace, line breaks, and other rustfmt output, with no change to any token's meaning +- **AND** `git blame` configured with `.git-blame-ignore-revs` attributes lines to their pre-reformat authors + +#### Scenario: CRLF working trees pass + +- **WHEN** the repository is checked out on Windows with `core.autocrlf = true` and `cargo fmt --all -- --check` runs +- **THEN** it reports no differences caused by line endings + +### Requirement: Toolchain floor and release profile are declared + +The workspace SHALL declare its minimum supported Rust version as `rust-version` in `[workspace.package]`, inherited by both crates. The declared value MUST be a toolchain on which the workspace, including all targets, builds against the committed `Cargo.lock`, and CI MUST check this on every pull request. The workspace SHALL define `[profile.release]` with `lto = "thin"`, `codegen-units = 1`, and `strip = "symbols"`. The release profile MUST keep the `unwind` panic strategy, because terminal restoration and worker-thread panic isolation depend on unwinding. The README's build instructions SHALL name the minimum Rust version. + +#### Scenario: Older toolchain is rejected clearly + +- **WHEN** a contributor builds with a toolchain older than the declared `rust-version` +- **THEN** cargo stops with its standard message naming the required version, instead of failing with a compile error deep in the code + +#### Scenario: The floor is verified + +- **WHEN** a pull request uses a standard-library API newer than the declared `rust-version` +- **THEN** the CI job that checks the workspace on the declared toolchain fails + +#### Scenario: Release binary is optimized and stripped + +- **WHEN** `cargo build --release` produces `filecommand.exe` +- **THEN** it is built with thin LTO, a single codegen unit, and `strip = "symbols"` +- **AND** on targets whose binaries embed a symbol table (ELF, Mach-O), the release binary carries none + +#### Scenario: Panics still unwind in release builds + +- **WHEN** a release build panics on the main thread while the terminal is in raw mode on the alternate screen +- **THEN** the terminal is restored before the panic report is printed, exactly as in a debug build + +### Requirement: libgit2 is built with only the features local status needs + +The `git2` dependency SHALL be declared with `default-features = false`, enabling no network transport (SSH or HTTPS), because git-info reads only local repository state. The Linux build MUST NOT require OpenSSL development headers. Git-info behavior SHALL be unchanged: branch name and per-entry status on Windows and elsewhere. + +#### Scenario: No SSH or OpenSSL in the dependency graph + +- **WHEN** the resolved dependency graph of `filecommand-core` is inspected (for example with `cargo tree -p filecommand-core`) +- **THEN** `libssh2-sys`, `openssl-sys`, and `openssl-probe` do not appear in it + +#### Scenario: Linux builds without OpenSSL headers + +- **WHEN** the workspace is built on an Ubuntu machine with a C compiler but without the OpenSSL development package +- **THEN** the build does not fail for lack of OpenSSL + +#### Scenario: Git info still works + +- **WHEN** a panel shows a directory inside a git working tree with a modified, a staged, and an untracked file +- **THEN** the branch name and the three status markers are reported exactly as before + +### Requirement: Windows FFI uses windows-sys and every unsafe block documents its invariant + +All Windows API calls in both crates SHALL use the prototypes, constants, and struct definitions from the `windows-sys` crate, declared as a Windows-only dependency. The workspace MUST NOT declare its own Windows prototypes (`extern "system" { … }` declaration blocks) or `#[repr(C)]` copies of Windows structures. Rust callback functions that Windows invokes, defined with the `extern "system"` ABI (for example a console control handler or a copy-progress routine), are permitted; their signatures SHALL match the corresponding `windows-sys` function-pointer type. Every `unsafe` block in the workspace MUST be immediately preceded by a `// SAFETY:` comment that states the invariant making it sound, and the workspace SHALL enable `clippy::undocumented_unsafe_blocks` so that CI rejects an undocumented one. + +#### Scenario: No hand-declared Windows prototypes + +- **WHEN** the workspace sources are searched for `extern "system" { … }` declaration blocks or `#[repr(C)]` structs mirroring Windows types +- **THEN** none are found, and drive enumeration, volume information, free space, memory status, file identity, alternate-data-stream enumeration, and local time all call through `windows-sys` +- **AND** any `extern "system" fn` found is a callback definition passed to a Windows API, not a prototype + +#### Scenario: Non-Windows builds do not pull in windows-sys + +- **WHEN** the workspace is built for a non-Windows target +- **THEN** `windows-sys` is not compiled as a dependency of either crate + +#### Scenario: Undocumented unsafe fails CI + +- **WHEN** a pull request adds an `unsafe` block without a preceding `// SAFETY:` comment +- **THEN** clippy reports `undocumented_unsafe_blocks` and the required Windows job fails + +#### Scenario: Behavior is unchanged + +- **WHEN** the drive-select dialog, the Info panel, a copy that preserves alternate data streams, and a case-only rename run on Windows after the migration +- **THEN** they produce the same results as before it diff --git a/openspec/changes/archive/2026-10-14-ci-and-build-tooling/tasks.md b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/tasks.md new file mode 100644 index 0000000..11273f3 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-ci-and-build-tooling/tasks.md @@ -0,0 +1,66 @@ +# Tasks: ci-and-build-tooling + +Commit order matters (design D7): format, then baseline fixes, then manifests and FFI, then the workflow, then blame-ignore. Start this branch when few other `build/` branches are open (proposal: Coordination). + +## 1. Formatting + +- [x] 1.1 Add `rustfmt.toml` at the repo root with `max_width = 150` and `use_small_heuristics = "Max"`, and nothing else (build-tooling: "Formatting configuration is committed"; design D2) +- [x] 1.2 Run `cargo fmt --all` and commit the result **alone**, with no other edits in the commit. Expect about 195 hunks in about 44 files when starting from `1bada6f`, more if other branches have merged since (design D2) +- [x] 1.3 Verify that the reformat commit leaves `cargo test --workspace` green and the insta snapshots unchanged (build-tooling: "The reformat commit is isolated") + +## 2. Clean baseline + +- [x] 2.1 Fix every warning from `cargo clippy --workspace --all-targets` (26 in the audit, 29 distinct in the review re-run), among them `useless_vec`, `default_constructed_unit_structs`, `collapsible_str_replace`, `cmp_owned`, `io_other_error`, `field_reassign_with_default`, `items_after_test_module`, `empty_line_after_doc_comments`, and the test-only `unused_variables`. Box the large variant of `editor::LoadResult` for `large_enum_variant`. Allow `too_many_arguments` locally, with a reason comment, only where a renderer genuinely needs the inputs. `upper_case_acronyms` goes away in 3.6 (build-tooling: "Clean baseline"; design D7) +- [x] 2.2 Fix the 13 rustdoc warnings from `cargo doc --workspace --no-deps`: + - correct the unresolved links (`Theme::resolve`, `close_tab`, `switch_tab`); + - turn links to private items (`update_impl`, `drag_allowed`, `State::next_request_id`, `Injection`, and the rest) into plain code spans; + - wrap `` and `` in backticks; + - disambiguate `update` with `fn@` or `mod@`. + + (build-tooling: "A broken doc link fails the pull request"; design D7) +- [x] 2.3 Confirm that `cargo clippy --workspace --all-targets -- -D warnings` and `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` both pass locally + +## 3. Manifests, dependencies, and FFI + +- [x] 3.1 Workspace `Cargo.toml`: add `rust-version` to `[workspace.package]`, and add `rust-version.workspace = true` to both crate manifests. Set the value by running `cargo +1.88 check --workspace --all-targets --locked` and raising it to the first toolchain that passes (build-tooling: "Toolchain floor and release profile are declared"; design D3) +- [x] 3.2 Workspace `Cargo.toml`: add `[profile.release]` with `lto = "thin"`, `codegen-units = 1`, `strip = "symbols"`, and a comment explaining why `panic` stays `"unwind"` (design D4) +- [x] 3.3 Change `git2` in `[workspace.dependencies]` to `{ version = "0.19", default-features = false }` and refresh `Cargo.lock`. Confirm that `cargo tree -p filecommand-core` shows no `libssh2-sys`, `openssl-sys`, or `openssl-probe`. Check that `crates/filecommand-core/build.rs` is still needed: remove its `advapi32` line once, confirm `cargo test` fails to link, restore it, and update its comment to say the link list of `libgit2-sys 0.17` does not depend on features (build-tooling: "libgit2 is built with only the features local status needs"; design D5) +- [x] 3.4 Move `windows-sys` to `[workspace.dependencies]` at `0.59` with the union of features (`Win32_Foundation`, `Win32_Storage_FileSystem`, `Win32_Security` (required for `CreateFileW`), `Win32_System_Diagnostics_Debug`, `Win32_System_SystemInformation`, `Win32_UI_Shell`, `Win32_UI_WindowsAndMessaging`, plus any feature other landed changes have added, e.g. `Win32_System_Console`, `Win32_System_Com`). Use it from `[target.'cfg(windows)'.dependencies]` in both crates; if `editor-encoding-safety` already added a core `windows-sys` entry, convert that entry instead of adding a second one (design D6) + - Scope note: this task was designed against `build/open-proposals-2026-10` (`1bada6f`). On the current branch the TUI contains no `ShellExecuteW`/`windows-sys` FFI yet — that code arrives via the sibling change `open-with-associated-app` (and `offload-blocking-io` adds the COM init). The TUI part of this task applies only if one of those has landed first; otherwise there is nothing to migrate and the task is satisfied by the other `extern "system"` migrations in this section (3.5 `crates/filecommand-core/src/drives.rs:145`, 3.6 `fs_ops/fs.rs:442` and `:518`, 3.7 `crates/filecommand-tui/src/clock.rs:63`). See design: Coordination. +- [x] 3.5 `crates/filecommand-core/src/drives.rs`: replace the `extern "system"` block and `MemoryStatusEx` with `windows-sys` (`GetLogicalDrives`, `SetThreadErrorMode`, `SEM_FAILCRITICALERRORS`, `GetVolumeInformationW`, `GetDiskFreeSpaceExW`, `GlobalMemoryStatusEx`, `MEMORYSTATUSEX`), keeping identical arguments and buffer sizes (build-tooling: "Windows FFI uses windows-sys and every unsafe block documents its invariant") +- [x] 3.6 `crates/filecommand-core/src/fs_ops/fs.rs`: replace the declarations in `file_id_via_handle` (`CreateFileW`, `GetFileInformationByHandle`, `CloseHandle`, `BY_HANDLE_FILE_INFORMATION`, the share and flag constants, `INVALID_HANDLE_VALUE`) and in `stream_enum` (`FindFirstStreamW`, `FindNextStreamW`, `FindClose`, `GetLastError`, `WIN32_FIND_STREAM_DATA`, `FindStreamInfoStandard`, the error constants). Delete the hand-written `HANDLE`, `c_void`, `FileTime`, and struct copies (design D6) +- [x] 3.7 `crates/filecommand-tui/src/clock.rs`: replace `GetLocalTime` and the hand-written `SystemTime` with `windows-sys` `GetLocalTime` and `SYSTEMTIME` (design D6) +- [x] 3.8 Add a `// SAFETY:` comment, stating the invariant from the audit appendix, to every remaining `unsafe` block in `drives.rs`, `fs_ops/fs.rs`, and `clock.rs`. Keep `app.rs`'s existing one. If `viewer-mapping-safety` has not landed yet, give `viewer/byte_source.rs`'s `Mmap::map` block an interim SAFETY comment stating its platform limits (design D6; proposal: Coordination) +- [x] 3.9 Add `[workspace.lints.clippy] undocumented_unsafe_blocks = "warn"` to the workspace and `[lints] workspace = true` to both crates, then confirm clippy is clean (build-tooling: "Undocumented unsafe fails CI") +- [x] 3.10 Confirm that a search of the workspace for `extern "system" {` declaration blocks and Windows-mirroring `#[repr(C)]` structs finds nothing. Any `extern "system" fn` left is a callback (e.g. a console control handler or a `CopyFileExW` progress routine) whose signature matches its `windows-sys` function-pointer type. If `durable-file-copy` landed first with a hand-declared `CopyFileExW`, move it to `windows-sys` here (build-tooling: "No hand-declared Windows prototypes") + +## 4. Behavior checks for the dependency and FFI changes + +- [x] 4.1 Existing `git_info` tests pass unchanged (branch, modified, staged, untracked, pathspec scoping) (build-tooling: "Git info still works") +- [x] 4.2 Existing drive, volume, and info tests pass. Manual on Windows: Alt+F1 lists drives with labels, and the Info panel shows free space, the serial, and available memory (build-tooling: "Behavior is unchanged") +- [x] 4.3 Existing `fs_ops` identity and ADS tests pass. Manual on Windows: copy a file with a `Zone.Identifier` stream and confirm the stream is copied, then do a case-only rename (build-tooling: "Behavior is unchanged") +- [x] 4.4 Build `--release`, run `cargo test --release -p filecommand-tui --test panic_restoration`, and confirm that `filecommand.exe` is smaller than before the profile change (build-tooling: "Panics still unwind in release builds", "Release binary is optimized and stripped") + +## 5. CI workflow + +- [x] 5.1 Add `.github/workflows/ci.yml` with three jobs (build-tooling: "Continuous integration gates every pull request"; design D1): + - `windows` on `windows-latest`, pinned stable toolchain with `rustfmt` and `clippy`, cargo cache, running fmt, then clippy `-D warnings`, then test, then doc with `RUSTDOCFLAGS=-D warnings`, all with `--locked`; + - `msrv` on `windows-latest`, `cargo check --workspace --all-targets --locked` on the `rust-version` toolchain; + - `linux` on `ubuntu-latest` with `continue-on-error: true`, build and test `--locked`. +- [x] 5.2 Trigger the workflow on `pull_request` and on `push` to `main`, and pin the stable toolchain version in a single workflow-level variable (design D1) +- [ ] 5.3 Open the PR and confirm the first run is green on `windows` and `msrv`. Record the `linux` result in the PR description, whatever it is (build-tooling: "Clean baseline", "Linux results never block a merge") +- [ ] 5.4 Manual (user): after the first green run, enable branch protection on `main` requiring the `windows` and `msrv` checks (design: Migration Plan) + +## 6. Blame and docs + +- [x] 6.1 Add `.git-blame-ignore-revs` listing the reformat commit's full hash, with a comment line naming the change (build-tooling: "Formatting configuration is committed") +- [x] 6.2 README "Building from source": name the minimum Rust version, and note that non-Windows builds are best-effort and need a C compiler but no OpenSSL headers (build-tooling: "Toolchain floor and release profile are declared"; design D8) +- [x] 6.3 README "Testing": replace "file ops against temp directories" with an accurate description (FakeFs-based engine tests, git-info tests on temporary repositories, TUI snapshot tests, plus the `RealFs` suite only if `durable-file-copy` has landed). List the four commands of the required CI job (build-tooling: "Continuous integration gates every pull request"; design D8) + +## 7. Verify + +- [x] 7.1 `cargo fmt --all -- --check` clean +- [x] 7.2 `cargo clippy --workspace --all-targets -- -D warnings` clean +- [x] 7.3 `cargo test --workspace` green +- [x] 7.4 `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` clean +- [x] 7.5 `openspec validate ci-and-build-tooling --strict` clean diff --git a/openspec/changes/archive/2026-10-14-config-parsing-robustness/.openspec.yaml b/openspec/changes/archive/2026-10-14-config-parsing-robustness/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-config-parsing-robustness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-config-parsing-robustness/design.md b/openspec/changes/archive/2026-10-14-config-parsing-robustness/design.md new file mode 100644 index 0000000..ae2ea3c --- /dev/null +++ b/openspec/changes/archive/2026-10-14-config-parsing-robustness/design.md @@ -0,0 +1,240 @@ +# Design: config-parsing-robustness + +## Context + +This was verified against `build/open-proposals-2026-10` at `1bada6f`. All code is in `crates/filecommand-core/src/config.rs` unless noted. + +### `config.toml` (`parse`, `load`) + +`parse` handles each line as follows: + +- It walks `input.lines()` and `trim()`s each line. +- It skips blank lines and lines that *start* with `#`. +- It treats `[...]` as a table header and sets `in_mouse_table` only for `[mouse]`. +- Otherwise it splits on the first `=`. +- Under `[mouse]`, only `enabled` is read; every other key is dropped silently. That is why the README example (`README.md` lines 152–164: `[mouse]` / `enabled = true` / then all ten `key.*` lines) loses every binding. +- Unknown keys fall into `_ => {}`. + +Values are read without any comment handling: + +- `parse_bool` matches exactly `"true"`/`"false"`. +- `parse_string` strips one pair of matching outer quotes and returns the inner text **verbatim**. Backslashes are not escapes, so `editor = "C:\Tools\ed.exe"` works today. +- `panel_split` uses `value.parse::()`. + +Beyond that: + +- A trailing `# comment` makes booleans and integers fail, and corrupts strings. +- A leading U+FEFF survives `trim()` (it is not `White_Space`), so the first line's key becomes `"\u{feff}splash"`. +- `load` uses `std::fs::read_to_string(path)` and maps **every** error, including invalid UTF-8, to `Config::default()`, with no signal. +- No warnings are collected anywhere. + +### `usermenu.toml` (`parse_usermenu`, `load_user_menu`) + +`parse_usermenu` uses the same line loop: + +- `[[entry]]` must match the trimmed line exactly. +- A `key = value` line whose value is not a full `"..."`/`'...'` string (via `parse_toml_string_strict`, which requires the value to *end* with the quote) sets `malformed`. A trailing comment therefore makes the whole file malformed. +- `\u{feff}[[entry]]` is malformed too. +- It already collects `warnings` for unknown keys, keys outside an `[[entry]]` block, and entries missing a field. `load_user_menu` returns them only for well-formed files. +- `app::apply_user_menu` ignores `warnings` and only raises the startup warning for `malformed`. `update::apply_user_menu_reload` does the same on reload. The warnings are never shown. + +`load_user_menu` treats **any** read error as "missing": it writes the default menu to the path with `std::fs::write` and returns `created_default`. A `usermenu.toml` saved in ANSI or as UTF-16 ("Unicode" in Notepad) fails `read_to_string` and is overwritten. + +### `history.json` reader + +- `parse_history_file` locates `"commands"` and `"directories"` with `extract_value_span`, which does a raw `input.find("\"key\"")` and then a bracket-balance scan that is aware of string literals. +- `parse_history`, which reads the command array, decodes `\uXXXX` with `char::from_u32`. That returns `None` for any surrogate, so a lone `\uD83D`, or even the valid pair `\uD83D\uDE00`, hits `None => return out` and **every later command is dropped**. +- `extract_string_field`, which reads a directory path, uses `?` on the same decode, so the one directory entry is skipped. +- The writer, `json_escape`, only emits `\u` for code points below 0x20, so only hand-edited or foreign files hit this. +- There is no BOM handling. + +### Rewrites (`set_theme_line`, `set_panel_split_line` → `save_*_atomic`) + +The rewrite helpers work line by line: + +- They replace the first non-comment line whose key is `theme`/`panel_split`, ignoring which table it is in. +- If there is none, they **append** the key at the end of the file. When the file ends with a `[mouse]` table, the appended key is scoped to `[mouse]` and ignored on the next load. +- A replaced line loses its trailing comment. +- A BOM-prefixed first line is not recognized, so a duplicate key gets appended. + +The `save_*_atomic` functions call `std::fs::write` on the fixed `config.toml.tmp`, then `rename`. There is no `sync_all`. + +### Startup warnings + +`State::startup_warning: Option` is one message. `views/startup_warning.rs` renders it on **one row**, truncated with `…`. `app::run` assigns it last-writer-wins in this order: + +1. `--config-dir` flag warning +2. profile-resolve warning +3. `--theme` warning +4. user-menu malformed +5. legacy-files notice +6. unwritable-profile warning + +## Goals / Non-Goals + +**Goals:** + +- A BOM, an inline comment, or a misplaced key never silently changes what a configuration file means. +- Every configuration line FileCommand ignores is reported once, with its file and line number. +- One tokenizer for both TOML-like files, so comment, quote, header, and BOM rules cannot drift between them. `history.json` is JSON and is not line-tokenized; it shares only the BOM strip, and its duplicated string decoders become one (D7). +- The history reader never loses entries to an escape it does not understand. +- FileCommand's own rewrites produce files that read back the way they were intended, and they survive a power loss. +- No new crates. + +**Non-Goals:** + +- Full TOML or JSON conformance: multi-line strings, arrays and inline tables in `config.toml`, numbers other than `panel_split`, and so on. +- Warnings for `theme` names that are not built-in, or for an invalid `panel_split`. theme-selection "Unknown configured theme falls back to default" and panel-split "Invalid configured value falls back" specify a silent fallback, and this change leaves those requirements unchanged. +- Warnings for `history.json`. fuzzy-jump "Missing or malformed history file" requires a normal launch without error, and the file is machine-written. +- `history.json` format marker, cap, merge, and writer (`history-persistence-hardening`). A logging sink for warnings (`error-diagnostics`). +- Reading non-UTF-8 (ANSI or UTF-16) configuration files. They are reported, not transcoded. +- Duplicate-key detection. The last occurrence still wins, as today. +- Moving the theme and panel-split writes off the UI thread. The audit lists these persist writes under P-09 (`offload-blocking-io`); this change only makes them durable. + +## Decisions + +### D1: One line tokenizer for `config.toml` and `usermenu.toml` + +**Scope of "one shared tokenizer".** The audit (B-05) recommends routing all three files through one tokenizer, and the user decision asks for warnings collected "via one shared tokenizer". The two TOML-like files share the line tokenizer below. `history.json` does not: it is a JSON document, not a sequence of `key = value` lines, and it produces no warnings (Non-Goals). What it shares with the other two is `strip_bom`, and its two copies of the string-escape loop are merged into one `decode_json_string` (D7). + +A new private module, `config::toml_lines` (or a section of `config.rs`), provides `tokenize(input: &str) -> Vec`. The input has already had its leading U+FEFF removed by `strip_bom`. Each `Line` carries its 1-based number and one of: + +- `Blank`, which also covers a whole-line comment. +- `Table { name, array: bool }`, for `[name]` or `[[name]]`, with an optional trailing comment. +- `KeyValue { key, value: Value, comment_span }`, where `Value` is one of: + - `Basic(String)`, the raw inner text of `"…"`; + - `Literal(String)`, the inner text of `'…'`; + - `Bare(String)`, unquoted text with surrounding whitespace trimmed. + + The span of the trailing comment is kept, for the rewriter (D5). +- `Malformed { reason }`: no `=`, an empty key, an unterminated quote, or text after a closing quote that is neither whitespace nor a comment. + +Comment rules follow TOML where they can, and stay compatible with the bare values `config.toml` already accepts: + +- **Quoted values.** The closing quote is the **first** matching quote character such that the rest of the line is empty, whitespace, or whitespace followed by `#…`. With that rule: + - `theme = "x" # it's "great"` is `x`; + - `editor = "a # b"` keeps the `#`; + - `shell = "C:\dir\"` (a trailing backslash) still terminates; + - `editor = "say \"hi\""` yields its verbatim inner text, as today. + + For `usermenu.toml`, the scan honors TOML basic-string escapes (`\"` does not close the string), because that file is real TOML and its values are unescaped by the existing `unescape_toml_basic`. +- **Bare values.** A `#` starts a comment only at the start of the value or after whitespace. `splash = true # no` is `true`; `editor = C:\Tools\C#\edit.exe` keeps `C#`. +- **Table headers** may carry a trailing comment: `[mouse] # pointer` is the `mouse` table. + +The tokenizer does not interpret values. `config.toml` keeps reading `Basic`/`Literal` content verbatim through `parse_string`'s existing semantics. Unescaping it would break `editor = "C:\Tools\ed.exe"`, which works today. `usermenu.toml` keeps applying `unescape_toml_basic` to `Basic` and reading `Literal` raw, and it still rejects `Bare` as malformed. That preserves user-menu's "real TOML" strictness. + +Alternatives considered: + +- **The `toml` crate.** The audit's alternative was rejected by user decision: no new crates. +- **Comment stripping as a pre-pass with a regex-like split on ` #`.** This cannot tell a quoted `#` from a comment. +- **TOML's strict rule** (any `#` outside a string starts a comment, even inside a bare value). Bare values are already a non-TOML extension, and the whitespace rule protects paths such as `C#`. + +### D2: `config.toml` warnings, by category + +`parse(input) -> ParsedConfig { config: Config, warnings: Vec }`, where `ConfigWarning { file: &'static str, line: usize, message: String }`. `load(path)` returns the same type. The warnings: + +| Condition | Message (example) | +|---|---| +| `Malformed` line | `config.toml line 7: expected key = value; line ignored` | +| Unknown top-level key | `config.toml line 9: unknown setting "colour"; ignored` | +| Key under `[mouse]` other than `enabled` | `config.toml line 12: "key.paste_name" is not a [mouse] setting and was ignored — put top-level settings before the first [table]` | +| Unknown table header | `config.toml line 20: unknown table [colors]; its settings are ignored` (one warning for the header; keys under it are not warned about individually) | +| Boolean key (`splash`, `animations`, `[mouse] enabled`) with a value other than `true`/`false` | `config.toml line 3: splash must be true or false; using the default` | +| `key.*` value that `parse_binding` rejects, or an empty value | `config.toml line 15: key.fuzzy_jump "" is not a key binding; using the default` | +| File exists but is not valid UTF-8 | `config.toml is not UTF-8 text; using default settings` (line 0, not tied to a line) | + +`theme` and `panel_split` values are never warned about (see Non-Goals). A missing `config.toml` yields no warning. `load` therefore distinguishes `ErrorKind::NotFound` from other read errors, and other unreadable cases, such as access denied, get a generic `could not be read` warning with the OS message. + +### D3: `usermenu.toml` on the tokenizer, never overwritten, warnings surfaced + +`parse_usermenu` is rebuilt on `tokenize`. It keeps its current outcomes, but now a BOM, a trailing comment, and `[[entry]] # c` are all accepted: + +- An entry key that is not `label` or `command` produces a warning. +- A key outside `[[entry]]` produces a warning. +- An entry missing a field produces a warning, and the entry is skipped. +- A `Malformed`, `Bare`, or non-`[[entry]]` table line makes the file malformed. This matches the current strictness; a `[foo]` header is malformed today, because it is not `[[entry]]` and has no `=`. + +The existing warning strings gain line numbers, for example `usermenu.toml line 4: unknown key "icon"; ignored`. + +`load_user_menu` creates the default file only for `ErrorKind::NotFound`. Content that is not valid UTF-8 is treated as **malformed**: default entries in memory, the startup warning raised, and the file left untouched. `UserMenuLoadResult` gains a field that says why the file was malformed, so `app::apply_user_menu` shows `usermenu.toml is not UTF-8 text; using the default menu — re-save it as UTF-8 or delete it to recreate the defaults` for this case and keeps today's `usermenu.toml is malformed; F2 uses the default user menu` for invalid TOML. Any other read error (for example access denied) is also treated as malformed, with the OS message, and never overwrites the file. The first-run default write goes through the durable helper (D6). + +### D4: Surfacing warnings through the single startup-warning field + +`app::run` collects `config.toml` warnings, then `usermenu.toml` warnings, in line order, into one list. It formats one summary line: + +- a single warning: ``; +- more than one: ` (+N more)`. + +The dialog is one row wide (`views/startup_warning.rs`), so the first message carries the file and line number the user needs to find the problem. The summary has the **lowest** precedence. In the existing last-writer-wins order it behaves as if it were assigned first; in practice `run` computes it after both files are loaded and stores it only when no other startup warning has been set: + +1. **configuration warnings** (new) +2. `--config-dir` flag warning +3. profile-resolve warning +4. `--theme` warning +5. user-menu malformed (including the new not-UTF-8 message) +6. legacy notice +7. unwritable profile + +Every warning that an existing requirement mandates therefore still appears exactly as today: theme-selection's `--theme` warning, user-menu's malformed-file warning, and config-profile-directory's profile warnings. The configuration summary is shown only when none of them fires. In practice the conflicts are rare: the legacy and unwritable warnings fire only when there is no `config.toml` to warn about, and `error-diagnostics` logs every displaced warning once it lands. + +Alternative considered: placing the summary after the profile-resolve warning (so it would outrank `--theme`). That would replace the profile-resolve and `--config-dir` warnings, which this change's own requirement says it must not do, and gives no clear benefit over a lower slot. + +On a user-menu reload (`UserMenuReloaded`), `apply_user_menu_reload` raises the same dialog. It reports `malformed` as today, otherwise the warning summary, otherwise nothing. + +Alternatives considered: + +- **A multi-line or queued warning dialog.** This is a larger UI change to a surface that other in-flight work (config-profile-directory) also relies on. The summary is enough to find line N. +- **Warnings on the panel mini-status.** The status line is per-panel, overwritten by the next error, and easy to miss at startup. + +When `error-diagnostics` lands a log sink, every warning, not just the summary, should also be logged. + +### D5: Table-aware rewrites that keep comments and the BOM + +`set_theme_line` and `set_panel_split_line` become thin wrappers over one `set_top_level_key(input, key, rendered_value)` built on `tokenize`. It: + +- remembers whether the input started with a BOM and re-emits it; +- finds the first `KeyValue` with that key **before the first table header**, and replaces only its key and value text, re-attaching the original trailing comment (`theme = "purple-lights" # favourite`); +- when no top-level line exists, inserts `key = value` immediately **before the first table header**, or appends at the end when there is no table; +- leaves every other line byte-for-byte unchanged, as today. + +Alternative considered: always appending, and relying on users to keep tables last. That is today's behavior, and it silently loses the setting the user just chose. + +### D6: Durable writes through the shared helper + +`save_theme_atomic`, `save_panel_split_atomic`, and `load_user_menu`'s first-run default write all call the shared durable-replace helper introduced by `editor-encoding-safety`: + +- the temp file has a unique name in the same directory, so two instances never share `config.toml.tmp`; +- the temp file is flushed with `sync_all` before the replace; +- the temp file is removed on failure. + +`history.json` uses the same helper, under `history-persistence-hardening`. The fixed-name `temp_sibling` helper is deleted once no caller remains. + +theme-selection "Config write is atomic" and panel-split "Split persistence to configuration" (which says "the same atomic temp-file-and-rename write as other configuration updates") stay satisfied: the helper still writes a temporary file and atomically replaces the target with it (on Windows through `ReplaceFileW` when the target exists, per `editor-encoding-safety` D4), and every configuration write still shares one path. + +### D7: Lossless history string decoding, top-level key lookup, BOM + +- **One string decoder.** A single `decode_json_string(chars) -> Option` replaces both copies of the escape loop, in `parse_history` and in `extract_string_field`: + - `\uD800–\uDBFF` followed by `\uDC00–\uDFFF` decodes to the combined scalar; + - a lone high or low surrogate, or a `\u` without four hex digits, decodes to U+FFFD, and decoding continues; + - only a string with no closing quote fails, and then only that string is lost. Parsing resumes after it where the structure allows; the earlier and later entries are kept. +- **Key lookup.** `extract_value_span` finds the key by scanning the top-level object with the same string-literal awareness it already uses for bracket balancing. A `"directories"` sequence inside a string value can therefore never be mistaken for the key. +- **BOM.** `parse_history_file` (and the legacy `parse_history`) start with `strip_bom`. +- **No warnings.** None are produced for `history.json` (Non-Goals). + +## Risks / Trade-offs + +- **[Risk] A bare value that intentionally contains ` #`, such as `shell = cmd /C echo #1`, is now cut at the comment.** → This is TOML's documented behavior and is unlikely in practice. The README notes that values containing ` #` must be quoted. The change is listed under Compatibility in the proposal. +- **[Risk] Users who never saw warnings may now get a startup dialog for a long-standing harmless typo.** → This is intended: R-16 shows that silence hides real breakage. The dialog appears once per launch, is dismissed with any key, and names the line to fix. +- **[Trade-off] The one-row summary hides all but the first warning.** → The `(+N more)` count tells the user to look further, fixing the first and relaunching reveals the next, and `error-diagnostics` will add the full list to its log. +- **[Risk] With the earliest-qualifying-closing-quote rule, a verbatim `config.toml` value whose own text contains a quote followed by ` #` (for example `editor = "run "x" #2"`) is cut at that comment.** → This is a vanishingly rare shape. A line that ends in a quote with no comment, such as `editor = "a" b"`, still reads back verbatim as `a" b`, exactly as today. A value with no qualifying closing quote at all, such as `theme = "x`, is reported as malformed and the key keeps its default. +- **[Risk] A non-UTF-8 `usermenu.toml` cannot be repaired from inside FileCommand with the built-in editor**, because `editor-encoding-safety` redirects non-UTF-8 files to the viewer. → The warning says how to recover: `usermenu.toml is not UTF-8 text; using the default menu — re-save it as UTF-8 or delete it to recreate the defaults`. An external editor (`editor =`) can still open it. +- **[Trade-off] `sync_all` makes each theme or split write slower, and those writes still run on the UI thread.** Holding Ctrl+Left/Ctrl+Right issues one flushed write per step. → A flush of a file this small is typically a few milliseconds on a local disk. Moving the writes off the UI thread is `offload-blocking-io` (P-09); panel-split already allows rapid adjustments to coalesce writes. +- **[Risk] Rewrites now move a missing key above the first table instead of to the end.** → The resulting file reads back correctly, and every existing line keeps its bytes. Only the position of the new line differs. + +## Migration Plan + +No data migration is needed. Existing files parse identically unless they contain comments after values, a BOM, misplaced keys, or invalid lines. All of those were silently misread before and are now read as intended or reported. The README example is corrected in the same change. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-config-parsing-robustness/proposal.md b/openspec/changes/archive/2026-10-14-config-parsing-robustness/proposal.md new file mode 100644 index 0000000..b83804d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-config-parsing-robustness/proposal.md @@ -0,0 +1,120 @@ +# Change: config-parsing-robustness + +## Why + +FileCommand reads three hand-written formats from the profile directory: `config.toml` (flat TOML-like lines plus a `[mouse]` table), `usermenu.toml` (a TOML array of tables), and `history.json`. Each has its own parser, and each fails silently in ways the 2026-10-12 code audit confirmed: + +- **R-16 (Medium): the README example silently disables every key binding, and inline comments break values.** + - The README's `config.toml` example lists all `key.*` lines *after* `[mouse]`. The parser correctly scopes them to that table, where only `enabled` is recognized, so every custom binding is dropped. No warning is given. + - Inline comments are not stripped. `splash = true # comment` fails `parse_bool`, `theme = "x" # c` keeps the quotes and the comment as the theme name, and `panel_split = 40 # c` is not an integer. + - Design §3.1 promises warnings for unknown keys, but `config::parse` collects none. +- **R-15, config part (Medium): BOMs break the files, and config writes are not crash-durable.** + - `parse` does not strip a UTF-8 BOM, because U+FEFF is not whitespace. A `config.toml` saved by older Notepad therefore silently ignores its first key. + - `parse_usermenu` sees `\u{feff}[[entry]]`, treats the whole file as malformed, and falls back to the default menu. + - `save_theme_atomic` and `save_panel_split_atomic` write a temp file and rename it without `sync_all`, so a power loss can leave a zero-length `config.toml`. +- **B-05 (Low): the bespoke parsers' silent failure modes.** + - Three dialects with no shared code and no diagnostics. + - The history JSON reader stops at the first `\u` escape that is not a standalone scalar value (a lone `\uD83D`, and even a valid surrogate pair) and **drops every later command** (`parse_history`). + - `extract_value_span` finds `"commands"`/`"directories"` by raw text search instead of by structure. + +Verifying these against the code turned up related silent failures in the same paths: + +- A `config.toml` that is not valid UTF-8 (an ANSI file containing `é`) is discarded completely without a word. +- A `usermenu.toml` that is not valid UTF-8 is treated as *missing*, and `load_user_menu` **overwrites it with the defaults**. That breaks user-menu's "MUST NOT silently overwrite" rule. +- `usermenu.toml`'s non-fatal warnings (unknown keys, entries missing a field) are collected but never shown, although user-menu "Unknown keys warn rather than fail" requires a warning. +- `set_theme_line`/`set_panel_split_line` append a missing key at the *end* of the file. In a file that ends with a `[mouse]` table, the new key lands inside `[mouse]` and is ignored on the next start. + +## What Changes + +- **New `configuration-files` capability**, describing how the profile files are read and written. Today no spec owns parsing; the requirements that mention these files are scattered across a dozen capabilities, among them theme-selection, panel-split, mouse-input, startup-splash, external-editor, command-line, user-menu, and fuzzy-jump. +- **What "one shared tokenizer" means here.** `config.toml` and `usermenu.toml` are line-oriented TOML-like files, and both are read through one shared line tokenizer (BOM strip, comment and quote rules, table headers, line numbers, warnings). `history.json` is JSON, not line-oriented, so it does **not** go through that tokenizer: it shares only the BOM strip, and its two duplicated string-escape loops are merged into one JSON string decoder. This is how this change applies the audit's "route all three through one tokenizer" (B-05) and the user decision "one shared tokenizer". +- **BOM and inline comments.** + - A leading UTF-8 BOM is ignored in `config.toml`, `usermenu.toml`, and `history.json`. + - In `config.toml` and `usermenu.toml`, a `#` outside a quoted string starts a comment that runs to the end of the line, on key lines and table-header lines alike. A `#` inside a quoted value is kept. + - In `config.toml`'s unquoted (bare) values, a `#` starts a comment only after whitespace, so a bare `C:\Tools\C#\edit.exe` keeps working. +- **Warnings instead of silence.** + - `config.toml` and `usermenu.toml` are read through one shared line tokenizer that collects warnings with line numbers. + - `config.toml` warns about: + - a line that is not `key = value`, a table header, or a comment; + - an unknown key; + - a key placed under the wrong table (for example `key.paste_name` after `[mouse]`), naming the line and explaining that top-level settings must come before the first table; + - an unknown table; + - a malformed quoted value; + - a boolean or key-binding value that cannot be read; + - a file that is not valid UTF-8 (defaults are used). + - `usermenu.toml`'s existing warnings, and a new "not UTF-8" outcome (treated as malformed, file left untouched), are surfaced too. + - Warnings are shown once at startup through the existing dismissable startup-warning dialog, as a one-line summary (first warning plus a "+N more" count). Warnings from a user-menu reload after an edit are shown the same way. + - The summary has the lowest precedence in the single startup-warning field: any other startup warning (an empty `--config-dir`, the profile-directory fallback, an unknown `--theme`, a malformed `usermenu.toml`, the legacy-files notice, an unwritable profile) is shown instead, so no warning that existing requirements mandate is displaced. + - A not-UTF-8 `usermenu.toml` gets its own message (`usermenu.toml is not UTF-8 text; using the default menu — re-save it as UTF-8 or delete it to recreate the defaults`) instead of the generic malformed one. + - `theme` and `panel_split` keep their already-specified silent fallbacks. +- **History JSON decoding** (the B-05 escape fix lives here, not in `history-persistence-hardening`): + - `\uXXXX` surrogate pairs decode to their character; + - a lone or invalid `\u` escape decodes to U+FFFD, and parsing continues, so no later entry is lost; + - the `"commands"`/`"directories"` keys are found only at the top level of the JSON object. + + `history.json` problems stay silent, as fuzzy-jump "Missing or malformed history file" requires. +- **Table-aware, durable rewrites.** + - Theme and panel-split rewrites go through the same tokenizer. They replace only the top-level line, keep its trailing comment and any leading BOM, and insert a missing key *before* the first table header instead of at the end of the file. + - Every write of `config.toml` and `usermenu.toml` (including the first-run default user menu) goes to a uniquely named temp file that is flushed with `sync_all` before it replaces the target. This uses the shared durable-replace helper. +- The parsers stay hand-rolled, with **no new crates** (user decision). +- The README's `config.toml` example is fixed at implementation time: `[mouse]` moves to the end, and the "Unknown keys are ignored" text becomes "produce a startup warning". + +## Capabilities + +### New Capabilities + +- `configuration-files`: + - BOM and inline-comment tolerance for the profile files; + - startup warnings for malformed, unrecognized, misplaced, and non-UTF-8 configuration content; + - lossless history JSON string decoding; + - table-aware rewrites; + - durable writes of `config.toml` and `usermenu.toml`. + +### Modified Capabilities + +None. No existing requirement is replaced: + +- the theme-selection and panel-split fallbacks remain silent; +- user-menu's malformed-file handling and its warn-on-unknown-key rule are what this change now implements faithfully; +- `history.json` write durability is specified by `history-persistence-hardening` in fuzzy-jump. + +## Impact + +- **Specs:** new `configuration-files` capability (five ADDED requirements). +- **Code (core):** + - `crates/filecommand-core/src/config.rs`: + - new shared line tokenizer: BOM strip, comment stripping, quoted and bare values, table headers, line numbers, warnings; + - `parse` rebuilt on it and returning warnings alongside `Config`; + - `parse_bool`, `parse_string`, and the `panel_split` integer parse receive comment-free values; + - `load` distinguishes NotFound from invalid UTF-8; + - `parse_usermenu` rebuilt on the tokenizer; + - `load_user_menu` no longer overwrites a non-UTF-8 file, and writes defaults through the durable helper; + - `set_theme_line`/`set_panel_split_line` become table-aware and keep comments and the BOM; + - `save_theme_atomic`/`save_panel_split_atomic` use the durable helper; + - shared `decode_json_string` used by `parse_history` and `extract_string_field`; + - top-level-only key lookup replaces `extract_value_span`'s raw `find`; + - BOM strip in `parse_history_file`. +- **Code (TUI):** + - `crates/filecommand-tui/src/app.rs`: + - `run` collects config and user-menu warnings into the startup-warning summary, which is stored only when no other startup warning was raised (lowest precedence); + - `apply_user_menu` surfaces `warnings`, and the not-UTF-8 message. +- **Code (core, reload path):** `apply_user_menu_reload` in `crates/filecommand-core/src/update.rs` surfaces reload warnings. It receives the `UserMenuLoadResult` that the TUI's `app::reload_user_menu` sends back as `Command::UserMenuReloaded`; `reload_user_menu` itself does not change. +- **Docs:** README `config.toml` example and the unknown-keys sentence; module docs in `config.rs`. +- **Tests:** + - unit tests in `config.rs` for every tokenizer rule, warning, rewrite, and JSON escape case; + - `app.rs` tests for the warning summary and its precedence; + - a temp-dir test that a non-UTF-8 `usermenu.toml` is not overwritten. +- **Compatibility:** + - A value that today contains a ` #` sequence outside quotes (for example `shell = cmd /C echo #1`) now ends at the comment. This is TOML's rule, and the fix is to quote the value. + - Quoted `config.toml` values keep today's verbatim treatment (backslashes are not escapes), so `editor = "C:\Tools\ed.exe"` keeps working. + - Files without comments, BOMs, or errors parse exactly as before. + +**Audit findings:** R-15 (config.toml and usermenu.toml part), R-16, B-05 + +**Coordination:** +- `history-persistence-hardening` (P-08, R-13, R-15 history part) changes `history.json`'s format marker, merge, cap, and writer. This change owns only the history *reader's* BOM handling and string-escape decoding. Both edit the history section of `config.rs`; whichever lands second rebases onto the other's helpers. +- `editor-encoding-safety` introduces the shared durable-replace helper (temp file, `sync_all`, replace). This change routes the `config.toml` and `usermenu.toml` writes through it, and introduces it if this change lands first. +- `error-diagnostics` (B-06) adds a diagnostics log. Once it exists, the full list of configuration warnings should also go to the log; the startup dialog shows only the summary. Neither change depends on the other. +- `ci-and-build-tooling` (B-09) also corrects README claims. The `config.toml` example reorder and the unknown-keys sentence belong to this change. +- `config-profile-directory` (in flight; already implemented on the build branch) defines where the three files live and raises the profile-directory startup warnings through the single last-writer-wins `startup_warning` field. This change gives its summary the lowest precedence, so every profile-directory warning still wins. +- `offload-blocking-io` (P-09) covers the synchronous effects on the UI thread, whose audit location includes the theme and panel-split persist writes. This change adds a `sync_all` to those writes but does not move them off the UI thread. diff --git a/openspec/changes/archive/2026-10-14-config-parsing-robustness/specs/configuration-files/spec.md b/openspec/changes/archive/2026-10-14-config-parsing-robustness/specs/configuration-files/spec.md new file mode 100644 index 0000000..718bba9 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-config-parsing-robustness/specs/configuration-files/spec.md @@ -0,0 +1,206 @@ +## ADDED Requirements + +### Requirement: Configuration files tolerate a UTF-8 BOM and inline comments + +The system SHALL ignore a leading UTF-8 byte-order mark (`EF BB BF`) when reading `config.toml`, `usermenu.toml`, and `history.json`, so the first line or key is read exactly as if no byte-order mark were present. + +In `config.toml` and `usermenu.toml`, a `#` that appears outside a quoted string SHALL begin a comment that extends to the end of the line, on key lines and on table-header lines, with one exception: in an unquoted `config.toml` value, a `#` SHALL begin a comment only when it is the first character of the value or is preceded by whitespace. The comment SHALL be excluded from the key's value. A `#` inside a quoted value SHALL be kept as part of the value. Comment handling SHALL NOT change how the characters of a quoted `config.toml` value are interpreted: its text between the quotes SHALL be taken verbatim, as before. Both files SHALL be tokenized by a single shared line tokenizer in `filecommand-core` that is unit-testable without a terminal. + +#### Scenario: BOM-prefixed config.toml reads its first key + +- **WHEN** `config.toml` begins with a UTF-8 byte-order mark immediately followed by the line `splash = false`, and the application starts +- **THEN** the splash is skipped, exactly as when the file has no byte-order mark + +#### Scenario: BOM-prefixed usermenu.toml loads its entries + +- **WHEN** `usermenu.toml` begins with a UTF-8 byte-order mark immediately followed by `[[entry]]`, a `label`, and a `command` line +- **THEN** that entry appears in the F2 user menu and the file is not treated as malformed + +#### Scenario: BOM-prefixed history.json loads + +- **WHEN** `history.json` begins with a UTF-8 byte-order mark and otherwise holds valid command and directory history +- **THEN** the command history and the fuzzy jump history load as if the byte-order mark were absent + +#### Scenario: Inline comment after a boolean value + +- **WHEN** `config.toml` contains `splash = false # start straight in the panels` +- **THEN** the splash is skipped and no warning is raised for that line + +#### Scenario: Inline comment after a quoted value + +- **WHEN** `config.toml` contains `theme = "purple-lights" # favourite` +- **THEN** the application starts with the `purple-lights` theme active + +#### Scenario: Hash inside a quoted value is kept + +- **WHEN** `config.toml` contains `editor = "C:\Tools\C# Editor\edit.exe"` +- **THEN** the configured external editor command is exactly `C:\Tools\C# Editor\edit.exe`, backslashes included + +#### Scenario: Hash inside an unquoted word is kept + +- **WHEN** `config.toml` contains `editor = C:\Tools\C#\edit.exe` +- **THEN** the configured external editor command is exactly `C:\Tools\C#\edit.exe` + +#### Scenario: Comment after a table header + +- **WHEN** `config.toml` contains the line `[mouse] # pointer settings` followed by `enabled = false` +- **THEN** mouse capture is disabled + +#### Scenario: Inline comment in usermenu.toml + +- **WHEN** an `[[entry]]` in `usermenu.toml` contains `label = "Build" # main build` and `command = "cargo build"` +- **THEN** the user menu lists an entry labelled `Build` that runs `cargo build`, and the file is not treated as malformed + +### 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`, `[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: 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 + +### Requirement: Persisted configuration writes are durable + +Every write the system makes to `config.toml` or `usermenu.toml` SHALL follow the same sequence. This covers the theme and panel-split rewrites, the first-run creation of the default `usermenu.toml`, and any later configuration write. The content SHALL be written to a uniquely named temporary file in the same directory and flushed to stable storage, and only then SHALL that file replace the target, so an interruption at any point leaves the target with either its complete previous content or its complete new content. If the write fails, the target SHALL be left unchanged, the temporary file SHALL be removed, and the session SHALL continue with the in-memory setting in effect. + +#### Scenario: Theme write is flushed before it replaces config.toml + +- **WHEN** the user applies a theme and `config.toml` is rewritten +- **THEN** the new content is written to a temporary file in the profile directory and flushed to stable storage before it replaces `config.toml` + +#### Scenario: Interrupted configuration write leaves a complete file + +- **WHEN** the process or the machine stops while `config.toml` is being rewritten +- **THEN** `config.toml` holds either its complete previous content or its complete new content, never a truncated or empty file + +#### Scenario: Failed configuration write leaves the file untouched + +- **WHEN** rewriting `config.toml` fails, for example because the disk is full +- **THEN** `config.toml` is unchanged, no temporary file remains in the profile directory, and the applied theme stays active for the session + +#### Scenario: First-run user menu is created durably + +- **WHEN** FileCommand starts with no `usermenu.toml` in the profile directory +- **THEN** the default `usermenu.toml` is written through a flushed temporary file that replaces the target, so a crash during first run can never leave a partial user-menu file + +### Requirement: Configuration rewrites keep settings in the top-level table + +When the system rewrites a top-level `config.toml` setting (`theme` or `panel_split`), it SHALL replace only an occurrence of that key that appears before the first table header, keeping that line's trailing comment. When no such occurrence exists, it SHALL insert the new line immediately before the first table header, or at the end of the file when the file has no table. A leading UTF-8 byte-order mark SHALL be kept, and every other line SHALL be left byte-for-byte unchanged. + +#### Scenario: New key is inserted above a trailing table + +- **WHEN** `config.toml` contains only `[mouse]` and `enabled = false`, and the user applies the `purple-lights` theme +- **THEN** the line `theme = "purple-lights"` is written above `[mouse]`, and after a restart the `purple-lights` theme is active and mouse capture is still disabled + +#### Scenario: Existing line is replaced with its comment kept + +- **WHEN** `config.toml` contains `theme = "nc-classic" # my pick` and the user applies `yellow-storm` +- **THEN** that line becomes `theme = "yellow-storm" # my pick` and every other line is unchanged + +#### Scenario: Byte-order mark is preserved by a rewrite + +- **WHEN** `config.toml` begins with a UTF-8 byte-order mark followed by `theme = "nc-classic"`, and the user applies `inverted` +- **THEN** the rewritten file still begins with the byte-order mark and contains exactly one `theme` line, `theme = "inverted"` + +#### Scenario: Split rewrite follows the same placement + +- **WHEN** `config.toml` contains only a `[mouse]` table and the user adjusts the panel split to 60% +- **THEN** `panel_split = 60` is written above `[mouse]`, and after a restart the panels open at that split + +### Requirement: History file strings decode without losing entries + +When reading `history.json`, the system SHALL decode a `\uXXXX` escape pair that forms a UTF-16 surrogate pair as the single character it encodes. It SHALL decode a lone surrogate escape, or a `\u` escape without four hexadecimal digits, as the replacement character U+FFFD and continue reading. Such an escape SHALL NOT cause any other command or directory entry to be dropped. The system SHALL recognize the `"commands"` and `"directories"` keys only as keys of the top-level object, never as text inside a string value. + +#### Scenario: Surrogate pair decodes to one character + +- **WHEN** a command in `history.json` is stored as `"echo \uD83D\uDE00"` +- **THEN** the loaded command history contains `echo 😀` + +#### Scenario: Lone surrogate does not drop later commands + +- **WHEN** `history.json` stores the commands `"cd a"`, `"cd b\uD83D"`, and `"cd c"` in that order +- **THEN** the loaded command history contains three entries: `cd a`, `cd b` followed by U+FFFD, and `cd c` + +#### Scenario: Key text inside a string is not mistaken for a key + +- **WHEN** the `"commands"` array in `history.json` contains the string `"directories"` followed by the string `"cd C:\\"`, and the file's top-level `"directories"` key holds two directory entries +- **THEN** both directory entries are loaded into the fuzzy jump history diff --git a/openspec/changes/archive/2026-10-14-config-parsing-robustness/tasks.md b/openspec/changes/archive/2026-10-14-config-parsing-robustness/tasks.md new file mode 100644 index 0000000..2ddd829 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-config-parsing-robustness/tasks.md @@ -0,0 +1,149 @@ +# Tasks: config-parsing-robustness + +## 1. Shared tokenizer (`crates/filecommand-core/src/config.rs`) + +- [x] 1.1 Add `strip_bom(&str) -> &str`, which removes one leading U+FEFF (configuration-files "Configuration files tolerate a UTF-8 BOM and inline comments"; design D1). +- [x] 1.2 Add `tokenize(input, QuoteMode) -> Vec`: + - line numbers are 1-based; + - `Line` is `Blank`, `Table { name, array }`, `KeyValue { key, value: Basic | Literal | Bare, comment_span }`, or `Malformed { reason }`; + - the closing quote is the earliest one leaving only whitespace or a `#` comment; + - a `#` in a bare value starts a comment only at the value start or after whitespace; + - table headers may carry a trailing comment; + - `QuoteMode::Verbatim` for `config.toml`, `QuoteMode::TomlEscapes` for `usermenu.toml`, so that `\"` does not close a basic string (design D1). +- [x] 1.3 Add `ConfigWarning { file, line, message }` and a summary formatter: the first warning plus ` (+N more)` (design D2, D4). + +## 2. `config.toml` parsing and loading + +- [x] 2.1 Rebuild `parse` on `strip_bom` and `tokenize`, returning `ParsedConfig { config, warnings }`. Keep every existing key, default, `[mouse]` scoping rule, and the verbatim quoted-value semantics of `parse_string` (configuration-files "Hash inside a quoted value is kept"; design D1). +- [x] 2.2 Emit the warnings in design D2's table: + - malformed line; + - unterminated quote; + - unknown key; + - a key under `[mouse]` other than `enabled`, with the "top-level settings must come before the first [table]" wording; + - unknown table (one warning per header); + - an uninterpretable boolean or `key.*` value. + + Emit no warning for the value of a well-formed `theme` or `panel_split` line; an unterminated quote on such a line is still reported as malformed (configuration-files "Malformed or unrecognized configuration lines produce startup warnings", "Unterminated quote is reported"). +- [x] 2.3 Make `load` return `ParsedConfig`: + - `NotFound` → defaults, no warning; + - invalid UTF-8 → defaults plus `config.toml is not UTF-8 text; using default settings`; + - other read errors → defaults plus a `could not be read` warning with the OS message. +- [x] 2.4 Update the callers of `parse`/`load` (`app::run`, tests) for the new return type. + +## 3. `usermenu.toml` + +- [x] 3.1 Rebuild `parse_usermenu` on `strip_bom` and `tokenize(…, TomlEscapes)`, keeping today's outcomes: + - a `Bare` value, a `Malformed` line, or a table other than `[[entry]]` is malformed; + - an unknown key, a key outside an entry, or an entry missing a field is a warning, now carrying line numbers. + + BOMs, trailing comments, and `[[entry]] # c` are accepted (configuration-files "BOM-prefixed usermenu.toml loads its entries", "Inline comment in usermenu.toml"; design D3). +- [x] 3.2 `load_user_menu`: write the default file only on `ErrorKind::NotFound`. Content that is not valid UTF-8 (including UTF-16), or any other read error, returns `malformed: true` with default entries and leaves the file untouched. Add a field to `UserMenuLoadResult` that carries the reason, so `apply_user_menu` shows the not-UTF-8 message from design D3 instead of the generic malformed one (configuration-files "Non-UTF-8 usermenu.toml is kept, not overwritten"; design D3). + +## 4. Surfacing warnings + +- [x] 4.1 `crates/filecommand-tui/src/app.rs` `run`: collect the `config.toml` warnings, then the `usermenu.toml` warnings, and give the summary the lowest precedence: once both files are loaded and every other startup warning (`--config-dir`, profile-resolve, `--theme`, user-menu-malformed, legacy, unwritable-profile) has been assigned, set the summary into `state.startup_warning` only if it is still `None` (configuration-files "Several warnings are summarized", "Profile warning keeps precedence", "Theme override warning keeps precedence"; design D4). +- [x] 4.2 `apply_user_menu` (startup): surface `warnings` as part of the summary in 4.1. `malformed` keeps its existing message and precedence. +- [x] 4.3 `crates/filecommand-core/src/update.rs` `apply_user_menu_reload`: when the result is not malformed but has warnings, set `startup_warning` to their summary and apply the entries (configuration-files "Warnings after editing the user menu"; design D4). +- [x] 4.4 Update the module docs in `config.rs`: comment and BOM rules, warnings, and the removal of "unrecognized keys are ignored". + +## 5. Table-aware, durable rewrites + +- [x] 5.1 Add `set_top_level_key(input, key, rendered)` on `tokenize`: + - replace the first occurrence of the key before the first table header, keeping its trailing comment; + - otherwise insert before the first header, or append when there is none; + - re-emit a leading BOM; + - leave other lines byte-identical. + + Re-implement `set_theme_line`/`set_panel_split_line` as wrappers (configuration-files "Configuration rewrites keep settings in the top-level table"; design D5). +- [x] 5.2 Route `save_theme_atomic`, `save_panel_split_atomic`, and `load_user_menu`'s default-file write through the shared durable-replace helper (unique temp name, `sync_all`, replace, cleanup on failure). Introduce the helper if `editor-encoding-safety` has not landed. Delete `temp_sibling` once it is unused, coordinating with `history-persistence-hardening` (configuration-files "Persisted configuration writes are durable"; design D6). + +## 6. History JSON reader (B-05) + +- [x] 6.1 Add a shared `decode_json_string`: + - surrogate pairs combine into one character; + - a lone surrogate or a bad `\u` becomes U+FFFD, and decoding continues; + - only an unterminated string fails. + + Use it in `parse_history` and `extract_string_field`, so no later entry is dropped (configuration-files "History file strings decode without losing entries"; design D7). +- [x] 6.2 Make the key lookup in `extract_value_span` scan only top-level object keys, outside string literals (configuration-files "Key text inside a string is not mistaken for a key"; design D7). +- [x] 6.3 Apply `strip_bom` in `parse_history_file` and `parse_history` (configuration-files "BOM-prefixed history.json loads"). + +## 7. Tests (`config.rs` unit tests unless noted) + +- [x] 7.1 Tokenizer: + - BOM strip; + - whole-line comment; + - `[mouse] # c` header; + - `[[entry]]` array header; + - `k = "a # b"` keeps the `#`; + - `k = "x" # c "d"` → `x`; + - `k = "C:\dir\"` terminates; + - `k = C:\Tools\C#\edit.exe` keeps `C#`; + - `k = v # c` → `v`; + - `k = "x` → Malformed; + - `k "x"` (no `=`) → Malformed; + - line numbers correct with CRLF input. +- [x] 7.2 `parse` with every recognized key, followed by an inline comment, yields the same `Config` as without comments (R-16 regression: `splash = true # c`, `theme = "x" # c`, `panel_split = 40 # c`). +- [x] 7.3 R-16 regression using the README example verbatim (`[mouse]` before the `key.*` lines): + - the bindings stay at their defaults; + - one warning per misplaced `key.*` line, each naming its line number and "not a [mouse] setting"; + - the corrected example (`[mouse]` last) parses with zero warnings and all ten bindings applied. +- [x] 7.4 Warnings for: + - an unknown key; + - a malformed line; + - an unknown table (exactly one warning for the header); + - `animations = yes`; + - `key.fuzzy_jump = ""`. + + No warning for `theme = "no-such-theme"` or `panel_split = "wide"`; one malformed-line warning for `theme = "purple-lights` (no closing quote). Zero warnings for the default `Config` rendered as a file. +- [x] 7.5 `load` (temp dir): a missing file gives defaults and no warning; a Windows-1252 file gives defaults and the not-UTF-8 warning; a BOM file parses its first key. +- [x] 7.6 `parse_usermenu`: + - BOM plus `[[entry]]` loads; + - `label = "Build" # c` loads; + - `[[entry]] # c` loads; + - a bare value is still malformed; + - `[foo]` is still malformed; + - unknown-key and outside-entry warnings carry line numbers. +- [x] 7.7 `load_user_menu` (temp dir): a UTF-16LE file and a Windows-1252 file each return `malformed: true` with defaults, and the bytes on disk are unchanged afterwards (configuration-files "Non-UTF-8 usermenu.toml is kept, not overwritten"). +- [x] 7.8 `set_top_level_key`: + - insert above `[mouse]`; + - replace keeping the trailing comment; + - BOM preserved with exactly one key line; + - a `theme` key inside a table is not replaced; + - no table → append; + - all other lines byte-identical. + + Then a round trip: re-`parse` the output, get the new value, and no warnings. +- [x] 7.9 Durable writes: with the helper's fault-injection seam, a failed theme write leaves `config.toml` byte-identical and no temp file; a successful write leaves no temp file. +- [x] 7.10 History decoding: + - `\uD83D\uDE00` → 😀; + - `["cd a","cd b\uD83D","cd c"]` → three commands, the second ending in U+FFFD; + - `\uZZZZ` → U+FFFD and parsing continues; + - a stray `\uDE00` → U+FFFD; + - the string `"directories"` inside the commands array before a `"cd C:\\"` entry does not hide the real `"directories"` key; + - a BOM-prefixed file parses. +- [x] 7.11 `crates/filecommand-tui/src/app.rs` tests: + - the summary shows the first `config.toml` warning plus `(+N more)` across both files; + - with an unwritable-profile warning present, that warning wins; + - with a `--theme` warning or a malformed `usermenu.toml` present, that warning wins; + - a not-UTF-8 `usermenu.toml` shows the not-UTF-8 message; + - with no warnings, `startup_warning` stays `None` (apart from other sources). +- [x] 7.12 `update/tests.rs`: `UserMenuReloaded` with warnings and no malformed flag applies the entries and sets the warning summary; a reload that is malformed keeps today's message and keeps the previous entries. + +## 8. Docs + +- [x] 8.1 README `config.toml` section: + - move `[mouse]` / `enabled = true` to the end of the example; + - state that top-level settings must precede any `[table]` and that `#` starts a comment (quote values containing ` #`); + - replace "Unknown keys are ignored rather than rejected, and the schema tolerates missing/malformed lines" with a description of the startup warning. +- [x] 8.2 README `usermenu.toml` section: mention that comments and a UTF-8 BOM are accepted, and that a non-UTF-8 file is reported and never overwritten. + +## 9. Verify + +- [x] 9.1 `cargo test --workspace` green. +- [x] 9.2 `cargo clippy --workspace --all-targets` with no new warnings. +- [x] 9.3 `openspec validate config-parsing-robustness --strict` clean. +- [ ] 9.4 Manual check: + - paste the old README example into `config.toml` and start; the warning names the first misplaced `key.*` line with `(+9 more)`; + - save `config.toml` and `usermenu.toml` as "UTF-8 with BOM" in Notepad and start; both load with no warning; + - apply a theme to a config that ends with `[mouse]`, restart, and confirm the theme sticks. diff --git a/openspec/changes/delete-key-triggers-delete/.openspec.yaml b/openspec/changes/archive/2026-10-14-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-14-delete-key-triggers-delete/.openspec.yaml diff --git a/openspec/changes/delete-key-triggers-delete/design.md b/openspec/changes/archive/2026-10-14-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-14-delete-key-triggers-delete/design.md diff --git a/openspec/changes/delete-key-triggers-delete/proposal.md b/openspec/changes/archive/2026-10-14-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-14-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-14-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-14-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-14-delete-key-triggers-delete/tasks.md similarity index 93% rename from openspec/changes/delete-key-triggers-delete/tasks.md rename to openspec/changes/archive/2026-10-14-delete-key-triggers-delete/tasks.md index 1f28858..a499019 100644 --- a/openspec/changes/delete-key-triggers-delete/tasks.md +++ b/openspec/changes/archive/2026-10-14-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. +- [x] 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. diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/.openspec.yaml b/openspec/changes/archive/2026-10-14-durable-file-copy/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/design.md b/openspec/changes/archive/2026-10-14-durable-file-copy/design.md new file mode 100644 index 0000000..7517366 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/design.md @@ -0,0 +1,224 @@ +# Design: durable-file-copy + +## Context + +All of the following was verified on `build/open-proposals-2026-10` @ `1bada6f`. + +**The copy primitive.** `FileSystem::copy_file_chunked(from, to, chunk_bytes, on_chunk)` is implemented on Windows by `copy_chunked_impl` (`fs_ops/fs.rs`), and by an identical loop in `portable_impl`: + +- It opens `from`, then `File::create(to)`. That truncates an existing target immediately. +- It loops `read` → `write_all` → `on_chunk(total)`. +- On `ChunkControl::Cancel` it drops the destination, removes it, and returns `Interrupted`. +- Any other error returns through `?` and leaves the partially written destination in place. +- There is no `sync_all`, no length check, and the handle is closed by `Drop`, which ignores `CloseHandle` errors. + +**The per-file loop.** `copy_one_file` (`fs_ops/worker.rs`) calls the primitive with `CHUNK_BYTES = 256 KiB`. On success it restores metadata, every call best-effort through `let _`: + +- named ADS, through `list_streams` (hand-rolled `FindFirstStreamW` FFI) and `copy_stream_chunked` +- the last-write time, through `set_modified` +- read-only, through `set_readonly` + +Hidden, system and archive attributes and the creation time are not restored. On error it asks the error-recovery dialog (Retry/Skip/Skip All/Abort) through `ErrorPolicy`. + +**Copy/move orchestration.** `run_copy_or_move`: + +- Computes a conflict only when the target exists **and** has a different file identity (`identity_matches`). A target with the *same* identity is silently copied over, which truncates the file to 0 bytes. That defect is fixed separately by `copy-onto-itself-guard` (see D6). +- On Overwrite, it clears read-only on the target and lets `File::create` truncate it. +- For a move, after `CopyOneResult::Done`, it runs `let _ = fs.set_readonly(src, false); let _ = fs.remove_file(src);`. + +**What `CopyFileExW` preserves.** Measured with .NET's `File.Copy`, which calls `CopyFileEx`, on this machine. A source with creation time 2001-02-03, last-write time 2002-03-04, attributes ReadOnly|Hidden|System|Archive, and a `Zone.Identifier` ADS copied to: + +| Kept | Not kept | +|---|---| +| Last-write time | Creation time (set to the time of the copy) | +| All four attributes | | +| The ADS | | + +A separate probe showed: +- A read-only + hidden file can be renamed over a hidden target (`MoveFileEx` replace). +- Renaming over a **read-only** target fails with "Access is denied." The existing "clear read-only before overwrite" step is therefore still needed. `std::fs::rename`, which `RealFs::rename` uses, behaves the same: a review probe got `os error 5` over a read-only target and success over a hidden one. + +**Test reality (B-01).** `RealFs` is referenced only by the `fs_ops::mod` re-export and `filecommand-tui/src/worker.rs:416`, and no test constructs it. `FakeFs`, `InjectableOp`, `FakeNode` and `Injection` are compiled unconditionally and re-exported `pub`. No crate outside `filecommand-core`'s own `#[cfg(test)]` modules uses `FakeFs`: neither `filecommand-tui/src` nor `filecommand-tui/tests`. `filecommand-core` has no `tests/` directory today. + +## Goals / Non-Goals + +**Goals:** + +- A cross-volume move deletes a source only after its copy is flushed to stable storage and length-checked. Flush and close failures are copy failures, and a source that cannot be removed is reported. +- No copy ever leaves a truncated file under a target's name. An Overwrite that fails leaves the old target intact, and Cancel, Skip and Abort leave no temporary file behind. +- On Windows, a copy preserves data, named ADS, the read-only, hidden, system and archive attributes, and the creation and last-write times. +- `RealFs` is exercised by real-filesystem integration tests, and `FakeFs` is not in the release binary. + +**Non-Goals:** + +- ACLs, owner and other security-descriptor fields. The copy inherits the destination directory's permissions, as an Explorer copy does. This also applies to the copy half of a cross-volume move. +- Last-access time, sparse and compression flags (these follow `CopyFileExW`'s own behavior and are not guaranteed), and directory attributes and timestamps. Directories are still created fresh at the destination, so a hidden directory's copy is not hidden. This is unchanged from today. +- An "encryption will be lost" confirmation. EFS is kept where the destination supports it, and is otherwise copied decrypted (see D3). +- Detecting a copy onto its own source (`copy-onto-itself-guard`), recreating links (`move-reparse-point-safety`), progress-report throttling (`idle-aware-event-loop`, P-11), typed errors and logging for metadata-restore failures (`error-diagnostics`, B-06), and waiting for cleanup on quit or cancelling a blocked job (`process-lifecycle-hardening`). +- Flushing plain Copy jobs (D4) and unbuffered I/O for very large files. + +## Decisions + +### D1: Land every copied file through `.~fc-partial` in the destination directory + +`copy_one_file` no longer copies straight to the target. It works in four steps: + +1. Choose a temporary path in the target's own directory. This puts it on the target's volume, so the final step is a same-volume rename. +2. Call `fs.copy_file(src, temp, …)`. +3. On success, `fs.rename(temp, final_target)`. This replaces an existing target on Overwrite. +4. On **every** other path, best-effort `fs.remove_file(temp)` (a `NotFound` is ignored). That covers copy error, rename error, cancellation, a Skip or Abort chosen from the error dialog, and a panic-free early return. + +Retry repeats steps 2-4 from scratch. + +**Name.** The temporary file is `.~fc-partial`: + +- The suffix keeps the file next to its target in a name-sorted panel. +- It gives the file an extension no shell association claims, so a leftover is never "opened" or executed by accident. +- It is self-describing if a crash leaves it behind: visible, recognizable, and safe to delete. +- If that name already exists (a leftover, or a user file of that name), the worker uses the first free candidate in `.~fc-partial-2`, `-3`, … (bounded, e.g. 100 tries). The copy creates the chosen file **exclusively**, so an existing file is never overwritten or deleted to make room. +- If `.~fc-partial` would exceed the 255-UTF-16-unit component limit, the base name is shortened, keeping the suffix. The temporary name never needs to round-trip. + +*Alternative considered:* a hidden or dot-prefixed name (`.~fc-`). On Windows a dot prefix does not hide a file, and a leftover would sort away from its target. +*Alternative considered:* a temp file in `%TEMP%`. It would be on the wrong volume, and the final step would be another copy, not an atomic rename. +*Alternative considered:* a per-process unique name (pid or random). It is harder to recognize as "FileCommand's leftover" and is not needed once creation is exclusive. + +### D2: `FileSystem::copy_file` copies data *and* metadata, creates its destination exclusively, and optionally flushes + +The trait method becomes `copy_file(&self, from, to, opts: &CopyOptions, on_progress: &mut dyn FnMut(u64) -> ChunkControl) -> io::Result`, with `CopyOptions { chunk_bytes: usize, flush: bool }` and `CopyReport { bytes: u64, unrestored: Vec }`. The contract has six parts: + +- **Exclusive create.** `to` must not exist. If it does, the call fails with `AlreadyExists`. +- **Metadata.** The call preserves metadata as far as the platform allows (D3 and D8). +- **Byte count.** `bytes` is the main data stream's byte count, the same quantity progress reports. +- **Length check.** Before returning `Ok`, the implementation checks that the destination's length equals `bytes`. A mismatch is an error. +- **Flush.** With `flush: true`, the destination is also flushed to stable storage before returning, and a flush or close failure is an error. +- **Unrestored metadata.** Metadata the implementation could not carry over is listed in `unrestored` (for example "creation time", "stream Zone.Identifier", "read-only attribute"), with the OS message. It never fails the call, and it is never silently swallowed inside the filesystem layer. + +This change does not display `unrestored`. The worker ignores it, which equals today's `let _`. It is the hand-off point for `error-diagnostics`' metadata warnings, whichever of the two lands second. + +The worker's restore block moves into the implementations. `list_streams`, `copy_stream_chunked` and `set_modified` leave the trait, because `CopyFileExW` copies ADS natively. The Windows `stream_enum` FFI stays as a `RealFs`-internal helper, used only for the post-copy stream check (D3). `set_readonly` stays on the trait: it is still needed to clear a target's or source's read-only attribute. The trait still covers the operations the "Narrow internal fs trait seam" requirement lists. + +*Alternative considered:* keep the restore in the worker and add only `sync`. That keeps the per-stream copy loop and splits one platform concern across two layers. With `CopyFileExW`, the OS already did that work during the copy, and only a verification of the result is left. + +### D3: Windows copies with `CopyFileExW` + +`RealFs::copy_file` on Windows calls `CopyFileExW(from, to, Some(progress_routine), lpData, null, COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_ALLOW_DECRYPTED_DESTINATION)` on `to_extended_path` forms. + +**Progress routine.** +- `lpData` points at the caller's `&mut dyn FnMut(u64) -> ChunkControl`. +- The routine reports the cumulative bytes of **stream 1** (`dwStreamNumber == 1`, `StreamBytesTransferred`). ADS bytes therefore never push the gauge past the planned total. +- It returns `PROGRESS_CANCEL` when the callback answers `Cancel`, and `PROGRESS_CONTINUE` otherwise, including on `CALLBACK_STREAM_SWITCH`. Cancellation is still polled on every callback. +- A cancelled copy fails with `ERROR_REQUEST_ABORTED` (1235), which is mapped to the existing `Interrupted` "job cancelled" error. `CopyFileExW` deletes its partial destination itself, and the worker's cleanup (D1) is a harmless second attempt. +- The routine must not unwind across the FFI boundary. Its body runs inside `std::panic::catch_unwind`, a panic answers `PROGRESS_CANCEL` and is re-raised with `resume_unwind` after `CopyFileExW` returns. +- `CopyOptions::chunk_bytes` is ignored: the OS chooses the chunk size, and progress cadence follows it. + +**Creation time.** `CopyFileExW` does not preserve it (Context). After a successful copy, the destination is opened with `OpenOptionsExt::access_mode(FILE_WRITE_ATTRIBUTES)`, which works even though the copy already carries the read-only attribute. Then `File::set_times(FileTimes::new().set_created(src_created))` is called through `std::os::windows::fs::FileTimesExt`. A failure here does not fail the copy. It is returned as `Unrestored { what: "creation time", .. }`. + +**Stream check.** `CopyFileExW` copies named streams itself. It is not documented to report a stream it could not create, for example on a destination without named-stream support. So when the source has named streams (`stream_enum::list_streams`, the existing helper), the copy's stream list is compared after the copy, and every missing name is returned as `Unrestored { what: "stream ", .. }`. Files without named streams, the common case, pay one `FindFirstStreamW` on the source, as today. + +**Flush and length (`flush: true`).** `FlushFileBuffers` needs a write handle, and opening a read-only file for write fails. The sequence is therefore: + +1. If the copy is read-only, clear the bit. +2. Open the copy with write access. +3. Compare `len()` with the stream-1 byte count. +4. `sync_all()`. +5. Close. +6. Restore read-only. + +Without `flush`, only the length check runs, on the `FILE_WRITE_ATTRIBUTES` handle used for the creation time. + +**EFS.** `COPY_FILE_ALLOW_DECRYPTED_DESTINATION` keeps today's outcome: an encrypted file copied to a volume that cannot hold EFS still succeeds, unencrypted. Where the destination supports EFS, `CopyFileExW` keeps the copy encrypted, which is an improvement on today's plaintext read/write loop. A confirmation prompt is a non-goal. + +**FFI.** `CopyFileExW` and the `LPPROGRESS_ROUTINE` signature are declared by hand in the crate's existing style, with a `// SAFETY:` comment on each `unsafe` block. If `ci-and-build-tooling` (B-07) has already moved core's FFI to `windows-sys`, the declarations come from there instead. + +*Alternative considered:* keep the manual loop and add attribute and creation-time restore. More hand-written metadata code for strictly less fidelity, since `CopyFileExW` also handles EFS and future NTFS metadata. The application design doc (`docs/superpowers/specs/2026-08-06-filecommand-design.md`, the `fs_ops` paragraph) already chose `CopyFileEx`. It asked for an integration test to confirm that attributes and timestamps survive, and the Context measurement shows the creation time does not, hence the explicit restore. +*Alternative considered:* `std::fs::copy`, which wraps `CopyFileExW` on Windows. It has no progress or cancel hook, so the 256 KiB-granular cancel contract would regress to whole-file granularity. +*Alternative considered:* `CopyFile2`. Equivalent capability, but a different callback struct, and nothing here needs its extra features. + +### D4: Verification means flush + length check + surfaced errors, and only moves flush + +The copy half of a move passes `flush: true`. Plain Copy passes `flush: false` but still gets the length check, which costs one stat. The rationale: + +- A move deletes the only other copy, so it must not proceed on data still sitting in a write-behind cache. +- `FlushFileBuffers` per file is expensive on slow media. Copying thousands of small files to a USB stick would pay it on every file, while the source still exists, so the cost buys little. + +The R-07 audit recommendation is the same: `sync_all` plus a size check in move mode. Any verification failure is a copy failure: the temp is removed, the source is untouched, and the error-recovery dialog is shown. + +### D5: Source removal goes through the error-recovery dialog + +After the rename lands, a move removes its source inside `attempt_with_recovery(src, …)`: clear read-only, then `remove_file`. The first failure shows the existing error-recovery dialog: + +- **Retry** retries the removal. +- **Skip** and **Skip All** record a `SkippedItem { path: src, reason: "copied, but the source could not be removed: " }`. The destination copy stays. +- **Abort** stops the job. + +The `let _ =` at worker.rs:412-415 is gone. Directory cleanup stays best-effort and silent, because a directory left non-empty by a skipped file is expected. Link removal is governed by `move-reparse-point-safety`. + +### D6: The same-file check runs before a temporary file is chosen + +`copy-onto-itself-guard` adds a check to `run_copy_or_move` that skips any planned file whose target is the same file as its source. This change keeps that check where it is, ahead of D1's temporary-path chooser. A same-file item therefore never creates a `.~fc-partial` file, and a copy-then-delete move never renames a copy over its own source and then deletes it. + +*Alternative considered:* rely on D1 alone. Temp-and-rename onto the same file keeps the content, but a copy would still rewrite the file pointlessly, and a copy-then-delete move would then delete it. + +### D7: Overwrite clears read-only, then renames over the target + +On Overwrite (or Overwrite All), the worker still clears the target's read-only attribute up front. The probe showed `MoveFileEx` cannot replace a read-only file, while a hidden target is replaced fine. The target is then left untouched until step 3 of D1. A failure before that point leaves the target byte-for-byte intact, though with read-only cleared. Re-applying read-only on the failure path is not attempted: the error dialog is already in front of the user and the target's content is safe. + +### D8: The non-Windows fallback keeps the manual loop + +`portable_impl::copy_file`: + +1. Opens `to` with `OpenOptions::new().write(true).create_new(true)`. +2. Copies chunk by chunk with the same cancel handling. +3. Sets the last-write time on the open handle (`File::set_modified`). +4. `sync_all()` when `flush`, and compares `metadata().len()` with the bytes written. +5. Closes, then applies the read-only state. + +A failure in step 3 or step 5 is returned in `unrestored`, not swallowed. Creation time, extended attributes and ADS do not exist or are not exposed portably. This matches the spec's "on platforms without these attributes" clause. + +### D9: B-01 — a `RealFs` integration suite, with `FakeFs` gated to `cfg(test)` + +**The suite.** `crates/filecommand-core/tests/realfs.rs` drives `fs_ops::run_job` with `RealFs` and a small recording `JobSink` defined in the test file, which needs only the public API. If `copy-onto-itself-guard` or `move-reparse-point-safety` already created the file, this change extends its harness. Each test gets its own directory under `std::env::temp_dir()`, named with the process id and test name (the crate's existing convention, so no `tempfile` dependency), with a drop guard that clears read-only and removes the tree. Coverage: + +- **Cross-platform:** + - copy round-trip + - cancel cleanup: a multi-chunk file, a sink that cancels on the first progress event, then no target and no `.~fc-partial` + - read-only target overwrite + - copy into a destination that already holds `.~fc-partial` uses `-2` and leaves the existing file alone +- **`#[cfg(windows)]`:** + - ADS, attributes (set with `attrib +r +h +s +a`) and creation and last-write times survive a copy, read back through `MetadataExt::file_attributes`, `created()`, `modified()` and `File::open(":")` + - a failed overwrite keeps the old target: the source is held open with `share_mode(0)` so `CopyFileExW` fails with a sharing violation, the answer is Skip, and the old target content survives with no `.~fc-partial` + - paths longer than 260 characters, built under `\\?\`, copy and delete + - a case-only rename through a `Rename` job + - a cross-volume move through a `CrossVolume` wrapper that answers `same_volume = false`, so the copy-then-verified-delete path runs on one volume: the source is gone and the destination is intact + - the junction-inside-a-moved-tree test from `move-reparse-point-safety`, which shares the `mklink /J` helper + +**Gating.** `FakeFs`, `FakeNode`, `Injection`, `InjectableOp` and the `pub use fs::FakeFs` re-export in `fs_ops/mod.rs` become `#[cfg(test)]`. The module docs stop intra-linking `FakeFs` from non-test items, to avoid broken rustdoc links. No `test-support` cargo feature is added: no other crate, and no integration test, uses `FakeFs` (checked: `filecommand-tui` and its `tests/` only construct `RealFs`). The feature can be introduced the day a cross-crate consumer appears. + +*Alternative considered:* `#[cfg(any(test, feature = "test-support"))]` now, as the audit suggests. It adds a feature nothing enables yet. + +### D10: FakeFs models partial files and flush failure + +`FakeFs::copy_file`: + +- creates the destination node at the start, then grows its size chunk by chunk +- respects exclusive creation (`AlreadyExists`) +- copies the source node's modeled streams, read-only and modified fields + +An injected `CopyAfterChunks` failure therefore leaves a partial node, as a real filesystem does, and the worker tests prove D1's cleanup. A new `InjectableOp::Flush` makes a `flush: true` copy fail after the last chunk, with the node fully written, so the "flush failure keeps the source" path is testable without hardware. + +## Risks / Trade-offs + +- [An overwritten target is replaced, not rewritten in place] Hard links to the old target keep the old content. The new file carries the copy's inherited ACL rather than the old target's ACL, and the old target's creation time is replaced by the source's (which is what "preserve the source's timestamps" means). → This is the price of never destroying the target on failure, and it is documented here and in the spec scenario for a failed overwrite. +- [Moves get slower on slow media] Moves pay one `FlushFileBuffers` per file. → It applies to moves only, where the source is about to be deleted. Plain Copy is unaffected. +- [Leftover `.~fc-partial` after a crash or power loss] → Leftovers are visible, recognizable and never mistaken for the real file. The next copy of the same file uses a numbered variant rather than touching them. +- [`CopyFileExW` behavior varies by Windows version and filesystem, e.g. FAT32 has no ADS or creation-time precision] → The `RealFs` suite asserts only what NTFS guarantees, and the spec states what is not guaranteed. +- [FFI callback correctness] → `catch_unwind` in the progress routine, `SAFETY` comments, and the cancel test in the `RealFs` suite. +- [Temp name visible mid-copy] A user who presses Ctrl+R on the destination mid-copy sees `name.~fc-partial`. → This is intentional transparency. The panel re-reads automatically when the job completes, and the file is gone by then. + +## Migration Plan + +None. No persisted data or configuration changes. Leftover `.~fc-partial` files cannot exist before this change ships. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/proposal.md b/openspec/changes/archive/2026-10-14-durable-file-copy/proposal.md new file mode 100644 index 0000000..a7a326b --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/proposal.md @@ -0,0 +1,72 @@ +# Change: durable-file-copy + +## Why + +The copy engine treats "the last `write_all` returned `Ok`" as proof that a file arrived. Everything the user cares about beyond the bytes is best-effort or missing. Four audit findings describe the result: + +- **R-07 (High) — cross-volume Move deletes its source without verifying the copy.** `copy_chunked_impl` never calls `sync_all` and never compares lengths, and it closes the destination by `Drop`, which discards `CloseHandle` errors. The move then removes the source with `let _ = fs.remove_file(..)`. Deferred write errors on SMB shares and removable media surface only at flush or close, so a move to a USB stick followed by a yank or power loss can leave the destination incomplete with the source already gone. A source that *cannot* be removed (for example, open in another program) is silently left behind, and the job still reports success. +- **R-08 (Medium) — failed copies leave partial files, and Overwrite destroys the target first.** The partial file is removed only on cancel. A disk-full or sharing-violation error leaves a truncated file under the target's name. After a Skip, that file looks like a successful copy. Overwrite opens the target with `File::create`, which truncates it before a single byte is copied, so a mid-copy failure loses the user's previous version too. +- **R-14 (Medium) — Copy loses attributes and the creation time.** The engine restores only the last-write time, read-only and named ADS. Copied hidden or system files become visible, archive state is lost, and backups lose their creation dates. The spec requirement "Copy preserves alternate data streams, attributes, and timestamps" is only partly met. +- **B-01 (High) — the real filesystem engine has no tests, and the test double ships in production.** No test constructs `RealFs`. Every copy, move and delete test runs against `FakeFs`, whose path model differs from NTFS exactly where R-01, R-05, R-07, R-08 and R-14 live. `FakeFs` and `InjectableOp` are `pub` and compiled into the release binary. + +Verifying R-08 also turned up a defect the audit does not list: copying a file onto itself truncates it to zero bytes. It is Critical data loss with a one-check fix, so it is tracked separately in `copy-onto-itself-guard` and is not part of this change. + +## What Changes + +- **Verified move.** The copy half of a cross-volume move flushes each destination file to stable storage and checks that its length equals the bytes copied, before the source is touched. A flush, close or length failure is a copy failure: the source is kept, and the error-recovery dialog offers Retry/Skip/Skip All/Abort. Removing the source goes through the same error-recovery dialog, and a source that cannot be removed is reported in the end-of-job summary instead of being ignored. +- **Copies land via a temporary partial file.** Each file is written to `.~fc-partial` in the destination directory and renamed over the target name only after the copy succeeded. The temporary file is removed on every error, Skip, Abort and Cancel path. An Overwrite never destroys the existing target when the copy fails, and a failed copy never leaves a truncated file under the target's name. +- **Full metadata on Windows via `CopyFileExW`.** On Windows the copy uses `CopyFileExW`. It copies the data, every named ADS, the read-only, hidden, system and archive attributes, and the last-write time. Its progress routine maps the existing `ChunkControl` callback, with `Cancel` returning `PROGRESS_CANCEL`. The creation time, which `CopyFileExW` does not preserve, is set explicitly afterwards. The manual read/write loop remains the non-Windows fallback. The worker's per-stream copy loop goes away. The `FindFirstStreamW` enumeration stays only to report a named stream that did not arrive on the copy. Metadata the copy could not carry over is returned to the worker, not discarded inside the filesystem layer, so `error-diagnostics` can surface it. ACLs and owner are not copied: the copy inherits the destination's permissions, as with Explorer. EFS is kept where the destination supports it. +- **Real-filesystem integration tests; test-only fault injection.** A new `crates/filecommand-core/tests/realfs.rs` suite runs `run_job` on temporary directories against `RealFs`. Windows-only behavior is gated with `#[cfg(windows)]`. The suite covers: + - copy with ADS, attributes and timestamps + - a junction inside a moved tree (shared with `move-reparse-point-safety`) + - paths longer than 260 characters + - a read-only target + - a case-only rename + - cancel cleanup + - a failed overwrite + + `FakeFs`, `InjectableOp` and their re-export become `#[cfg(test)]`. +- Non-breaking for users, apart from the documented trade-offs in design.md: an overwritten target is replaced rather than rewritten in place, and moves pay one flush per file. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `file-operations`: + - "Same-volume move is a rename; cross-volume move is copy-then-verified-delete" defines *verified*: flushed, length-checked, with close and flush errors surfaced. It also requires source-removal failures to surface. + - "Copy preserves alternate data streams, attributes, and timestamps" enumerates exactly what is preserved and what is not. + - New requirement: "Copies land via a temporary partial file." +- `filesystem-error-handling`: new requirement "Real filesystem engine is integration-tested; fault injection is test-only." + +## Impact + +- **Specs:** `file-operations` (two modified, one added requirement), `filesystem-error-handling` (one added requirement). +- **Code:** + - `crates/filecommand-core/src/fs_ops/fs.rs`: + - `FileSystem::copy_file_chunked` becomes a metadata-preserving `copy_file(from, to, &CopyOptions { chunk_bytes, flush }, on_progress) -> io::Result` with exclusive creation of `to`. + - `list_streams`, `copy_stream_chunked` and `set_modified` leave the trait. + - The Windows `copy_chunked_impl` (lines 247-271) is replaced by a `CopyFileExW` path. + - The portable `copy_file_chunked` (lines 324-350) gets temp-safe exclusive create, `set_modified`, optional `sync_all`, and a length check. + - `stream_enum` (lines 491-579) stays as a `RealFs`-internal helper for the post-copy stream check. + - `FakeFs` gets partial-file modeling, a flush-failure injection, and `#[cfg(test)]` gating (lines 581-955). + - `crates/filecommand-core/src/fs_ops/worker.rs`: + - `copy_one_file` (lines 267-318) gets temp naming, rename-over, and cleanup on every path. Its metadata-restore block (lines 289-299) moves into the `FileSystem` implementations. + - `run_copy_or_move` (lines 324-439) gets `flush` for moves and source removal through `attempt_with_recovery` (replacing `let _` at 412-415). + - `crates/filecommand-core/src/fs_ops/mod.rs`: the `FakeFs` re-export becomes `#[cfg(test)]`. + - New `crates/filecommand-core/tests/realfs.rs`, or an extension of it if `copy-onto-itself-guard` or `move-reparse-point-safety` created it first. + - No `filecommand-tui` changes. Its only `fs_ops` filesystem use is `RealFs::new()` in `worker.rs:416`. +- **Dependencies:** none new. `CopyFileExW` is declared by hand, matching the crate's existing FFI style, or taken from `windows-sys` if `ci-and-build-tooling` (B-07) has already moved core's FFI there. + +**Audit findings:** R-07, R-08, R-14, B-01 + +**Coordination:** +- `copy-onto-itself-guard` (Critical, not in the audit) adds a same-file check to the same `run_copy_or_move` loop and should land first. This change keeps that check ahead of choosing the temporary file, so a same-file item never creates a `.~fc-partial` file. Both changes add tests to `crates/filecommand-core/tests/realfs.rs`, and whichever lands first creates it. +- `move-reparse-point-safety` changes the same `run_copy_or_move` loop and `FakeFs`, and adds the junction test to the shared `realfs.rs` suite. Its link removal follows this change's "source-removal failures surface" rule. Whichever change lands second rebases. Neither depends on the other. +- `idle-aware-event-loop` (P-11) throttles the progress reports `copy_one_file`'s callback sends. With `CopyFileExW`, the callback cadence is chosen by the OS, and throttling stays in the worker callback. +- `error-diagnostics` (B-06) turns best-effort metadata-restore failures into summary warnings, and today it targets the worker's restore loop, which this change removes. `CopyReport::unrestored` is the hand-off. Every piece of metadata the copy could not carry over is returned there, never swallowed: the creation time, a named stream missing on the copy, and on the portable path the last-write time or read-only state. A metadata failure never fails the copy. Whichever change lands second maps `unrestored` onto `error-diagnostics`' warning items. If this change lands first, the worker drops `unrestored`, which is no worse than today's `let _`. +- `process-lifecycle-hardening` (R-11, R-18) waits for cancellation cleanup on quit and makes a blocked job observe cancel. Removing the `.~fc-partial` file is part of that cleanup. +- `ci-and-build-tooling` (B-07, B-09): if core's FFI moves to `windows-sys` first, `CopyFileExW` comes from there. The README's "file ops against temp directories are tested" claim becomes true with this change. diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/specs/file-operations/spec.md b/openspec/changes/archive/2026-10-14-durable-file-copy/specs/file-operations/spec.md new file mode 100644 index 0000000..49b1c2c --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/specs/file-operations/spec.md @@ -0,0 +1,109 @@ +## MODIFIED Requirements + +### Requirement: Same-volume move is a rename; cross-volume move is copy-then-verified-delete + +A move (F6 Rename/Move) whose source and destination reside on the same volume SHALL be performed as a single `rename` (instant, no data movement). A move across volumes SHALL be performed as a copy followed by deletion of the source, and the source SHALL be deleted only after the copy has been verified successful. Make-directory (F7) SHALL create the named directory at the destination. + +A copied file SHALL count as verified only when all of the following hold: + +- its data has been flushed to stable storage; +- the destination's length equals the number of bytes copied; +- no write, flush, or close error was reported. + +Any such error, or a length mismatch, SHALL be treated as a failure of that file's copy. The failure SHALL be offered through the error-recovery dialog, and the source SHALL be kept. + +Removing a source after a verified copy SHALL also be offered through the error-recovery dialog when it fails. A source that cannot be removed SHALL NOT be silently ignored. When the user skips it, it SHALL be listed in the end-of-job skipped-files summary as copied but not removed. + +#### Scenario: Same-volume move renames instead of copying +- **WHEN** an entry is moved to a destination on the same volume +- **THEN** the operation is a single `rename` and no byte-copy of the content occurs + +#### Scenario: Cross-volume move copies then deletes the source +- **WHEN** an entry is moved to a destination on a different volume +- **THEN** the content is copied to the destination and, only after the copy is verified, the source is deleted + +#### Scenario: Cross-volume source survives a failed copy +- **WHEN** a cross-volume copy fails before completing/verifying +- **THEN** the source is NOT deleted + +#### Scenario: A flush failure keeps the source +- **WHEN** a cross-volume move has written all of `a.txt`'s data to the destination but flushing it to stable storage reports an error +- **THEN** the error-recovery dialog is shown for `a.txt` and the source `a.txt` is not deleted +- **AND** if the user chooses Skip, no file named `a.txt` and no partial file is left at the destination + +#### Scenario: A source that cannot be removed is reported +- **WHEN** a cross-volume move copies and verifies `a.txt` but removing the source `a.txt` fails because another program has it open +- **THEN** the error-recovery dialog is shown for `a.txt` +- **AND** if the user chooses Skip, the verified copy stays at the destination and the end-of-job summary lists `a.txt` as copied but not removed + +### Requirement: Copy preserves alternate data streams, attributes, and timestamps + +A copy, including the copy half of a cross-volume move, SHALL preserve on the copied file: + +- the source's named alternate NTFS data streams; +- the read-only, hidden, system, and archive attributes; +- the creation and last-write timestamps. + +On a platform or filesystem that lacks some of these, the copy SHALL preserve the ones it supports, and at minimum the last-write time and the read-only state. + +A copy SHALL NOT be required to preserve: + +- the source's security descriptor (ACLs and owner). The copy takes the permissions inherited at its destination. +- the last-access time. +- sparse or compression state. +- EFS encryption on a destination that cannot hold it. The copy is then written unencrypted. + +Where a target already exists and carries a read-only attribute that would block an overwrite or delete, the operation SHALL clear that attribute as needed to proceed. + +#### Scenario: Alternate data streams and metadata survive a copy +- **WHEN** a file carrying an alternate data stream and specific timestamps/attributes is copied +- **THEN** the copied file has the same alternate data stream content, attributes, and timestamps as the source + +#### Scenario: Read-only target is cleared before overwrite +- **WHEN** an overwrite (or delete) targets a file whose read-only attribute would otherwise block the operation +- **THEN** the read-only attribute is cleared so the operation can complete + +#### Scenario: Hidden and system attributes survive a copy +- **WHEN** a file with the hidden, system, and archive attributes set is copied +- **THEN** the copy has the hidden, system, and archive attributes set, so it renders as hidden in the destination panel just as the source does + +#### Scenario: Creation time survives a copy +- **WHEN** a file created at 2001-02-03 04:05 and last written at 2002-03-04 05:06 is copied +- **THEN** the copy reports a creation time of 2001-02-03 04:05 and a last-write time of 2002-03-04 05:06 + +#### Scenario: A moved file keeps its metadata +- **WHEN** a read-only file carrying an alternate data stream is moved across volumes +- **THEN** the file at the destination has the same alternate data stream content, the read-only attribute, and the source's creation and last-write timestamps + +## ADDED Requirements + +### Requirement: Copies land via a temporary partial file + +A copy, including the copy half of a cross-volume move, SHALL write each file's data to a temporary file in the target's directory named `.~fc-partial`. If that name is already taken, a numbered variant `.~fc-partial-2`, `-3`, … SHALL be used. If the name would exceed the filename length limit, `` SHALL be shortened. The temporary file SHALL be created exclusively, so no existing file is ever overwritten or removed to make room for it. + +Only after the copy of that file has succeeded SHALL the temporary file be renamed to the target name. On Overwrite, that rename replaces the existing target. + +On every other outcome for that file, the temporary file SHALL be removed. That covers a copy or verification error, a rename error, a Skip, Skip All, or Abort chosen in the error-recovery dialog, and cancellation. + +As a result, an existing target chosen for Overwrite SHALL remain intact until its replacement is complete, and a failed copy SHALL never leave a truncated file under the target's name. + +#### Scenario: A successful copy appears under the target name with no partial file left +- **WHEN** `report.txt` is copied to `D:\backup` and the copy completes +- **THEN** `D:\backup\report.txt` holds the complete content and no `D:\backup\report.txt.~fc-partial` exists + +#### Scenario: A failed overwrite keeps the existing target +- **WHEN** the user chooses Overwrite for an existing `D:\backup\report.txt` and the copy then fails partway (for example, the disk becomes full) +- **THEN** `D:\backup\report.txt` still holds its previous, complete content +- **AND** no `D:\backup\report.txt.~fc-partial` remains once the user answers the error-recovery dialog + +#### Scenario: Skip after a failed copy leaves nothing behind +- **WHEN** copying `big.iso` to `D:\backup` (where no `big.iso` existed) fails partway and the user chooses Skip in the error-recovery dialog +- **THEN** neither `D:\backup\big.iso` nor `D:\backup\big.iso.~fc-partial` exists, and `big.iso` is listed in the end-of-job skipped-files summary + +#### Scenario: Cancel removes the partial file +- **WHEN** the user cancels a job while `big.iso` is partway through being copied to `D:\backup` +- **THEN** neither `D:\backup\big.iso` nor any `D:\backup\big.iso.~fc-partial*` file remains from that copy + +#### Scenario: An existing file with the temporary name is left alone +- **WHEN** `D:\backup` already contains a file named `report.txt.~fc-partial` and `report.txt` is copied there +- **THEN** that existing file is not modified or removed, the copy uses `report.txt.~fc-partial-2` as its temporary file, and `D:\backup\report.txt` receives the copy diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/specs/filesystem-error-handling/spec.md b/openspec/changes/archive/2026-10-14-durable-file-copy/specs/filesystem-error-handling/spec.md new file mode 100644 index 0000000..a138386 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/specs/filesystem-error-handling/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Real filesystem engine is integration-tested; fault injection is test-only + +The real filesystem implementation of the fs trait seam SHALL be covered by an automated integration-test suite. The suite SHALL run file-operation jobs through the real implementation against temporary directories on the real filesystem, and SHALL remove those directories afterwards. It SHALL cover at least: + +- copying a file that carries alternate data streams, attributes, and timestamps; +- a junction inside a tree that is moved; +- paths longer than 260 characters; +- overwriting a read-only target; +- a case-only rename; +- cleanup after a cancelled copy. + +Tests that depend on Windows- or NTFS-specific behavior SHALL be compiled and run only on Windows. The fake filesystem and its fault-injection API SHALL be compiled only for tests and SHALL NOT be part of the release binary. + +#### Scenario: The suite exercises the real implementation on temporary directories +- **WHEN** `cargo test --workspace` runs on Windows +- **THEN** the real-filesystem suite runs copy, move, delete, and rename jobs through the real implementation on temporary directories, asserts the resulting on-disk state, and leaves none of its temporary directories behind + +#### Scenario: Windows-specific tests are gated +- **WHEN** the test suite is built for a non-Windows target +- **THEN** the alternate-data-stream, attribute, junction, and long-path tests are not compiled, and the platform-neutral real-filesystem tests still run + +#### Scenario: The release binary carries no fault-injection double +- **WHEN** the workspace is built without tests (for example `cargo build --release`) +- **THEN** the fake filesystem and its fault-injection types are not compiled into `filecommand-core`, and no non-test code can refer to them diff --git a/openspec/changes/archive/2026-10-14-durable-file-copy/tasks.md b/openspec/changes/archive/2026-10-14-durable-file-copy/tasks.md new file mode 100644 index 0000000..12fe214 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-durable-file-copy/tasks.md @@ -0,0 +1,64 @@ +# Tasks: durable-file-copy + +## 1. FileSystem copy contract (`crates/filecommand-core/src/fs_ops/fs.rs`) + +- [x] 1.1 Replace `copy_file_chunked` with `copy_file(from, to, opts: &CopyOptions { chunk_bytes, flush }, on_progress) -> io::Result`, documenting the contract on the trait (design D2): + - `to` is created exclusively (`AlreadyExists` if present). + - Metadata is preserved. + - `bytes` is the main-stream byte count, and a length mismatch is an error. + - With `flush`, the data is flushed to stable storage, and flush or close errors are returned. + - Metadata that could not be carried over is listed in `unrestored` and never fails the call. +- [x] 1.2 Remove `list_streams`, `copy_stream_chunked` and `set_modified` from the trait. Keep `set_readonly`. The Windows `stream_enum` helper stays as `RealFs`-internal code (design D2) +- [x] 1.3 Windows `RealFs::copy_file`: declare `CopyFileExW` and the progress-routine type, with a `// SAFETY:` comment per `unsafe` block, or use `windows-sys` if `ci-and-build-tooling` landed first. Call it with `COPY_FILE_FAIL_IF_EXISTS | COPY_FILE_ALLOW_DECRYPTED_DESTINATION`. The progress routine reports stream-1 cumulative bytes, maps `ChunkControl::Cancel` to `PROGRESS_CANCEL`, and wraps the callback in `catch_unwind`, re-raising after return. `ERROR_REQUEST_ABORTED` maps to the existing `Interrupted` cancelled error (design D3) +- [x] 1.4 Windows: after a successful `CopyFileExW`, set the source's creation time through a `FILE_WRITE_ATTRIBUTES` handle (`OpenOptionsExt::access_mode`, `File::set_times` + `FileTimesExt::set_created`). A failure goes to `unrestored`. Without `flush`, check the length on that handle. With `flush`: clear read-only if set, open for write, check the length, `sync_all`, close, and restore read-only (design D3, D4) +- [x] 1.5 Windows: when the source has named streams (`stream_enum::list_streams`, now `RealFs`-internal), list the copy's streams after the copy and report each missing name in `unrestored` (design D3) +- [x] 1.6 Delete the Windows `copy_chunked_impl` (design D3) +- [x] 1.7 Portable `RealFs::copy_file`: `create_new(true)`, the chunk loop with cancel handling, `File::set_modified`, `sync_all` when `flush`, a length check via `metadata()`, then read-only applied after close. `set_modified` and read-only failures go to `unrestored` (design D8) + +## 2. FakeFs (`fs_ops/fs.rs`, `fs_ops/mod.rs`) + +- [x] 2.1 `FakeFs::copy_file`: fail with `AlreadyExists` if `to` exists. Create the destination node before the first chunk and grow it per chunk, so an injected `CopyAfterChunks` failure or a cancel leaves a partial node. Copy the source node's streams, read-only and modified fields on success (design D10) +- [x] 2.2 Add `InjectableOp::Flush`: a `flush: true` copy of the injected path fails after its last chunk, leaving the fully written node (design D10) +- [x] 2.3 Gate `FakeNode`, `Injection`, `InjectableOp`, `FakeFs` and its `impl FileSystem` with `#[cfg(test)]`, and the `FakeFs` item in `fs_ops/mod.rs`'s `pub use`. Reword the module docs so no non-test item intra-links `FakeFs` (filesystem-error-handling: "The release binary carries no fault-injection double"; design D9) +- [x] 2.4 Update the existing `fs.rs` `FakeFs` unit tests to the new `copy_file` API. Drop or rewrite `fake_fs_streams_roundtrip` against `copy_file`'s stream propagation + +## 3. Worker (`crates/filecommand-core/src/fs_ops/worker.rs`) + +- [x] 3.1 Add the temp-path chooser: `.~fc-partial` in the target's directory, the first free numbered variant `-2`, `-3`, … (bounded), and the base name shortened to fit 255 UTF-16 units (file-operations: "Copies land via a temporary partial file"; design D1) +- [x] 3.2 Rewrite `copy_one_file` as: choose temp → `fs.copy_file(src, temp, …)` → `fs.rename(temp, final_target)`. Best-effort `fs.remove_file(temp)` runs on every non-success path: copy or verify error, rename error, cancel, Skip, Skip All, Abort. Retry restarts from a fresh temp. Delete the old metadata-restore block. `CopyReport::unrestored` is not displayed by this change: either map it onto `error-diagnostics`' warning items if that change has landed, or drop it with a comment pointing at that change (design D1, D2) +- [x] 3.3 `run_copy_or_move`: pass `flush: is_move` (design D4) +- [x] 3.4 `run_copy_or_move`: keep `copy-onto-itself-guard`'s same-file check ahead of the temp-path chooser, so a same-file item never creates a `.~fc-partial` file. If that change has not landed yet, land it first (design D6) +- [x] 3.5 Overwrite keeps clearing the target's read-only attribute up front. The target is otherwise untouched until the final rename (design D7) +- [x] 3.6 Move source removal: replace `let _ = fs.set_readonly(..); let _ = fs.remove_file(..)` with `attempt_with_recovery` (clear read-only, then `remove_file`). Skip and Skip All record `SkippedItem { path: src, reason: "copied, but the source could not be removed: …" }`, and Abort cancels the job (file-operations: "A source that cannot be removed is reported"; design D5) + +## 4. Worker tests (FakeFs, `fs_ops/worker.rs`) + +- [x] 4.1 A successful copy leaves only the target, with no `.~fc-partial` node (file-operations: "A successful copy appears under the target name with no partial file left") +- [x] 4.2 Overwrite plus `CopyAfterChunks` failure plus Skip: the original target node is unchanged (size and modified time) and no `.~fc-partial` node remains (file-operations: "A failed overwrite keeps the existing target") +- [x] 4.3 A new-target copy with `CopyAfterChunks` failure plus Skip: no target and no partial; the summary lists the source (file-operations: "Skip after a failed copy leaves nothing behind") +- [x] 4.4 A cancel mid-copy leaves no partial node and no target (file-operations: "Cancel removes the partial file") +- [x] 4.5 An existing `report.txt.~fc-partial` node is untouched, and the copy lands via `-2` (file-operations: "An existing file with the temporary name is left alone") +- [x] 4.6 A cross-volume move with a `Flush` injection plus Skip keeps the source and leaves no partial node (file-operations: "A flush failure keeps the source") +- [x] 4.7 A cross-volume move with a `RemoveFile` injection on the source plus Skip: the destination node exists, and the summary lists the source with "could not be removed" (file-operations: "A source that cannot be removed is reported") +- [x] 4.8 The existing `move_cross_volume_*`, `overwrite_all_*`, `skip_all_*`, `error_*`, `cancellation_*` and `permission_denied_*` tests pass, adjusted only for the API rename + +## 5. RealFs integration suite (`crates/filecommand-core/tests/realfs.rs`) + +- [x] 5.1 Harness: a per-test temp directory named with the process id and test name under `std::env::temp_dir()`, with a drop guard that clears read-only recursively and removes the tree. A recording `JobSink` with scripted conflict and error answers and a cancel-after-N-progress option. On Windows only: a `cmd /C mklink /J` helper and a `CrossVolume` wrapper answering `same_volume = false`. If `copy-onto-itself-guard` or `move-reparse-point-safety` already created this file, extend its harness instead (design D9) +- [x] 5.2 Cross-platform: a copy round-trip; cancel cleanup (multi-chunk file, cancel on first progress → no target, no `*.~fc-partial*`); a read-only target overwritten through an Overwrite answer; an existing `name.~fc-partial` file is left alone (filesystem-error-handling: "The suite exercises the real implementation on temporary directories") +- [x] 5.3 `#[cfg(windows)]`: ADS (`std::fs::write("f:stream")`), `attrib +r +h +s +a`, and creation and last-write times set with `File::set_times` all survive a Copy and a `CrossVolume` Move. Read back via `file_attributes()`, `created()`, `modified()` and `File::open(":")` (file-operations: "Hidden and system attributes survive a copy", "Creation time survives a copy", "A moved file keeps its metadata") +- [x] 5.4 `#[cfg(windows)]`: failed overwrite. Hold the source open with `share_mode(0)`, answer Overwrite then Skip, and assert the old target's content is intact and no partial remains (file-operations: "A failed overwrite keeps the existing target") +- [x] 5.5 `#[cfg(windows)]`: a path over 260 characters, built under `\\?\`, copies, moves (`CrossVolume`) and deletes successfully (file-operations: "Operation on a path longer than the legacy limit succeeds") +- [x] 5.6 `#[cfg(windows)]`: a case-only rename (`foo.txt` → `Foo.txt`) through a `Rename` job succeeds with no conflict (file-operations: "Case-only rename succeeds") +- [x] 5.7 `#[cfg(windows)]`: a cross-volume move through `CrossVolume` removes the source only after the destination exists with full length (file-operations: "Cross-volume move copies then deletes the source") +- [x] 5.8 Hold a slot for `move-reparse-point-safety`'s junction-in-moved-tree test, which shares the `mklink /J` helper. If that change has not landed, add a TODO pointing at it + +## 6. Verify + +- [x] 6.1 `cargo test --workspace` green on Windows, with the `RealFs` suite running and no leftover temp directories +- [x] 6.2 `cargo build --workspace --release` succeeds, and the release `filecommand-core` exposes no `FakeFs`/`InjectableOp` (a non-test reference fails to compile) +- [x] 6.3 `cargo clippy --workspace --all-targets` introduces no new warnings +- [x] 6.4 `openspec validate durable-file-copy --strict` clean +- [ ] 6.5 Manual (Windows): + - F5 a hidden+system file and confirm it stays hidden at the destination with its creation date. + - Fill a small USB stick, F5 Overwrite a file there, and confirm the old file survives the disk-full error. diff --git a/openspec/changes/archive/2026-10-14-editor-encoding-safety/.openspec.yaml b/openspec/changes/archive/2026-10-14-editor-encoding-safety/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-editor-encoding-safety/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-editor-encoding-safety/design.md b/openspec/changes/archive/2026-10-14-editor-encoding-safety/design.md new file mode 100644 index 0000000..1850cb8 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-editor-encoding-safety/design.md @@ -0,0 +1,148 @@ +# Design: editor-encoding-safety + +## Context + +This was verified against `build/open-proposals-2026-10` at `1bada6f`. + +**Open path.** F4 on a panel file runs `update::handle_request_editor`. If an external editor is configured, that wins. Otherwise the reducer emits `Effect::OpenEditor { path }`. `run_effects` in `crates/filecommand-tui/src/app.rs` executes it synchronously by calling `EditorState::open(&path)`: + +- `open` stats the file. At or above `EDITOR_MAX_BYTES` (10 MiB) it returns `LoadResult::TooLarge { size }` without reading the file. +- Otherwise it reads the whole file and calls `EditorState::from_bytes`. +- `from_bytes` decodes with `viewer::decode::decode_lossy`, which is `String::from_utf8_lossy` (the audit says `from_utf8_lossy` directly; the call goes through the viewer's helper, with the same effect). It then splits lines with `logical_lines`, which strips one trailing `\r` per line, and detects the dominant line ending from the raw bytes. + +The TUI turns `TooLarge` into `Command::EditorTooLarge { path, size }`. The reducer then: + +1. sets the active panel's `last_error` to "`` is too large for the editor (10 MB limit) — opened in the viewer". This is the panel mini-status, visible once the viewer closes. +2. emits `Effect::OpenViewer { path }`. + +Nothing inspects the content. Invalid UTF-8 becomes U+FFFD, NUL bytes load as control characters, and a UTF-16 file loads as mojibake interleaved with NULs. + +**UTF-8 BOM today.** `from_utf8_lossy` keeps the BOM, so U+FEFF becomes the first character of `lines[0]`. It is written back on save only because it is still in the buffer. It is a zero-width character that counts as a caret column, though. Home puts the caret in front of it, so typing there pushes the BOM into the middle of the line. One Right arrow from (0,0) moves the caret past it with no visible movement, and a Backspace there then deletes it without any visible change. + +**Save path.** `Command::EditorSave` emits `Effect::SaveEditor { editor, then_quit }`, which `run_effects` executes synchronously with `EditorState::save()`: + +- `save` calls `std::fs::write(to_extended_path(&self.path), self.to_bytes())`. This truncates the file, then writes it. +- There is no flush to stable storage, no temporary file, and no attribute handling. +- On a read-only file, `std::fs::write` fails with the OS's access-denied error. The TUI sends `Command::EditorSaveFailed { message }`, and the reducer stores the message in `EditorState::save_error`. The original file is untouched in that case, but `save_error` is **never rendered** (no view reads it), so the user only sees the ` *` marker stay. The field's doc comment also says it is "cleared by the next edit", but only `save()` clears it. + +Mixed line endings are normalized to the dominant ending on save. That is specified behavior, not in scope here. + +## Goals / Non-Goals + +**Goals:** + +- The built-in editor never loads content it cannot write back byte-exactly apart from edits and line-ending normalization. Files that are not UTF-8 go to the viewer, using the same mechanism and notice surface as the size redirect. +- A UTF-8 BOM survives a save, and the user can never edit it in or out by accident. +- F2 is crash-safe. At every point the file on disk is either the complete old content or the complete new content, and the new content is flushed before it replaces the old. +- Saving does not silently change the file's attributes, creation time, or (on Windows) DACL or alternate data streams. Today's truncate-and-write keeps all of them because the file object survives, so the atomic save must not regress them. Saving never makes a read-only file writable. +- A failed save is visible to the user. + +**Non-Goals:** + +- Editing legacy code pages or UTF-16. Supporting them needs encoding detection and a transcoding writer; the redirect is the safe floor. A read-only view of the decoded text already exists in F3. +- Preserving mixed line endings. Dominant-ending normalization stays as specified. +- Moving open or save off the UI thread. That is `offload-blocking-io` (R-09/P-09). +- Copy and Move attribute preservation (`durable-file-copy`, R-14) and `config.toml`/`history.json` durability (`config-parsing-robustness`, `history-persistence-hardening`). They only share the helper introduced here. +- Keeping hard-link identity (see Risks). + +## Decisions + +### D1: Classify bytes after the size gate, in core, before building the buffer + +`EditorState::open` keeps its order: stat, size gate, read. After reading it calls a new pure function, `classify_editor_bytes(&[u8]) -> Result, NotEditableReason>`. Checks run in this order, and the first match wins: + +1. Starts with `FF FE` or `FE FF` → `NotEditableReason::Utf16`. This also catches UTF-32LE, which starts `FF FE 00 00`. +2. Contains a `0x00` byte anywhere → `NotEditableReason::Binary`. The check is a `memchr`-style scan of at most 10 MiB, already in memory. +3. Starts with `EF BB BF` → remember `has_bom = true` and validate the rest. +4. `std::str::from_utf8` on the remainder fails → `NotEditableReason::NotUtf8`. + +Otherwise it returns the validated `&str` and the BOM flag. `open` returns the new variant `LoadResult::NotEditable { reason }`. + +The order is chosen for the notice. A UTF-16 file is also invalid UTF-8 and usually contains NULs, but "UTF-16 text" tells the user more than "not UTF-8". UTF-16 without a BOM ends up as Binary because ASCII-range UTF-16 contains NULs, and that message is still accurate. + +The check runs only on the built-in editor path. With an external editor configured, F4 never reaches `Effect::OpenEditor`, so external editors still get every file (existing "External editor takes precedence" scenario). + +Alternatives considered: + +- **Open read-only in the editor.** The audit suggests this as an option. It needs a new editor mode and a lossy display, and the viewer already does read-only display well. The user chose the viewer redirect. +- **Heuristic "looks binary" checks**, such as the share of control characters. These are not well defined and are not needed: NUL plus strict UTF-8 validation catches real binaries, and valid UTF-8 text with control characters round-trips exactly. + +### D2: The redirect reuses the TooLarge mechanism with its own reply command + +The new reply `Command::EditorNotEditable { path, reason }` is handled exactly like `Command::EditorTooLarge`: it sets the active panel's `last_error` notice and emits `Effect::OpenViewer { path }`. The notice text depends on the reason: + +- ` is UTF-16 text — the editor only edits UTF-8; opened in the viewer` +- ` looks binary (contains NUL bytes) — the editor only edits UTF-8 text; opened in the viewer` +- ` is not valid UTF-8 — the editor only edits UTF-8 text; opened in the viewer` + +`EditorTooLarge` stays as it is, so `offload-blocking-io` and the existing tests keep their shape. + +Alternative considered: one `EditorRedirect { reason }` command that replaces `EditorTooLarge`. This is cleaner, but it renames a command that another in-flight proposal (offload-blocking-io) depends on, and the benefit is cosmetic. + +### D3: Strip the UTF-8 BOM into a flag and write it back on save + +`EditorState` gains `has_bom: bool`. The buffer is built from the text after the BOM, so `lines[0]` never contains U+FEFF. `to_bytes()` prefixes `EF BB BF` when `has_bom` is set. Because `to_bytes` is what `save` writes and what `byte_len` measures, the header's size figure still matches what F2 writes, and that figure includes the BOM. + +Alternative considered: keeping the BOM as a buffer character. That is today's behavior, with the caret and editing problems described in Context. + +### D4: Atomic save through a shared durable-replace helper + +A new core module (working name `crate::durable`) provides `replace_file(path: &Path, bytes: &[u8]) -> io::Result<()>`. It writes through `to_extended_path` throughout, in these steps: + +1. **Resolve the target.** If `path` is a symbolic link or another name-surrogate reparse point, it is resolved to its final target, and that file is replaced. The link stays a link. This mirrors how `std::fs::write` follows links today. +2. **Snapshot the original.** If the target exists, read its metadata: attributes, read-only flag, and on non-Windows the permission mode. +3. **Create the temporary file.** It goes in the target's directory with a unique name, `....fc-tmp`, using `create_new`. + - On Windows it is created with the original's HIDDEN, SYSTEM, ARCHIVE, and NOT_CONTENT_INDEXED attribute bits, through `std::os::windows::fs::OpenOptionsExt::attributes`. + - On other platforms its permission mode is set to the original's. +4. **Write and flush.** Write all bytes, then call `File::sync_all()` and close the file. +5. **Replace the original.** + - **Windows, original exists:** call `ReplaceFileW(target, temp, backup, REPLACEFILE_IGNORE_MERGE_ERRORS | REPLACEFILE_IGNORE_ACL_ERRORS)`, where `backup` is a sibling `....fc-bak`. ReplaceFile documents that it carries over the original's creation time, DACL, named streams (ADS, for example `Zone.Identifier`), object ID, encryption, and compression. It does not carry over the owner or the SACL, so the spec promises the DACL only, and `IGNORE_ACL_ERRORS` makes the DACL best-effort on volumes without ACLs. A review probe through .NET's `File.Replace`, which calls `ReplaceFile` with these two flags, confirmed on NTFS that the creation time, the hidden attribute and a `Zone.Identifier` stream survive, that the result takes the *original's* attributes rather than the temporary file's, and that replacing a read-only original fails with "Access is denied." Setting the attribute bits on the temporary file (step 3) is therefore redundant on this path, though harmless. On success the backup is deleted, and a failure to delete it is ignored. The documented partial-failure codes are handled with the backup present, so neither copy is ever lost: + - `ERROR_UNABLE_TO_REMOVE_REPLACED` and `ERROR_UNABLE_TO_MOVE_REPLACEMENT`: both files keep their names. The temp file is deleted and the error is returned. + - `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`: the original was moved to the backup name. It is renamed back, the temp file is deleted, and the error is returned. + - **Windows, original missing** (deleted while it was being edited): `ReplaceFileW` reports file-not-found, and the helper falls back to `std::fs::rename(temp, target)`. + - **Non-Windows:** `std::fs::rename(temp, target)`, then a best-effort `sync_all` of the parent directory. +6. **Clean up on failure.** On any error before the replace succeeds, the temporary file is removed and the error is returned unchanged. The original is untouched. + +`EditorState::save` makes two calls in order: `check_writable(target)`, then `durable::replace_file(target, &self.to_bytes())`. On success it clears `save_error` and resets `saved_snapshot` as it does today. + +`ReplaceFileW` comes from `windows-sys` (features `Win32_Storage_FileSystem` and `Win32_Foundation`), added as a `cfg(windows)` dependency of `filecommand-core`. The TUI already depends on windows-sys 0.59. This follows the direction of audit B-07 (`ci-and-build-tooling`) instead of adding another hand-declared `extern "system"` block, and the `unsafe` call carries a `// SAFETY:` comment: the paths are NUL-terminated wide strings that live for the call, and the reserved pointers are null. + +Alternatives considered: + +- **Temp file, `sync_all`, `std::fs::rename` on every platform, then re-apply attributes and creation time with `File::set_times`/`FileTimesExt::set_created`.** This needs no FFI, but it loses the original's explicit DACL and its alternate data streams. Losing `Zone.Identifier` silently removes the Mark of the Web from downloaded scripts on edit. The file-operations spec already treats ADS as data worth keeping. +- **Keep `std::fs::write` and add `sync_all`.** This flushes data but does nothing about truncation: a crash between the truncate and the write still leaves a short file. +- **Fall back to an in-place write when the temporary file cannot be created**, for example when the directory denies create permission but the file allows write. That is exactly the unsafe path this change removes. The save fails with the OS error instead, and the buffer stays modified (see Risks). + +The helper is deliberately generic, taking a path and bytes. `config-parsing-robustness` (`config.toml` rewrites) and `history-persistence-hardening` (`history.json`) reuse it instead of growing a third and fourth copy of the temp-file-and-rename code in `config.rs`. + +### D5: Read-only files are refused, never made writable + +`check_writable` reads the target's metadata. If the target exists and `permissions().readonly()` is true, `save` returns an `io::Error` of kind `PermissionDenied` with the message ` is read-only — not saved`, before any temporary file is created. Today the save also fails on a read-only file, but with an OS error string that is never shown. On Windows, `ReplaceFileW` would fail the same way ("Access is denied", D4). On other platforms `rename` would succeed and replace content the user had marked read-only, which is exactly what this decision rules out. The behavior is the same; the outcome is now deterministic and stated, and no temporary file is created only to be discarded. + +Alternative considered: clearing read-only, saving, and setting it again, as the file-operations engine does for overwrite and delete. Those are explicit user operations on file entries with confirmation dialogs. An editor save that silently overrides a protection bit is a different contract. Users who want to edit the file can clear the attribute first. + +### D6: Save failures are rendered, and an edit clears them + +When `save_error` is set, `views/editor.rs` renders it as a one-row overlay on the last body row, directly above the F-key bar. The overlay reads `Save failed: `, uses the `DialogError` role, and is truncated to the width. The header and F-key bar specified by "Full-screen editor chrome" stay unchanged, in the same way the save-on-exit confirmation overlays the body without changing the chrome. The buffer-mutating methods (`type_char`, `insert_newline`, `backspace`, `cut_selection`, `paste`, `replace_first`, `undo`) clear `save_error`, which matches the field's existing doc comment. A successful save clears it as it does today. + +Alternatives considered: + +- **Reporting the failure on the panel mini-status.** The panels are hidden while the editor is open, so the message would show up only after the editor closes, which is too late to act on. +- **Replacing the header's `Line/Col` indicator.** That changes the chrome that "Full-screen editor chrome" specifies. + +## Risks / Trade-offs + +- **[Risk] A legitimate UTF-8 text file containing a NUL byte now opens in the viewer instead of the editor.** → Such files are rare, and the editor could not have shown NUL sensibly anyway. The viewer shows them, and an external editor (`editor =`) still opens them. +- **[Risk] Hard links: the replace gives the saved name a new file identity**, so other hard-link names of the same file keep the old content. `ReplaceFileW` and `rename` both behave this way. → This is accepted. Text files edited inside FileCommand are rarely hard-linked. This change does not detect link counts. +- **[Risk] Directories that allow writing an existing file but not creating one** (some locked-down shares) can no longer be saved to. → The save reports the OS error and keeps the buffer. The external editor remains an option. This is the price of never truncating in place. +- **[Risk] A crash between the temp-file write and the replace leaves a `.fc-tmp` file** (or, in the rare `ReplaceFileW` recovery paths, a `.fc-bak` file) next to the edited file. → The names are distinctive and hidden-style (leading dot), and the original is intact. Automatic cleanup is not attempted, because another instance may be mid-save. +- **[Trade-off] Saving is slower:** an extra file create, a `sync_all`, and a replace instead of a single write. → This is unnoticeable on local disks for files under 10 MiB. `offload-blocking-io` later moves the save off the UI thread for slow media. +- **[Risk] `ReplaceFileW` edge semantics on network or FAT volumes**, which have no ACLs or streams. → The `IGNORE_MERGE_ERRORS` and `IGNORE_ACL_ERRORS` flags make missing features non-fatal. The integration tests cover NTFS, and the helper's error paths are covered by the fault-injection tests. + +## Migration Plan + +There is no data migration. Files that the previous build would have corrupted on save now open in the viewer. Rollback means reverting the change. Any `.fc-tmp` or `.fc-bak` leftovers are inert files. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-editor-encoding-safety/proposal.md b/openspec/changes/archive/2026-10-14-editor-encoding-safety/proposal.md new file mode 100644 index 0000000..f4e76d0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-editor-encoding-safety/proposal.md @@ -0,0 +1,56 @@ +# Change: editor-encoding-safety + +## Why + +The built-in F4 editor can destroy the files it edits (audit finding **R-02**, severity **Critical**). `EditorState::from_bytes` decodes every file with a lossy UTF-8 decode, so each byte sequence that is not valid UTF-8 becomes U+FFFD in the buffer, and F2 writes the buffer back as UTF-8. Every non-ASCII character in a Windows-1252 `.bat` or `.ini` file, every character of a UTF-16 file (Windows PowerShell 5.1 `>` output, `.reg` exports), and every byte of a small binary file that is not valid UTF-8 is replaced with `EF BF BD` on the first save. Nothing detects or refuses these files. For example, a user fixes a one-character typo in an ANSI batch file, presses F2, and loses every accented character in the file for good. + +The save itself is also unsafe. `EditorState::save` calls `std::fs::write`, which truncates the file and then rewrites it in place. A crash, a full disk, or a power loss during the write leaves a truncated file. The spec requirement is named "Save in place with line-ending and encoding preservation", but its text only promises UTF-8 output, so it does not cover any of this. + +## What Changes + +- **Refuse non-UTF-8 files at open.** A file that is not valid UTF-8, starts with a UTF-16 byte-order mark (`FF FE` or `FE FF`), or contains a NUL byte is not loaded into the editor. F4 opens it in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text. This uses the same mechanism and notice surface as the existing ≥10 MB redirect. A file with a UTF-8 BOM is still editable. +- **Keep the UTF-8 BOM.** The BOM is removed from the buffer at load, so it is never an invisible first character the caret can type in front of or delete. It is written back on save. +- **Save atomically.** F2 writes the encoded buffer to a temporary file in the same directory, flushes it to stable storage, and then replaces the original in one rename. A failure at any step leaves the original file byte-for-byte intact, removes the temporary file, and keeps the buffer modified. +- **Keep file metadata on save.** The original file's attributes (hidden, system, archive), its creation time, and on Windows its alternate data streams and access-control list (DACL) carry over to the saved file. Today's in-place write keeps all of these, so the atomic save must not lose them. A read-only file is never made writable: the save is refused with a message, and the original stays untouched. Today's save of a read-only file also fails, but with a raw OS error. +- **Show save failures.** Today `EditorSaveFailed` stores its message in `EditorState::save_error`, but nothing renders it. The editor will show the message until the next edit or successful save. +- Mixed line endings are still normalized to the dominant ending on save. This is the documented existing behavior and is a non-goal here. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `builtin-editor`: + - "Editor invocation and size cap" now also redirects non-UTF-8, UTF-16-BOM, and NUL-containing files to the viewer with a notice, and states that a UTF-8 BOM file is editable. + - "Save in place with line-ending and encoding preservation" now requires an atomic, flushed save that leaves the original intact on failure. It also requires preserving file attributes, refusing read-only files, round-tripping the UTF-8 BOM, and showing save failures. + +## Impact + +- **Specs:** `builtin-editor` (two MODIFIED requirements). +- **Code (core):** + - `crates/filecommand-core/src/editor.rs`: + - `EditorState::open`: classify the bytes after the size gate. + - `EditorState::from_bytes`: strip the UTF-8 BOM into a new `has_bom` field, and decode strictly instead of with `decode_lossy`. + - New `LoadResult::NotEditable { reason }` and `NotEditableReason`. + - `to_bytes` and `byte_len`: prefix the BOM. + - `save`: atomic replace through the new helper; read-only refusal. + - Edits clear `save_error`. + - New durable-replace helper module in `crates/filecommand-core/src/` (temp file in the same directory, `sync_all`, replace, keep metadata, clean up on failure). On Windows it uses `ReplaceFileW` through a new `cfg(windows)` `windows-sys` dependency on `filecommand-core`. + - `crates/filecommand-core/src/update.rs`: new `Command::EditorNotEditable { path, reason }` reply, handled like `Command::EditorTooLarge` (panel notice plus `Effect::OpenViewer`). +- **Code (TUI):** + - `crates/filecommand-tui/src/app.rs`, `run_effects` → `Effect::OpenEditor`: map the new `LoadResult` variant. + - `crates/filecommand-tui/src/views/editor.rs`: render `save_error`. + - `crates/filecommand-tui/tests/integration_smoke.rs`: map the new `LoadResult` variant in its effect shim. +- **Docs:** README "Viewer (F3) and editor (F4)" bullet: non-UTF-8 files open in the viewer. +- **Tests:** unit tests in `editor.rs` for classification, BOM round-trip, and atomic save with failure injection. Reducer tests in `update/tests.rs` for the redirect notice. Windows integration tests (temp directory) for attribute, creation-time, ADS, and read-only behavior. An insta snapshot of the save-error row. +- **Compatibility:** files that used to open in the editor and then get corrupted on save now open in the viewer instead. UTF-8 files behave as before. + +**Audit findings:** R-02 + +**Coordination:** +- `offload-blocking-io` (R-09/P-09) moves editor open and save onto a worker thread. **This change lands first.** Classification and the atomic save live in core's `EditorState::open`/`save`, so moving them is mechanical, and the worker replies with `EditorNotEditable` alongside `EditorOpened`/`EditorTooLarge`/`EditorSaved`. Its `Saving…` notice uses the same last body row as this change's save-failure row; the notice shows while a save is outstanding, and the failure row after a failed one. +- `config-parsing-robustness` and `history-persistence-hardening` need the same temp-file, `sync_all`, and rename durability for `config.toml` and `history.json`. This change introduces the shared durable-replace helper and they reuse it. If either lands first, it introduces the helper and this change reuses it. +- `ci-and-build-tooling` (B-07) moves core's hand-declared Windows FFI to `windows-sys`. This change adds `windows-sys` to `filecommand-core` for `ReplaceFileW`, so the two changes share the dependency entry. diff --git a/openspec/changes/archive/2026-10-14-editor-encoding-safety/specs/builtin-editor/spec.md b/openspec/changes/archive/2026-10-14-editor-encoding-safety/specs/builtin-editor/spec.md new file mode 100644 index 0000000..c6ddfa2 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-editor-encoding-safety/specs/builtin-editor/spec.md @@ -0,0 +1,100 @@ +## MODIFIED Requirements + +### Requirement: Editor invocation and size cap + +The system SHALL open the built-in editor on F4 for the file under the cursor when no external editor is configured, loading files smaller than 10 MB into an in-memory line buffer, and SHALL redirect files of 10 MB or larger to the F3 viewer with a notice rather than loading them into the editor. The built-in editor SHALL edit only UTF-8 text. A file smaller than 10 MB SHALL NOT be loaded into the editor when it begins with a UTF-16 byte-order mark (`FF FE` or `FE FF`), contains a NUL (`0x00`) byte, or is not valid UTF-8. Such a file SHALL instead be opened in the F3 viewer with a notice explaining that the built-in editor only edits UTF-8 text, using the same redirect and notice mechanism as the size limit, and the file on disk SHALL NOT be modified. A file that begins with a UTF-8 byte-order mark (`EF BB BF`) and is otherwise valid UTF-8 without NUL bytes SHALL open in the editor. The byte-order mark SHALL NOT appear in the editable buffer. + +#### Scenario: Small file opens in the editor + +- **WHEN** the user presses F4 on a file of size less than 10 MB and no external editor command is configured +- **THEN** the built-in editor opens full-screen, replacing the panels, with the file's contents loaded into the editable buffer and the caret at the start of the first line + +#### Scenario: Large file redirects to the viewer + +- **WHEN** the user presses F4 on a file whose size is 10 MB or greater +- **THEN** the built-in editor does not load the file and the file is opened in the F3 viewer with a notice explaining it exceeds the editor's size limit + +#### Scenario: External editor takes precedence + +- **WHEN** a `config.toml` external editor command is set and the user presses F4 on a file +- **THEN** the built-in editor does not open and the external editor is launched instead + +#### Scenario: Non-UTF-8 file redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on `setup.bat`, a 2 KB file containing the Windows-1252 byte `E9` (`é`), which is not valid UTF-8 +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text +- **AND** `setup.bat` on disk is byte-for-byte unchanged + +#### Scenario: UTF-16 file redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on a file whose first two bytes are `FF FE` +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text + +#### Scenario: File containing a NUL byte redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on a file smaller than 10 MB that contains a `00` byte +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text + +#### Scenario: UTF-8 file with a byte-order mark is editable + +- **WHEN** no external editor is configured and the user presses F4 on a file that begins with `EF BB BF` followed by valid UTF-8 text `hello` +- **THEN** the built-in editor opens with `hello` as the first line, and the byte-order mark is not part of the buffer, so the caret at the start of the first line sits before `h` + +#### Scenario: External editor receives non-UTF-8 files unchanged + +- **WHEN** a `config.toml` external editor command is set and the user presses F4 on a UTF-16 file +- **THEN** the external editor is launched on that file and no encoding check or redirect takes place + +### Requirement: Save in place with line-ending and encoding preservation + +The system SHALL save the buffer in place on F2, writing UTF-8 text and preserving the file's original dominant line ending (CRLF or LF) detected on load, so a CRLF file stays CRLF and an LF file stays LF. When the file began with a UTF-8 byte-order mark on load, the saved file SHALL begin with the same byte-order mark. The save SHALL be atomic. The encoded content SHALL be written to a temporary file in the same directory as the target and flushed to stable storage, and only then SHALL that temporary file replace the original in a single rename-style operation, so at every moment the file at the target path holds either its complete previous content or its complete new content. If any step fails, the original file SHALL remain byte-for-byte intact, the temporary file SHALL be removed, the buffer SHALL remain modified, and the editor SHALL display the failure message until the next edit or successful save. The save SHALL preserve the original file's attributes (including hidden and system) and its creation time. On Windows it SHALL also preserve the file's alternate data streams and, where the volume supports access-control lists, its discretionary access-control list (DACL). The system SHALL NOT clear a file's read-only attribute in order to save it. Saving a read-only file SHALL fail with a message stating that the file is read-only, and the file SHALL be left untouched. + +#### Scenario: CRLF file stays CRLF on save + +- **WHEN** a file whose dominant line ending is CRLF is edited and saved with F2 +- **THEN** the file is written back using CRLF terminators + +#### Scenario: LF file stays LF on save + +- **WHEN** a file whose dominant line ending is LF is edited and saved with F2 +- **THEN** the file is written back using LF terminators + +#### Scenario: Save clears the modified state + +- **WHEN** the buffer differs from the last saved state and the user presses F2 +- **THEN** the file on disk is updated in place and the buffer is no longer considered modified + +#### Scenario: UTF-8 byte-order mark is written back + +- **WHEN** a file that began with `EF BB BF` is edited and saved with F2 +- **THEN** the saved file begins with exactly one `EF BB BF` sequence, followed by the buffer's UTF-8 text + +#### Scenario: A failed save leaves the original intact + +- **WHEN** the user presses F2 and writing the new content fails partway, for example because the disk is full +- **THEN** the file at the target path still holds its complete previous content, no temporary file remains in its directory, the buffer is still marked modified, and the editor displays the failure message + +#### Scenario: Content is flushed before it replaces the original + +- **WHEN** the user presses F2 on a modified buffer +- **THEN** the new content is written to a temporary file in the target's directory and flushed to stable storage before that file replaces the original, and the original is never truncated or rewritten in place + +#### Scenario: Attributes and creation time survive a save + +- **WHEN** a hidden file with creation time T is edited and saved with F2 +- **THEN** after the save the file is still hidden and its creation time is still T + +#### Scenario: Alternate data streams survive a save on Windows + +- **WHEN** on Windows a file carrying a `Zone.Identifier` alternate data stream is edited and saved with F2 +- **THEN** after the save the file still carries that `Zone.Identifier` stream with its previous content + +#### Scenario: Read-only file is not saved + +- **WHEN** the file being edited has the read-only attribute and the user presses F2 +- **THEN** the file is not modified, its read-only attribute remains set, the buffer stays marked modified, and the editor displays a message stating that the file is read-only + +#### Scenario: Save failure message clears on the next edit + +- **WHEN** the editor is displaying a save failure message and the user types a character +- **THEN** the failure message is no longer displayed diff --git a/openspec/changes/archive/2026-10-14-editor-encoding-safety/tasks.md b/openspec/changes/archive/2026-10-14-editor-encoding-safety/tasks.md new file mode 100644 index 0000000..e5f72e1 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-editor-encoding-safety/tasks.md @@ -0,0 +1,85 @@ +# Tasks: editor-encoding-safety + +## 1. Byte classification and BOM handling (`crates/filecommand-core/src/editor.rs`) + +- [x] 1.1 Add `NotEditableReason { Utf16, Binary, NotUtf8 }` and `LoadResult::NotEditable { reason }`. Add the pure `classify_editor_bytes(&[u8]) -> Result, NotEditableReason>`, which checks the UTF-16 BOM, then NUL, then the UTF-8 BOM, then strict `from_utf8` (builtin-editor "Editor invocation and size cap"; design D1). +- [x] 1.2 Add `EditorState::has_bom`. Build the buffer from the validated text after the BOM, and stop using `decode_lossy` on the editor path. `LineEnding::detect` still runs on the raw bytes. Make `from_bytes` strict, returning `Result` (design D1, D3). +- [x] 1.3 In `EditorState::open`, after the existing size gate and read, classify the bytes and return `LoadResult::NotEditable { reason }` instead of loading (design D1). +- [x] 1.4 Make `to_bytes()` prefix `EF BB BF` when `has_bom` is set, so `save` and `byte_len` both include it (builtin-editor "UTF-8 byte-order mark is written back"; design D3). +- [x] 1.5 Update the `state()` test fixture and any other struct-literal construction for the new `has_bom` field. + +## 2. Durable replace helper (new core module, e.g. `crates/filecommand-core/src/durable.rs`) + +- [x] 2.1 Add `windows-sys` (features `Win32_Storage_FileSystem`, `Win32_Foundation`) as a `[target.'cfg(windows)'.dependencies]` entry in `crates/filecommand-core/Cargo.toml`, with the same version as the TUI (0.59) (design D4). +- [x] 2.2 Implement `replace_file(path, bytes)`: + - resolve a symlink target; + - snapshot the original's metadata; + - create a uniquely named `.fc-tmp` sibling with `create_new`, carrying the original's HIDDEN/SYSTEM/ARCHIVE/NOT_CONTENT_INDEXED attributes on Windows (`OpenOptionsExt::attributes`) or its permission mode elsewhere; + - write all bytes, then `sync_all`; + - replace the original; + - remove the temp file on any failure. + + All paths go through `to_extended_path` (design D4). +- [x] 2.3 Windows replace step: `ReplaceFileW` with a `.fc-bak` backup and `REPLACEFILE_IGNORE_MERGE_ERRORS | REPLACEFILE_IGNORE_ACL_ERRORS`; delete the backup on success. Recover from `ERROR_UNABLE_TO_REMOVE_REPLACED`, `ERROR_UNABLE_TO_MOVE_REPLACEMENT`, and `ERROR_UNABLE_TO_MOVE_REPLACEMENT_2` as design D4 describes. Fall back to `std::fs::rename` when the original no longer exists. Add a `// SAFETY:` comment on the `unsafe` call. +- [x] 2.4 Non-Windows replace step: `std::fs::rename`, then a best-effort `sync_all` on the parent directory. +- [x] 2.5 Add a fault-injection seam (a test-only hook that fails after N bytes written, or before the replace) so the failure paths are unit-testable without filling a disk. + +## 3. Editor save (`crates/filecommand-core/src/editor.rs`) + +- [x] 3.1 Add `check_writable`: an existing target whose `permissions().readonly()` is set yields `PermissionDenied` with ` is read-only — not saved`, before any temp file is created (builtin-editor "Read-only file is not saved"; design D5). +- [x] 3.2 Make `EditorState::save` call `check_writable`, then `durable::replace_file(&self.path, &self.to_bytes())`. On success, reset `saved_snapshot` and clear `save_error` as today (builtin-editor "Save in place with line-ending and encoding preservation"; design D4). +- [x] 3.3 Clear `save_error` in `type_char`, `insert_newline`, `backspace`, `cut_selection`, `paste`, `replace_first` (on a replacement), and `undo` (design D6). + +## 4. Reducer and TUI wiring + +- [x] 4.1 `crates/filecommand-core/src/update.rs`: add `Command::EditorNotEditable { path, reason }`. Handle it like `Command::EditorTooLarge`: set the active panel's `last_error` to the reason-specific notice from design D2 and emit `Effect::OpenViewer { path }` (builtin-editor "Non-UTF-8 file redirects to the viewer"; design D2). +- [x] 4.2 `crates/filecommand-tui/src/app.rs` `run_effects` → `Effect::OpenEditor`: map `LoadResult::NotEditable { reason }` to `Command::EditorNotEditable { path, reason }`. +- [x] 4.3 `crates/filecommand-tui/tests/integration_smoke.rs`: map the new variant in the effect shim. +- [x] 4.4 `crates/filecommand-tui/src/views/editor.rs`: render `save_error` as a one-row `Save failed: ` overlay on the last body row in the `DialogError` role, without changing the header or the F-key bar (builtin-editor "A failed save leaves the original intact"; design D6). + +## 5. Tests + +- [x] 5.1 `editor.rs` unit tests for `classify_editor_bytes`: + - `FF FE …` → Utf16; + - `FE FF …` → Utf16; + - `FF FE 00 00` → Utf16; + - ASCII with an embedded `00` → Binary; + - a lone `E9` byte → NotUtf8; + - truncated multi-byte sequence at EOF → NotUtf8; + - empty file → editable; + - `EF BB BF` + `hello` → editable with the BOM flag; + - `EF BB BF` + invalid UTF-8 → NotUtf8. +- [x] 5.2 `editor.rs` (temp dir): `open` on a Windows-1252 file returns `NotEditable(NotUtf8)` and leaves the file byte-identical; `open` on a ≥10 MiB file still returns `TooLarge` without classifying (the existing test stays). +- [x] 5.3 `editor.rs` (temp dir): a BOM file's `lines[0]` has no U+FEFF; Home then type `X` and save produce `EF BB BF X…`; `byte_len()` includes the 3 BOM bytes (builtin-editor "UTF-8 file with a byte-order mark is editable", "UTF-8 byte-order mark is written back"). +- [x] 5.4 `durable.rs` unit tests with the fault-injection seam: + - failure mid-write leaves the original byte-identical and no `.fc-tmp` sibling; + - failure before the replace leaves the original byte-identical; + - success leaves no temp or backup files; + - a missing original is created (builtin-editor "A failed save leaves the original intact", "Content is flushed before it replaces the original"). +- [x] 5.5 `editor.rs`: saving a read-only file returns `PermissionDenied`, the file stays unchanged and read-only, `is_modified()` stays true, and no temp file is created (builtin-editor "Read-only file is not saved"). +- [x] 5.6 `editor.rs`: after a failed save sets `save_error`, `type_char` clears it (builtin-editor "Save failure message clears on the next edit"). +- [x] 5.7 Windows-only integration test (`#[cfg(windows)]`, temp dir on NTFS): + - hidden and system attributes survive a save; + - creation time is unchanged; + - a `:Zone.Identifier` stream survives with the same content; + - the existing CRLF/LF round-trip tests still pass through the new path (builtin-editor "Attributes and creation time survive a save", "Alternate data streams survive a save on Windows"). +- [x] 5.8 Unix-only test: saving through a symlink replaces the link target and leaves the link a symlink; the permission mode is preserved. +- [x] 5.9 `update/tests.rs`: `Command::EditorNotEditable` for each reason sets the reason-specific notice on the active panel, emits `Effect::OpenViewer` for the same path, and does not enter `UiPhase::Editor` (builtin-editor "UTF-16 file redirects to the viewer", "File containing a NUL byte redirects to the viewer"). +- [x] 5.10 `update/tests.rs`: with `state.editor` set (external editor configured), F4 on any file still dispatches the external editor and never emits `Effect::OpenEditor` (builtin-editor "External editor receives non-UTF-8 files unchanged"). +- [x] 5.11 `views/editor.rs`: add an insta snapshot of the editor with `save_error` set, showing the overlay row, unchanged header, and unchanged F-key bar. + +## 6. Docs + +- [x] 6.1 README "Viewer (F3) and editor (F4)": state that the built-in editor edits UTF-8 text only. Files that are not UTF-8, that are UTF-16, or that contain NUL bytes open in the viewer with a notice. Saves are atomic and keep file attributes; read-only files are not saved. + +## 7. Verify + +- [x] 7.1 `cargo test --workspace` green, including `cargo insta test` review of the new snapshot. +- [x] 7.2 `cargo clippy --workspace --all-targets` with no new warnings. +- [x] 7.3 `openspec validate editor-encoding-safety --strict` clean. +- [ ] 7.4 Manual check on Windows: + - F4 on an ANSI `.bat` with `é` opens the viewer with the notice; + - F4 on a PowerShell 5.1 `>`-redirected file (UTF-16LE) opens the viewer; + - F4 on a UTF-8-BOM file edits and saves with the BOM intact; + - F2 on a read-only file shows the read-only message; + - F2 on a hidden file keeps it hidden. diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/.openspec.yaml b/openspec/changes/archive/2026-10-14-error-diagnostics/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/design.md b/openspec/changes/archive/2026-10-14-error-diagnostics/design.md new file mode 100644 index 0000000..cb96bbe --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/design.md @@ -0,0 +1,123 @@ +# Design: error-diagnostics + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`). + +- **Persistence failures are discarded.** `run_effects` runs `let _ = config::save_history_file_atomic(..)` (`app.rs:897`), `let _ = config::save_theme_atomic(..)` (`:906`), and `let _ = config::save_panel_split_atomic(..)` (`:913`). The comments say this is deliberate, since a failed write must not end the session, but nothing records the failure. +- **Channel sends are discarded.** Every `Command` reply in `run_effects` and in each `worker::spawn_*` is sent with `let _ = tx.send(..)`; examples are `app.rs:822`, `:876`, `:920`, `:934`, and `worker.rs:41-199`. The job answers use `let _ = handle.reply_tx.send(..)` (`app.rs:857`, `:862`). + - A send to `rt.tx` from the UI thread can only fail after the receiver in `run` is gone. + - A worker's send fails after shutdown. + - A `reply_tx` send fails when the job thread has already ended. + + These are rare, but each is a lost result that cannot be diagnosed today. +- **Metadata-restore failures are discarded** (`fs_ops/worker.rs:288-298`, inside `copy_one_file`, after a successful `copy_file_chunked`): + - `fs.list_streams(src).unwrap_or_default()` turns a listing error into "no streams"; + - `let _ = fs.copy_stream_chunked(..)` discards each stream failure; + - `let _ = fs.set_modified(dst, ..)` and `let _ = fs.set_readonly(dst, true)` discard restore failures. + + The file is then counted as done (`CopyOneResult::Done`). +- **Status-line messages carry no context.** + - `spawn_listing` sends `ListingFailed { message: e.to_string() }` (`worker.rs:199`), which core stores verbatim in `last_error` (`update.rs:3905-3909`). The panel title does show the failed directory, because `begin_new_listing` sets `cwd` before the read (`panel.rs:498-516`), but the message itself does not. + - `ViewerOpenFailed`, `EditorOpenFailed`, and `EditorSaveFailed` likewise carry the bare `e.to_string()` (`app.rs:937`, `:979`, `:992`). +- **The job dialogs already show paths, contrary to the audit's wording.** + - The error-recovery dialog draws `info.path` on its first row and `info.message` on the second (`views/error_dialog.rs:18-41`). + - The end-of-job summary formats each row as `"{path}: {reason}"` (`views/skipped_summary.rs:22`). + + For job errors, B-06's "job error messages lack the path" is therefore already met. This change pins that behavior in the spec. +- **Startup warnings are last-writer-wins.** `State::startup_warning` is a single `Option`. `run` assigns it up to five times (`app.rs:288-322`), and `apply_user_menu` once more (`:696`), so earlier warnings are lost. +- **No logging infrastructure.** There is no `log`/`tracing` dependency and no debug file. Launch flags are parsed by `parse_launch_args` (`app.rs:117-145`), and `main.rs` prints the usage block by hand. + +## Goals / Non-Goals + +**Goals:** + +- A zero-cost, opt-in, append-only diagnostic file that records every error the application swallows. +- Status-line errors say what failed. +- Metadata that is not restored becomes visible to the user as a warning. +- No new crates, and no logging in `filecommand-core`. + +**Non-Goals:** + +- Typed error enums replacing `String` messages. B-06 calls error handling "stringly-typed", but the user-visible requirement is context, not types. A typed-error refactor would touch every reply command and is not needed to satisfy it. +- Log levels, log rotation, or size caps. The log is opt-in for diagnosing a specific problem. +- Logging successful operations or a trace of every command. +- The `CopyFileExW` metadata path and the creation-time restore. Those are `durable-file-copy`'s, which hands its failures over in `CopyReport::unrestored`. ACLs and owner are not copied by either change (a `durable-file-copy` non-goal), so they produce no warning. +- Worker panics and quit-wait timeouts. `process-lifecycle-hardening` owns them and logs through this change's API. + +## Decisions + +### D1: A minimal file appender in `filecommand-tui` + +New module `diag.rs`: + +- `static LOG: OnceLock>`. +- `pub fn init(path: &Path) -> io::Result<()>` opens the file with `OpenOptions::new().create(true).append(true)` and writes a session header line: `FileCommand session start`. +- `pub fn log(category: &str, message: impl Display)` is a no-op when `LOG` is unset. Otherwise it locks, writes one `"{ts} [{category}] {message}\n"` line, flushes, and ignores write errors. +- `pub fn log_err(category, context, result: Result) -> Option` is a convenience for the `let _ =` sites. + +The timestamp is UTC, formatted `YYYY-MM-DDTHH:MM:SS.mmmZ` from `listing::DateTime::from_system_time(SystemTime::now())` plus the millisecond remainder. That reuses the civil-date conversion the codebase already has. The `Mutex` makes the log safe to call from worker threads. Append mode keeps concurrent sessions that share one file from clobbering each other. + +*Alternative considered:* `log` plus `simplelog`, or `tracing`. This was rejected because the audit and the user asked for no new crates, and a one-file appender covers the need. + +*Alternative considered:* logging from `filecommand-core`. This was rejected because core is I/O-free (application-shell "Pure core update function"). Core errors surface as reply commands or `SkippedItem`s, and the TUI logs them. + +### D2: Activation, precedence, and failure handling + +- `parse_launch_args` accepts `--log ` and `--log=` into `LaunchOptions::log: Option`, using the same absent/empty/value convention as `--theme` (`None` when the switch is absent, `Some(String::new())` when passed with no value, `Some(path)` otherwise). If the `config-profile-directory` change has already landed, `--config-dir` is an equally good model. +- `run` resolves the effective path: the flag if it is present, else the non-empty `FILECOMMAND_LOG`. Reading the environment happens in `run`, never in core. +- If the effective value is empty, or `diag::init` fails, `run` sets the startup warning `Diagnostic log disabled: cannot open ()`, or `--log needs a path`, and continues. +- `print_usage` gains the line `--log Append diagnostic messages to (or set FILECOMMAND_LOG)`, in the same OPTIONS block that lists `--theme` (and that the in-flight `--noanimations` change extends; if `config-profile-directory` has landed, `--config-dir` is an equally good model). + +### D3: What is logged + +| Site | Category | Message | +|---|---|---| +| `Persist*` failures (`app.rs:891-915`) | `persist` | `history write : ` / `config write : ` | +| Failed `rt.tx.send` / worker `tx.send` | `channel` | ` not delivered: event loop gone` | +| Failed `reply_tx.send` | `job` | `conflict/error answer not delivered: job ended` | +| Metadata warnings in a `JobDone` outcome (`ChannelSink::done`) | `copy` | `: not restored: ` | +| Every startup warning, at the point it is assigned | `startup` | the warning text | + +Startup warnings are logged at assignment, through a small `set_startup_warning(&mut state, msg)` helper, so that the earlier warnings the dialog drops are kept. `config-parsing-robustness`' collected parser warnings pass through the same helper. + +### D4: Status-line message formats + +- `spawn_listing` sends `Cannot read : `. `` is `path.file_name()`, or the whole path when there is none (`C:\`, `\\server\share`). +- `ViewerOpenFailed` and `EditorOpenFailed` send `Cannot open : `, and `EditorSaveFailed` sends `Cannot save : `. After `editor-encoding-safety`, `` for a read-only target is that change's own ` is read-only — not saved` text rather than an OS message; the prefix is added around it unchanged. +- The other status-line errors already name their target and are left alone: the command line's `cd`/`del`/`rmdir` rejections format `path.display()`, and `open_with_association_error` names the file. These four `e.to_string()` sites are the only bare OS messages that reach a status line (a search of `update.rs`, `app.rs`, and `worker.rs` for `last_error`/`save_error` assignments). +- The name is used rather than the full path because the panel's mini-status is about half the terminal width and truncates from the right. A full path would push the operating-system message, the actionable part, out of view, and the panel title directly above already shows the directory. The dialogs, which have room, keep showing full paths. Full paths also go to the log whenever a logged event carries one. + +*Alternative considered:* ` ()`. This was rejected because it reads poorly and still truncates the path away at 80 columns. + +### D5: Metadata warnings ride in the existing skipped list + +- `SkippedItem` gains `kind: ItemKind { Skipped, Warning }`, with `SkippedItem::skipped(path, reason)` and `SkippedItem::warning(path, reason)` constructors. Every existing literal becomes `skipped(..)`. +- `copy_one_file` gains `warnings: &mut Vec`. It pushes: + - `warning(dst, "alternate data streams not listed: ")` when `list_streams` fails; + - `warning(dst, "stream not copied: ")` for each failed stream; + - `warning(dst, "modification time not restored: ")`; + - `warning(dst, "read-only attribute not restored: ")`. + + It still returns `Done`. +- When the destination volume reports no named-stream support, the per-file stream copies are skipped and a single job-level warning is recorded instead (see Risks). `FileSystem` gains `supports_named_streams(&Path) -> io::Result`, and an error there is treated as "supported", so the per-file path still reports real failures. +- The engine appends these to the outcome's `skipped` vector, so `JobOutcome`, `Command::JobDone`, and `UiPhase::FileOpSummary(Vec)` keep their shapes. `handle_file_op_running` already shows the summary whenever that vector is non-empty, which gives the "warnings only" case for free. +- `skipped_summary.rs` lists `Skipped` rows first, then a `Warnings` group, and titles the dialog `Skipped N item(s)`, `N warning(s)`, or `Skipped N item(s), M warning(s)`. +- This change does not alter the cross-volume move's policy for removing the source after a file with warnings. That policy is today's (remove the source) until `durable-file-copy` decides otherwise. + +*Alternative considered:* a separate `warnings: Vec` in `JobOutcome` and `FileOpSummary`. This was rejected for churn: every outcome match and the summary phase would change. The `kind` field gives the same separation with one new field. + +## Risks / Trade-offs + +- [The existing operation-dialogs scenario "No summary when nothing was skipped" would contradict a warnings-only summary.] → This change MODIFIES "End-of-job skipped-files summary": its description gains the warnings case, and that scenario's WHEN becomes "no items skipped and no warnings recorded". Its intent holds, because a job with neither still shows nothing. The parenthetical list of skip sources becomes "for example via …", because `durable-file-copy`, `move-reparse-point-safety`, `process-lifecycle-hardening`, and `copy-onto-itself-guard` also list engine-skipped items (a failed verification, a link in a move, an internal error, a copy onto its own source) in this summary. No other new or in-flight change modifies this requirement (checked with `grep -rn "### Requirement: End-of-job skipped-files summary" openspec/changes/*/specs/`). +- [Logging from many threads through one `Mutex`.] → Only failures are logged, so contention is negligible. When the log is disabled, the cost is a `OnceLock` load. +- [ADS-heavy copies, such as browser downloads that each carry a `Zone.Identifier`, would produce one warning per file on a destination without stream support, such as a FAT32 or exFAT USB stick.] → The engine asks the `FileSystem` seam once per job whether the destination volume supports named streams (on Windows, `GetVolumeInformationW`'s `FILE_NAMED_STREAMS` flag). When it does not, the engine skips the per-file stream copies and records one job-level warning instead: the destination path plus "alternate data streams not supported; streams of N file(s) not copied". FakeFs gets a matching toggle. +- [Startup warnings logged before `diag::init`.] → `run` resolves and initializes the log first, before profile resolution and config loading, so every warning assignment comes after `init`. + +## Migration Plan + +None. The log is opt-in. The message text changes are cosmetic, and any snapshot or test asserting the old bare messages is updated. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/proposal.md b/openspec/changes/archive/2026-10-14-error-diagnostics/proposal.md new file mode 100644 index 0000000..126b9a9 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/proposal.md @@ -0,0 +1,83 @@ +# Change: error-diagnostics + +## Why + +Audit finding **B-06 (Low)**: errors are strings that lose their context, several failures are dropped silently, and there is no logging at all. + +- **Silently dropped errors.** These failures disappear without trace: + - persistence writes (`let _ = config::save_history_file_atomic(..)`, `save_theme_atomic`, and `save_panel_split_atomic` at `app.rs:897`, `:906`, `:913`); + - channel sends (`let _ = rt.tx.send(..)` and `handle.reply_tx.send(..)` throughout `app.rs` and `worker.rs`, for example `app.rs:822`, `:857`, `:862`); + - metadata restoration after each copied file (`fs_ops/worker.rs:289-298`): the alternate-data-stream listing falls back to an empty list on error, each stream copy, the modification-time restore, and the read-only restore all use `let _ =`; + - startup warnings, because `State::startup_warning` holds only one: each later warning overwrites the earlier ones (`app.rs:288-322`, plus `apply_user_menu` at `:696`). +- **Status-line errors without context.** The panel and status-line errors that come from `io::Error::to_string()` do not say what failed: + - `ListingFailed { message: e.to_string() }` (`worker.rs:199`); + - `ViewerOpenFailed` (`app.rs:937`); + - `EditorOpenFailed` (`app.rs:979`); + - `EditorSaveFailed` (`app.rs:992`). + + A user report of "Access is denied. (os error 5)" therefore does not say what was denied. +- **No diagnostic trail.** There is no `log`/`tracing` crate or debug file anywhere, so a user report cannot be diagnosed after the fact. + +The impact is partial failures nobody notices (for example ADS not copied, or timestamps not restored) and bug reports nobody can act on. + +## What Changes + +- **Optional diagnostic log.** A diagnostic log is enabled with `--log ` (or `--log=`) or the `FILECOMMAND_LOG` environment variable; the flag wins. When it is enabled, each swallowed error is appended to the file with a timestamp and a category: + - persistence write failures; + - failed channel sends; + - metadata-restore failures; + - every startup warning. + + When neither is set, no file is created or written. The logger is a minimal mutex-guarded file appender in `filecommand-tui`. No new crates are added and `filecommand-core` gains no logging dependency. A log path that cannot be opened raises the startup-warning dialog, and the session continues without logging. `--help` lists the flag and the variable. +- **Status-line errors name what failed.** + - A panel read error reads `Cannot read : `. + - Viewer and editor open failures read `Cannot open : `. + - A built-in editor save failure reads `Cannot save : `, where the reason is the OS message or the editor's own refusal text. + + Messages that already name their target (`cd`/`del`/`rmdir` rejections, Open-with failures) are unchanged. The error-recovery dialog (path above the message) and the end-of-job summary rows (`: `) already name the full path, and that behavior is now required. +- **Metadata-restore warnings reach the user.** A copied file whose alternate data streams, modification time, or read-only attribute could not be restored is recorded as a warning in the end-of-job summary, separate from skipped items. The file still counts as copied. A job that skipped nothing but has warnings still shows the summary, titled by its warning count. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: ADDED "Optional diagnostic log". This covers the flag and environment variable, what is logged and its format, behavior when the log is unset or unwritable, and the `--help` listing. The listing follows the precedent of the existing `--theme` entry and the in-flight `--noanimations` change, so "Help flags print usage and exit before TUI startup" is left unchanged. (If the `config-profile-directory` change has already landed, `--config-dir` is an equally good model for the listing.) +- `filesystem-error-handling`: ADDED "Error messages name the affected path". +- `operation-dialogs`: ADDED "Metadata-restore failures are reported in the end-of-job summary". MODIFIED "End-of-job skipped-files summary": the summary is also shown for a job that recorded warnings but skipped nothing, so its "No summary when nothing was skipped" scenario now reads "no items skipped and no warnings recorded". Its other scenario is kept verbatim, and a warnings-only scenario is added. + +## Impact + +- New `crates/filecommand-tui/src/diag.rs`: `init(path) -> io::Result<()>`, `log(category, message)`, and a `log_err` helper. It holds a `OnceLock>`, opened in append mode, and formats a UTC timestamp through `listing::DateTime::from_system_time`. +- `crates/filecommand-tui/src/app.rs`: + - `LaunchOptions`/`parse_launch_args` (`:78-145`) gain `log: Option`; + - `run` resolves `--log` over `FILECOMMAND_LOG` and calls `diag::init`, logging every startup warning before `stamp_startup_warning`; + - `run_effects` logs the `Persist*` failures (`:891-915`) and every `send` failure; + - the `ViewerOpenFailed`, `EditorOpenFailed`, and `EditorSaveFailed` message formats change. +- `crates/filecommand-tui/src/main.rs`: `print_usage` lists `--log ` and `FILECOMMAND_LOG`. +- `crates/filecommand-tui/src/worker.rs`: the `ListingFailed` message names the directory. Failed `tx.send`s are logged. `ChannelSink::done` logs the job's metadata warnings. +- `crates/filecommand-core/src/fs_ops/error.rs`: `SkippedItem` gains a `kind: ItemKind { Skipped, Warning }` with `SkippedItem::skipped(..)` and `SkippedItem::warning(..)` constructors. +- `crates/filecommand-core/src/fs_ops/worker.rs`: `copy_one_file` (`:288-299`) records a warning for each failed ADS listing, stream copy, mtime restore, or read-only restore instead of discarding it. The sink-facing outcome carries them in the existing `skipped` vector. +- `crates/filecommand-tui/src/views/skipped_summary.rs`: groups skipped items and warnings and titles the dialog by both counts. +- `README.md`: a "Diagnostics" note. +- Tests: + - argument parsing and environment precedence; + - the logger appends with a timestamp, and creates nothing when it is disabled; + - startup warning when the log cannot be opened; + - message formats; + - FakeFs-injected `set_modified`/`copy_stream_chunked`/`list_streams` failures produce warnings and still count the file as copied; + - summary snapshots for warnings only and for skipped items plus warnings. + +**Audit findings:** B-06 + +**Coordination:** +- `durable-file-copy` (R-07/R-08/R-14/B-01) moves Windows copies to `CopyFileExW`, which carries ADS, the read-only/hidden/system/archive attributes, and the last-write time, and restores the creation time explicitly. ACLs and owner are an explicit non-goal there (the copy inherits the destination's permissions), so no ACL warning exists in either change. It removes the worker's restore loop (and `list_streams`/`copy_stream_chunked`/`set_modified` from the `FileSystem` trait) and returns whatever could not be carried over in `CopyReport::unrestored`. Whichever change lands second maps `unrestored` entries onto this change's warning items (and the per-volume "streams not supported" aggregation), and updates the "Metadata-restore failures" scenarios and the FakeFs fault-injection tests to the set of metadata that change restores. +- `copy-onto-itself-guard` adds another engine-recorded skip ("source and destination are the same file") listed in the end-of-job summary. It relies on this change's "for example via …" wording in the MODIFIED "End-of-job skipped-files summary"; such items are `Skipped`, not warnings. No ordering constraint. +- `process-lifecycle-hardening` (R-10/R-11/R-12/R-18): its contained worker panics and its quit-wait timeout are logged through `diag::log` when the log is enabled. Whichever change lands second wires them in. +- `config-parsing-robustness` (R-15/R-16/B-05) collects parser warnings and surfaces them at startup. They pass through the same startup-warning path, so this change logs every one, including any a later warning overwrites. +- `history-persistence-hardening` (P-08/R-13/R-15) may move the persistence writes off the UI thread. Their failures must still go through `diag::log`. +- `offload-blocking-io` (R-09/P-09) moves the viewer and editor open and save onto workers. The `Cannot open`/`Cannot save` formats apply to whichever code builds those messages. +- `editor-encoding-safety` (R-02) makes a read-only save fail with its own message (` is read-only — not saved`) instead of an OS error. Under this change that message is the `` in `Cannot save : `. Whichever change lands second keeps the read-only text intact inside the prefix. diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/application-shell/spec.md new file mode 100644 index 0000000..fef0592 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/application-shell/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Optional diagnostic log + +The system SHALL support an opt-in diagnostic log file, chosen at launch by the `--log ` flag (also accepted as `--log=`) or by the `FILECOMMAND_LOG` environment variable. When both are given, the flag SHALL win. + +When a log path is in effect, the system SHALL append one line per event to that file. Each line SHALL begin with a UTC timestamp, followed by a short category and a message naming the operation and the affected path where there is one. The file SHALL never be truncated. At least the following SHALL be logged, most of which the application otherwise handles without telling the user: + +- failures to write `history.json` or `config.toml`; +- results that could not be delivered between a worker thread and the event loop; +- answers that could not be delivered to a running file operation; +- metadata that could not be restored on a copied file; +- every startup warning, including any that a later warning replaced in the startup-warning dialog. + +When neither the flag nor the variable is set, the system SHALL NOT create or write any log file. + +If the log file cannot be opened for appending, or the flag is given without a path, the session SHALL start without logging and SHALL raise the dismissable startup-warning dialog naming the path and the reason. A failure to write a log line SHALL never interrupt the session. + +The `--help` usage block SHALL list `--log ` and SHALL mention `FILECOMMAND_LOG`. The log SHALL be implemented without adding a dependency, and `filecommand-core` SHALL NOT write to it directly. + +#### Scenario: No log unless asked for + +- **WHEN** FileCommand runs without `--log` and with `FILECOMMAND_LOG` unset, and a `history.json` write fails during the session +- **THEN** no log file is created anywhere, and the session continues as before + +#### Scenario: A failed history write is logged with a timestamp + +- **WHEN** FileCommand is launched with `--log C:\temp\fc.log` and a `history.json` write fails because the profile directory became read-only +- **THEN** `C:\temp\fc.log` gains a line beginning with a UTC timestamp that names the history write and the path of `history.json`, together with the operating-system error +- **AND** no dialog or status-line message is shown for it + +#### Scenario: The environment variable enables logging + +- **WHEN** `FILECOMMAND_LOG` is set to `D:\logs\fc.log` and FileCommand is launched without `--log` +- **THEN** diagnostic lines are appended to `D:\logs\fc.log` + +#### Scenario: The flag wins over the environment variable + +- **WHEN** `FILECOMMAND_LOG` is `D:\logs\env.log` and FileCommand is launched with `--log=D:\logs\flag.log` +- **THEN** lines are appended to `D:\logs\flag.log` and `D:\logs\env.log` is not created or written + +#### Scenario: Existing log content is kept + +- **WHEN** the log file already holds lines from an earlier session +- **THEN** the new session's lines are appended after them + +#### Scenario: Unwritable log path warns and continues + +- **WHEN** FileCommand is launched with `--log` naming a file in a directory that does not exist +- **THEN** the TUI starts normally, the startup-warning dialog names that path and the reason it could not be opened, and nothing is logged for the session + +#### Scenario: Superseded startup warnings are still recorded + +- **WHEN** logging is enabled and two startup warnings occur, such as an unknown `--theme` and a malformed `usermenu.toml`, so the dialog shows only the last one +- **THEN** the log holds a line for each of the two warnings + +#### Scenario: Usage lists the log options + +- **WHEN** the user runs `filecommand --help` +- **THEN** the usage block lists `--log ` and mentions the `FILECOMMAND_LOG` environment variable diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/specs/filesystem-error-handling/spec.md b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/filesystem-error-handling/spec.md new file mode 100644 index 0000000..d958d7b --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/filesystem-error-handling/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Error messages name the affected path + +A user-facing filesystem error SHALL identify the file or directory it concerns together with the reason for the failure. A bare operating-system message, such as "Access is denied. (os error 5)", SHALL NOT be shown on its own. + +The status-line messages that are built from an operating-system error SHALL name the item by its file or directory name; the panel's title already shows the directory it lives in. They SHALL take these forms: + +- a panel whose directory cannot be read SHALL show `Cannot read : ` in its read-error state; +- a failure to open a file in the F3 viewer or the built-in editor SHALL read `Cannot open : `; +- a failed built-in editor save SHALL read `Cannot save : `, where the reason is the operating system's message or the editor's own explanation of why it refused to save. + +Status-line messages that already name their target, such as the command line's `cd`, `del`, and `rmdir` rejections and the Open-with-associated-application failure, keep their existing text. + +The multi-line job dialogs SHALL show the full path: + +- the error-recovery dialog SHALL show the full path of the failing item above the operating-system message; +- each row of the end-of-job summary SHALL show the item's full path followed by its reason. + +#### Scenario: Access-denied listing names the directory + +- **WHEN** a panel navigates to `C:\Windows\CSC` and the read fails with access denied +- **THEN** the panel's read-error message reads `Cannot read CSC: Access is denied. (os error 5)`, and the panel title shows `C:\Windows\CSC` + +#### Scenario: Viewer open failure names the file + +- **WHEN** F3 is pressed on `locked.db` and the file cannot be opened because another process holds it exclusively +- **THEN** the panel's status line reads `Cannot open locked.db: ` followed by the operating system's sharing-violation message + +#### Scenario: Editor save failure names the file + +- **WHEN** F2 in the built-in editor fails to write `notes.txt` because the disk is full +- **THEN** the editor reports `Cannot save notes.txt: ` followed by the operating system's message, and the buffer stays modified + +#### Scenario: The error-recovery dialog shows the full path + +- **WHEN** a copy job fails to read `D:\data\reports\q3.xlsx` with a permission error +- **THEN** the error-recovery dialog shows `D:\data\reports\q3.xlsx` on one line and the operating system's message on the next + +#### Scenario: Summary rows show the full path and reason + +- **WHEN** a job ends having skipped `D:\data\old.log` because the target already exists +- **THEN** the end-of-job summary row reads `D:\data\old.log: target already exists` diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/operation-dialogs/spec.md new file mode 100644 index 0000000..b3e6e02 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/specs/operation-dialogs/spec.md @@ -0,0 +1,62 @@ +## MODIFIED Requirements + +### Requirement: End-of-job skipped-files summary + +The system SHALL display, when a job finishes and one or more items were skipped (for example via Skip, Skip All, or a latched skip policy from a conflict or error), a summary dialog listing the skipped items so the user knows what was not processed. The system SHALL also display the summary when a job finishes having recorded one or more warnings (see "Metadata-restore failures are reported in the end-of-job summary"), even if nothing was skipped. A job that finishes with neither skipped items nor warnings SHALL show no summary. + +#### Scenario: Summary lists skipped items + +- **WHEN** a copy job completes after the user chose Skip on two conflicting files and Skip All latched a third +- **THEN** an end-of-job summary dialog lists those three skipped items + +#### Scenario: No summary when nothing was skipped + +- **WHEN** a job completes with no items skipped and no warnings recorded +- **THEN** no skipped-files summary dialog is shown + +#### Scenario: Summary shown for warnings alone + +- **WHEN** a copy job completes with no items skipped but one copied file's modification time could not be restored +- **THEN** the end-of-job summary dialog is shown, titled by its warning count, and lists that warning + +## ADDED Requirements + +### Requirement: Metadata-restore failures are reported in the end-of-job summary + +After a file's content has been copied successfully, any metadata the system restores on the copy that cannot be restored SHALL be recorded as a warning for that file, never discarded silently. This covers: + +- an alternate data stream that cannot be listed or copied; +- the modification time; +- the read-only attribute. + +When the destination volume does not support alternate data streams at all, the job SHALL record one warning instead of one per file. That warning SHALL name the destination and the number of files whose streams were not copied. + +A file with only warnings SHALL count as copied: it SHALL NOT be recorded as a skipped item, and it SHALL count toward the progress dialog's completed files. + +The end-of-job summary SHALL list warnings separately from skipped items. Each warning SHALL show the file's full path and what could not be restored, with the operating system's message. A summary that holds only warnings SHALL be titled by its warning count; one that holds both SHALL give both counts. When the summary is shown is defined by "End-of-job skipped-files summary". + +#### Scenario: Timestamp restore failure becomes a warning + +- **WHEN** a copy of `report.docx` succeeds but setting the destination's modification time fails +- **THEN** `report.docx` is counted as copied and is not listed as skipped +- **AND** the end-of-job summary is shown, titled with one warning, and lists `report.docx`'s full destination path with "modification time not restored" and the operating system's message + +#### Scenario: Alternate data stream copy failure becomes a warning + +- **WHEN** a copy of `photo.jpg` that carries a `Zone.Identifier` stream succeeds for the main content but copying the stream fails +- **THEN** the summary lists a warning naming `photo.jpg` and the `Zone.Identifier` stream with the operating system's message + +#### Scenario: A destination without stream support yields one warning + +- **WHEN** forty downloaded files, each carrying a `Zone.Identifier` stream, are copied to a FAT32 USB drive +- **THEN** all forty files are copied, and the summary shows a single warning naming the destination and stating that the streams of 40 files were not copied + +#### Scenario: Warnings and skipped items appear together + +- **WHEN** a job skips two conflicting files and records one read-only-attribute warning on a third +- **THEN** a single end-of-job summary lists the two skipped items and, separately, the one warning, and its title gives both counts + +#### Scenario: Clean job shows no summary + +- **WHEN** a job completes with no skipped items and every metadata restore succeeds +- **THEN** no end-of-job summary is shown diff --git a/openspec/changes/archive/2026-10-14-error-diagnostics/tasks.md b/openspec/changes/archive/2026-10-14-error-diagnostics/tasks.md new file mode 100644 index 0000000..95c2de2 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-error-diagnostics/tasks.md @@ -0,0 +1,66 @@ +# Tasks: error-diagnostics + +## 1. Diagnostic logger (`crates/filecommand-tui/src/diag.rs`) + +- [x] 1.1 Add `diag.rs`: + - a `OnceLock>`; + - `init(path)`, which opens in append mode and writes the session header; + - `log(category, message)`, which writes a UTC `YYYY-MM-DDTHH:MM:SS.mmmZ` timestamp via `listing::DateTime::from_system_time`, flushes, and ignores write errors; + - the `log_err` helper. + + No new dependencies (design D1; application-shell "Optional diagnostic log"). +- [x] 1.2 Unit tests with an injected path: + - a disabled logger creates no file; + - after `init`, two `log` calls append two lines that each start with a timestamp matching the format; + - re-initializing on an existing file keeps its earlier content; + - logging from four threads at once produces whole lines. + +## 2. Activation (`app.rs`, `main.rs`) + +- [x] 2.1 `LaunchOptions::log` and `parse_launch_args`: accept `--log ` and `--log=`, using `--theme`'s absent/empty/value convention (if the `config-profile-directory` change has already landed, `--config-dir` is an equally good model). Add parser tests (design D2). +- [x] 2.2 `run`: before profile resolution, resolve the flag over a non-empty `FILECOMMAND_LOG` and call `diag::init`. An empty value or an `init` error sets a startup warning and continues without logging. Add tests for the precedence and failure paths via a pure `resolve_log_path(flag, env)` helper. +- [x] 2.3 Route every startup-warning assignment in `run` and `apply_user_menu` through a `set_startup_warning` helper that also logs the warning (design D3). +- [x] 2.4 `main.rs` `print_usage`: add the `--log ` line mentioning `FILECOMMAND_LOG`. Extend the help-output test. + +## 3. Swallowed-error sites + +- [x] 3.1 `run_effects`: log `PersistHistory`, `PersistTheme`, and `PersistPanelSplit` failures with the target path and the error. Log every failed `rt.tx.send` and `handle.reply_tx.send` (design D3). +- [x] 3.2 `worker.rs`: log failed `tx.send` calls in every `spawn_*` body and in `ChannelSink`. In `ChannelSink::done`, log each `Warning` item of the outcome under `copy`. + +## 4. Status-line messages name the item (filesystem-error-handling "Error messages name the affected path") + +- [x] 4.1 `spawn_listing`: `ListingFailed` message `Cannot read : `, falling back to the whole path when `file_name()` is `None` (design D4). +- [x] 4.2 `run_effects`: `ViewerOpenFailed` and `EditorOpenFailed` become `Cannot open : `, and `EditorSaveFailed` becomes `Cannot save : `. +- [x] 4.3 Tests: + - a listing of a missing directory yields `Cannot read : …`; + - a root path keeps the whole path as the name; + - the editor and viewer failure formats; + - snapshot tests pin the error dialog's path row and the summary's `path: reason` rows. They already pass and are kept as regressions. + +## 5. Metadata-restore warnings (operation-dialogs "Metadata-restore failures are reported in the end-of-job summary", "End-of-job skipped-files summary") + +- [x] 5.1 `fs_ops/error.rs`: add `ItemKind { Skipped, Warning }` and `SkippedItem::kind`, with `skipped(..)` and `warning(..)` constructors. Migrate the existing literals to `skipped(..)` (design D5). +- [x] 5.2 `fs_ops/fs.rs`: add `FileSystem::supports_named_streams(&Path)`. RealFs on Windows uses `GetVolumeInformationW`'s `FILE_NAMED_STREAMS`, and other platforms return `Ok(false)`. FakeFs gets a toggle. +- [x] 5.3 `fs_ops/worker.rs` `copy_one_file`: push a warning for a failed `list_streams`, each failed `copy_stream_chunked`, a failed `set_modified`, and a failed `set_readonly`. Still return `Done`. When the destination lacks stream support, skip stream copies and add one job-level warning with the file count. Append warnings to the outcome's `skipped` vector. +- [x] 5.4 Engine tests with FakeFs fault injection: + - an injected `set_modified` failure gives `Completed` with one `Warning`, the file counted in `files_done`, and the destination content written; + - an injected `copy_stream_chunked` failure gives a warning naming the stream; + - a `list_streams` failure gives one warning; + - `supports_named_streams == false` with three ADS-bearing files gives exactly one job-level warning naming 3 files; + - a clean copy gives no warnings and an empty `skipped`. +- [x] 5.5 `views/skipped_summary.rs`: list skipped rows, then a `Warnings` group. Title the dialog `Skipped N item(s)`, `N warning(s)`, or `Skipped N item(s), M warning(s)`. Add insta snapshots for warnings only and for mixed. Existing skipped-only snapshots stay unchanged. +- [x] 5.6 Reducer tests: a `JobDone(Completed)` whose `skipped` holds only warnings moves to `FileOpSummary` and still emits the re-read listings; a `JobDone(Completed)` with an empty `skipped` still returns to `Panels` with no summary (operation-dialogs "End-of-job skipped-files summary"). + +## 6. Docs + +- [x] 6.1 README: a "Diagnostics" section covering `--log`, `FILECOMMAND_LOG`, what is recorded, and where to attach the file in a bug report. Add `--log` to the flag table. + +## 7. Verification + +- [x] 7.1 `cargo test --workspace` passes. +- [x] 7.2 `cargo clippy --workspace --all-targets` reports no new warnings. +- [x] 7.3 `openspec validate error-diagnostics --strict` is clean. +- [ ] 7.4 Manual (Windows): + - Run `filecommand --log %TEMP%\fc.log`, make the profile directory read-only, and change the theme. A `persist` line appears. + - Copy a downloaded file to a FAT32 stick. The summary shows one stream warning. + - Navigate to `C:\System Volume Information`. The status line reads `Cannot read System Volume Information: Access is denied. (os error 5)`. diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/.openspec.yaml b/openspec/changes/archive/2026-10-14-filter-selection-safety/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/design.md b/openspec/changes/archive/2026-10-14-filter-selection-safety/design.md new file mode 100644 index 0000000..a4f1dc0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/design.md @@ -0,0 +1,149 @@ +# Design: filter-selection-safety + +## Context + +This was verified against `build/open-proposals-2026-10` at `1bada6f`. + +**Quick filter (`crates/filecommand-core/src/panel.rs`).** + +- `PanelState::quick_filter: Option`. +- `matches_quick_filter` is a case-insensitive substring test, and the `..` entry always matches. +- `visible_indices()` allocates the matching indices. +- `quick_filter_push`/`quick_filter_backspace` call `snap_cursor_to_visible`. That function **returns early when `visible` is empty**, and otherwise moves the cursor to the nearest visible index. +- `move_cursor_filtered` returns early on an empty visible list. +- `jump_to_prefix` (Alt+letter), `handle_click_entry`, and `handle_open_action_menu_at` already target visible entries only: the first by searching the visible subset, the other two because mouse hit maps contain only rendered rows. + +So the only way the cursor rests on a hidden entry is a pattern that matches nothing, in a listing without `..`. That means a drive root or UNC share root, or an empty-result case where `..` is absent. + +**Cursor consumers.** + +- `PanelState::selected()` is `self.entries.get(self.cursor)`, with no filter check. Every cursor-targeted reducer path goes through it: + - `handle_enter` (Enter, including opening the file-action menu); + - `handle_request_viewer` (F3); + - `handle_request_editor`/`handle_request_external_editor` (F4); + - `active_selection_sources` when nothing is selected (F5/F6/F8/Delete, and Ctrl+C clipboard export); + - `paste_cursor_entry` (Ctrl+Enter/Ctrl+]). +- The TUI's mini-status and the opposite panel's quick view also call `selected()` (`views/panel.rs`). +- `toggle_selection_and_advance` (Ins) bypasses `selected()` with its own `entries.get(self.cursor)`, so Ins on an empty filtered view toggles a hidden entry. +- `set_sort_mode` also reads `entries.get(cursor)`, but only as a sort anchor, which is harmless. + +**Selection under a filter.** + +- `active_selection_sources` returns every entry in `panel.selected` when the set is non-empty, whether or not the filter hides it. +- `enter_file_op_setup_for_sources` builds `FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }` for F5/F6, for file-action-menu Copy/Move, and for drag-and-drop drops. +- `enter_delete_confirm_for_sources` builds `FileOpSetup::DeleteConfirm { sources, … }`. + +Rendering: + +- `views/delete_confirm.rs` draws a 4-row box: a frame, `Delete ? This cannot be undone.`, and `(Y)es (N)o`. Its `hit_buttons` re-derives the same geometry. +- `views/destination_input.rs` draws a 6-row box, or 7 rows with drop buttons: frame and title, blank, prompt, field, **blank (row 4)**, optional buttons, frame. + +**Wildcards.** `select_matching` (`+`) and `deselect_matching` (`-`) both call `panel::wildcard_match(pattern, &entry.name.to_string_lossy())`, so they share one matcher. That matcher: + +- lowercases both strings; +- recurses over `as_bytes()`, so `?` consumes a single byte; +- has a `*` arm that tries both "skip `*`" and "consume one byte" recursively, which is exponential in the number of stars on adversarial input. + +## Goals / Non-Goals + +**Goals:** + +- No keyboard command ever acts on a cursor entry the user cannot see. +- Selection-scoped operations keep acting on the whole selection (user decision), and they say so whenever part of that selection is hidden. +- `+`/`-` patterns treat each character as one character, and no pattern can stall the UI. +- Unfiltered panels and dialogs without hidden selections stay byte-for-byte unchanged. + +**Non-Goals:** + +- Changing which entries `+`, `-`, and `*` (invert) affect while a filter is active. They operate on the whole listing, as today, consistent with quick-filter "the quick filter narrows what is shown but does not alter the underlying listing". The disclosure covers the consequence when an operation follows. +- Disclosing hidden selections for clipboard export (Ctrl+C, Send to clipboard). That path has no confirmation dialog, and its feedback count already includes every selected entry. +- Making matching run against the raw `OsString` instead of `to_string_lossy()`. selection "Select a group by wildcard with `+`" says matching runs against the original stored name, but `select_matching`/`deselect_matching` already pass `to_string_lossy()`. The two differ only for names that are not valid Unicode (an unpaired UTF-16 surrogate on Windows becomes one U+FFFD), where `?` still consumes one unit and `*` still spans it. This existing drift is left as is and is not widened by the rewrite. +- Caching visible indices (`streaming-listing-performance`, P-03). +- Any change to operation-dialogs "Destination input dialog" (in flight in `line-editing-in-text-fields`) or "Delete confirmation dialog". + +## Decisions + +### D1: `selected()` is filter-aware; the panel has no current entry when nothing is visible + +`PanelState::selected()` returns `None` when `quick_filter` is `Some(p)` and the cursor entry does not satisfy `matches_quick_filter(entry, p)`. Otherwise it behaves as before. The check costs one string match, not a `visible_indices()` allocation. + +`toggle_selection_and_advance` starts with `let Some(entry) = self.selected()` and does nothing to the selection when there is no current entry. The following `move_cursor` is already a no-op on an empty visible list. + +Because every cursor-targeted command already treats `None` as "do nothing", one change closes all of them: + +- Enter, F3, F4; +- F5/F6/F8/Delete when nothing is selected; +- Ctrl+C on the cursor entry, which then reports its existing `Nothing to copy` feedback instead of copying the hidden entry; +- Ctrl+Enter/Ctrl+]; +- the TUI's opposite-panel quick view, which shows nothing. + +`cursor` itself is left where it is. When the pattern changes so that entries become visible again, `snap_cursor_to_visible` moves it to the nearest visible entry, as today. If the old cursor entry is visible again, it simply stays there. + +Alternatives considered: + +- **A sentinel cursor value (`usize::MAX`) or `Option` cursor.** This touches every cursor user (rendering, scrolling, tabs, re-read anchors) for a state that only exists while a filter matches nothing. +- **Clearing the filter automatically when it matches nothing.** This contradicts quick-filter "No matches yields an empty body", which keeps the panel in filter mode. +- **Guarding each command handler individually.** This needs many call sites, and a future handler would forget the guard. + +### D2: The hidden-selection count is computed when the dialog opens and stored on the setup + +`FileOpSetup::DestinationInput` and `FileOpSetup::DeleteConfirm` gain `hidden_by_filter: usize`. + +- `enter_file_op_setup_for_sources(state, kind, sources, source_side, …)` and `enter_delete_confirm_for_sources` compute it from the **source panel** with a new helper, `PanelState::hidden_selected_count(&[SourceItem])`. A source counts when it lies in that panel's `cwd`, its name is in the panel's selection set, and its entry fails the active `quick_filter`. With no filter the count is 0. + - Restricting the count to selected entries keeps the wording ("selected … hidden by filter") true for every caller. `enter_delete_confirm_for_sources` is also reached from the command-line `del`/`rmdir` builtins (`dispatch_delete_builtin`), whose target the user typed. Such a target counts only if it also happens to be a selected entry of the panel, and then the disclosure is accurate. + - A single-target file-action menu (Enter-opened, or right-clicked on an unselected entry) always targets a visible entry, so its count is 0. +- Every other place that rebuilds these variants (the setup-dialog state transitions in `update.rs`: `FileOpConfirm` toggling `confirmed_once`, text edits, and the drop dialog's verb change, plus `confirm_destination_input`'s early return that keeps the dialog open) carries the field through unchanged. + +The count is stored rather than recomputed at render time because the filter can no longer change while the modal dialog is open. Storing it also keeps the TUI free of filter logic and keeps `hit_buttons` geometry a pure function of the setup. + +Operations still use the full `sources` list. Nothing about job execution changes (user decision). + +Alternative considered: excluding hidden entries from operations, the audit's NC-style suggestion. The user rejected it. Select-then-filter-then-act is a deliberate workflow, and silently shrinking a selection would be its own surprise. + +### D3: Disclosure wording and placement + +The text, used only when `hidden_by_filter > 0`: + +- With several sources: `{h} of {n} selected hidden by filter`, for example `2 of 5 selected hidden by filter`. +- With one source (a selection of one entry that the filter hides): `Selected item is hidden by filter`. + +Placement: + +- **Delete confirmation.** The text goes on its own row between the question and the `(Y)es (N)o` row, so the box grows from 4 to 5 rows. The question line is untouched, so the "names the single item / shows the count / states the deletion is permanent" contract of operation-dialogs "Delete confirmation dialog" holds verbatim. `hit_buttons` uses the same row count: the button row moves down one row when the disclosure is present. Both the preferred and the minimum height passed to `overlay_rect` become 5, still within responsive-layout "Unified overlay geometry"'s 58×14 cap on declared minimums, so the dialog stays renderable at the 60×16 floor. Both functions derive the row layout from one shared helper, so they cannot drift (pending `reducer-overlay-structure`, B-08). The row also appears on the second, non-empty-directory confirmation. +- **Destination dialog** (keyboard F5/F6, file-action-menu Copy/Move, and drop-initiated). The text goes on the existing blank row directly below the input field (row 4), truncated with an ellipsis to the inner width. The box size, the field position, the caret position, and the drop button row are all unchanged, so the in-flight text-field rendering and the mouse hit-testing are unaffected. operation-dialogs "Drop-initiated destination dialog" requires keyboard-initiated dialogs to stay unchanged by the drop feature ("F5 dialog unchanged": no button row). The disclosure adds no button row, appears only when a selected entry is hidden, and the selection requirement states it is additive, so the two do not conflict. +- Both use the dialog's body role. No new theme role is introduced. + +Alternatives considered: + +- **Appending the count to the delete question** (`Delete 5 files (2 hidden)? …`). In narrow terminals the question is truncated with `…`, so the disclosure would be the first thing cut. +- **Changing the destination dialog title** (`Copy (2 hidden)`). The drop dialog's title format is specified by operation-dialogs, and the keyboard title is fixed per verb. + +### D4: Iterative, character-based wildcard matching + +`wildcard_match(pattern, name)` collects `pattern.chars()` and `name.chars()` into `Vec` (names are at most a few hundred characters) and runs the classic two-pointer glob: + +- On a literal or `?` match, both indices advance. `?` matches any one `char`. +- On `*`, it records `star = p`, `mark = s`, and advances the pattern index. +- On a mismatch with a recorded star, it backtracks to `p = star + 1`, `s = mark + 1`, and sets `mark += 1`. +- After the name is consumed, the remaining pattern must be all `*`. + +Only the most recent `*` is ever backtracked to, which is safe because an earlier `*` can never need to absorb more once a later one exists. The worst case is O(|pattern| × |name|), with no recursion and no exponential blow-up. Characters are compared case-insensitively per character: equal, or `a.to_lowercase().eq(b.to_lowercase())`. That avoids pre-lowercasing whole strings, which can change the character count (`İ` lowercases to two scalars) and would break "`?` is one character". + +The audit calls this algorithm "linear". It is linear for typical patterns, but its worst case is the product of the two lengths. The spec states that accurate bound. + +Alternative considered: segment matching with substring search (KMP between stars) for a strict O(n + m). It is more code for no observable gain on file names. + +## Risks / Trade-offs + +- **[Risk] A user at a drive root types a non-matching pattern, presses F8 out of habit, and sees nothing happen.** → This is intentional, and the empty panel body explains it. With a non-empty selection, F8 still opens the confirmation with the disclosure. +- **[Trade-off] The delete confirmation box is one row taller when hidden entries are selected.** → It happens only in that case. The F8 dialog is otherwise unchanged and its snapshots stay byte-identical. +- **[Risk] Per-character case folding differs from whole-string lowercasing for a few special characters** (for example `ß` against `SS`). → Whole-string lowercasing never matched `ß` against `SS` either. For a single-character fold, the behavior is identical to today. +- **[Risk] `selected()` returning `None` in a state that used to return `Some` could surprise a caller that assumed `Some` whenever `entries` is non-empty.** → A grep shows that every caller already handles `None`. The unit tests pin each user-visible command's no-op. + +## Migration Plan + +None. These are behavioral fixes with no persisted state. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/proposal.md b/openspec/changes/archive/2026-10-14-filter-selection-safety/proposal.md new file mode 100644 index 0000000..da2b274 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/proposal.md @@ -0,0 +1,71 @@ +# Change: filter-selection-safety + +## Why + +The 2026-10-12 code audit found two ways the quick filter and group selection can act on, or fail to match, entries the user did not expect. + +**R-17 (Medium): the quick filter can leave the cursor on a hidden entry, and operations can act on hidden selections.** + +- `PanelState::snap_cursor_to_visible` returns early when no entry is visible. At a drive root there is no `..` entry, so a pattern that matches nothing leaves `cursor` on an entry the filter hides. +- `PanelState::selected()` returns `entries[cursor]` without checking the filter. Enter, F3, F4, F5, F6, F8/Delete, and the file-action menu all go through it, and Ins (`toggle_selection_and_advance`) reads `entries[cursor]` directly. Each of them then acts on a file that is not on screen. The confirmation dialog names the file, which limits the damage, but the user is deleting or moving something they cannot see. +- `update::active_selection_sources` builds F5/F6/F8 sources from the whole selection set, including entries the active filter hides. The user decided to keep that behavior, because selecting and then filtering is a legitimate workflow. But nothing tells the user that the operation reaches beyond what is visible. + +**R-19 (Low): `wildcard_match` matches bytes and can backtrack exponentially.** + +- `panel::wildcard_match` runs on the `as_bytes()` of the lowercased strings, so `?` consumes one UTF-8 byte, not one character. `?.txt` therefore does not match `é.txt`, a two-byte character, in either `+` (select) or `-` (deselect); both use the matcher. +- The recursive `*` arm is the naive exponential algorithm. A pattern such as `*a*a*a*a*a*a*b` against long runs of `a` can stall the reducer, and with it the whole UI. + +## What Changes + +- **No current entry when the filter hides everything.** + - When the quick-filter pattern leaves no entry visible (possible only where there is no `..`, such as a drive root), the panel has no current entry. + - `selected()` returns an entry only when it is visible under the active filter, and Ins goes through the same rule. Every command that acts on the cursor entry does nothing until the pattern changes so that an entry is visible again: Enter, F3, F4, F5/F6/F8/Delete without a selection, Ins, the file-action menu, and Ctrl+Enter/Ctrl+]. + - When the pattern is widened, the cursor snaps to the nearest visible entry, as today. +- **Hidden selections are disclosed.** + - Copy, Move, and Delete still act on every selected entry, including entries the quick filter hides (user decision). + - When any of the selected entries are hidden, the delete confirmation dialog and the copy/move destination dialog (keyboard- or drop-initiated) state how many, for example `2 of 5 selected hidden by filter`. + - Dialogs with no hidden selected entries are unchanged. +- **Character-correct, bounded wildcard matching.** + - `+` and `-` share one matcher. In it `?` matches exactly one character (one Unicode scalar value, not one byte), `*` matches any run of characters including none, and comparison stays case-insensitive. + - Matching uses the iterative two-pointer glob algorithm with single-star backtracking. Its worst case is proportional to pattern length × name length, with no exponential blow-up. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `quick-filter`: "Cursor and mini-status behavior under an active filter" gains the no-visible-entry rule: no current entry, and cursor-targeted commands do nothing until an entry is visible again. +- `selection`: + - ADDED "Operations disclose selected entries hidden by the quick filter": operations act on every selected entry, and the delete confirmation and copy/move destination dialogs state the hidden count; + - ADDED "Wildcard pattern matching for group select and deselect": `?` is one character, `*` is any run, case-insensitive, bounded time. It applies to both `+` and `-` without rewriting their existing requirements. + +## Impact + +- **Specs:** `quick-filter` (one MODIFIED requirement), `selection` (two ADDED requirements). "Destination input dialog" in operation-dialogs (in flight in `line-editing-in-text-fields`) and "Delete confirmation dialog" are not modified. +- **Code (core):** + - `crates/filecommand-core/src/panel.rs`: + - `selected()` returns `None` when a quick filter is active and the cursor entry does not match it; + - `toggle_selection_and_advance` uses `selected()`; + - a new `hidden_selected_count()`, counting the sources that are selected entries of the panel and fail its active filter; + - `wildcard_match` is rewritten (char slices, iterative two-pointer algorithm, per-character case folding). + - `crates/filecommand-core/src/fs_ops/dialog.rs`: `FileOpSetup::DestinationInput` and `FileOpSetup::DeleteConfirm` gain a `hidden_by_filter: usize` field. + - `crates/filecommand-core/src/update.rs`: `enter_file_op_setup_for_sources` and `enter_delete_confirm_for_sources` compute the field from the source panel; the other constructors of these variants carry it through. +- **Code (TUI):** + - `crates/filecommand-tui/src/views/delete_confirm.rs`: an extra disclosure row when the count is non-zero, with `hit_buttons` geometry kept in step. + - `crates/filecommand-tui/src/views/destination_input.rs`: the disclosure goes on the existing blank row below the field, so the box geometry is unchanged. +- **Tests:** + - `panel.rs` unit tests for `selected()` under the filter, Ins with no current entry, and `wildcard_match` (Unicode `?`, pathological patterns, a proptest against a reference matcher); + - `update/tests.rs` for F3/F4/F5/F8/Enter no-ops and for the disclosure count; + - insta snapshots of both dialogs with and without hidden selected entries. +- **Compatibility:** no change for unfiltered panels or for dialogs without hidden selections. `+`/`-` patterns now match multi-byte characters with `?` as users expect. + +**Audit findings:** R-17, R-19 + +**Coordination:** +- `streaming-listing-performance` (P-03) caches visible indices. `snap_cursor_to_visible`, `move_cursor_filtered`, and `hidden_selected_count` should read that cache once it exists. The `selected()` visibility check here is a single-entry match, so it does not depend on the cache. +- `line-editing-in-text-fields` (in flight) modifies operation-dialogs "Destination input dialog" and the destination field rendering. This change does not touch that requirement. The disclosure line uses a row the field rendering does not occupy, so the two changes merge without overlap in `destination_input.rs`'s field code. +- `reducer-overlay-structure` (B-08) unifies render and hit-test geometry. Until it lands, `delete_confirm.rs`'s `hit_buttons` must mirror the new disclosure row exactly, and this change keeps the two in step. If it lands first, the disclosure row goes into `delete_confirm`'s shared layout function instead. +- `relative-destination-resolution` (R-06) adds an early return in `confirm_destination_input` that rebuilds `DestinationInput`, and `link-aware-listing` (R-05) edits `enter_delete_confirm_for_sources`. Whichever lands second carries the new `hidden_by_filter` field through those edits. `offload-blocking-io` (R-09) makes `dispatch_delete_builtin` asynchronous; it still opens the dialog through `enter_delete_confirm_for_sources`. diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/quick-filter/spec.md b/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/quick-filter/spec.md new file mode 100644 index 0000000..1b07a0d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/quick-filter/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: Cursor and mini-status behavior under an active filter + +The system SHALL keep the panel cursor positioned on a currently visible (matching) entry while a quick filter is active. When the entry under the cursor is filtered out by a pattern change, the system SHALL move the cursor to the nearest remaining visible entry. + +When the pattern leaves no entry visible, the panel SHALL have no current entry. This can happen only where the listing has no `..` parent entry, such as a drive root. While there is no current entry, every command that acts on the entry under the cursor SHALL do nothing. That covers: + +- Enter; +- F3 and F4; +- F5, F6, F8, and the Delete key when no entries are selected; +- Ins; +- opening the file-action menu; +- pasting the cursor entry's name or path to the command line. + +No command that targets the entry under the cursor SHALL act on an entry the filter hides. Copy, Move, and Delete of selected entries are governed by selection "Operations disclose selected entries hidden by the quick filter" instead. When the pattern changes so that at least one entry is visible again, the cursor SHALL move to the nearest visible entry. + +#### Scenario: Cursor moves to a visible entry when its target is filtered out + +- **WHEN** the cursor is on an entry and the user extends the pattern so that entry no longer matches +- **THEN** the cursor moves to a still-visible matching entry rather than a hidden one + +#### Scenario: Navigation is restricted to matching entries + +- **WHEN** a quick filter is active and the user moves the cursor with the arrow keys +- **THEN** the cursor traverses only the visible matching entries (and `..`), skipping filtered-out entries + +#### Scenario: No visible entry at a drive root means no current entry + +- **WHEN** the active panel shows the root of drive `C:` (no `..` entry), the cursor is on `pagefile.sys`, and the user types a quick-filter pattern that matches no entry +- **THEN** the panel body is empty and the panel has no current entry + +#### Scenario: Cursor-targeted operations do nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry, nothing is selected, and the user presses F8, F5, F6, F3, F4, or Enter +- **THEN** no dialog, viewer, editor, or menu opens, and no file is affected + +#### Scenario: Ins does nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry and the user presses Ins +- **THEN** the selection set is unchanged + +#### Scenario: Widening the pattern restores a current entry + +- **WHEN** a quick filter matches no entry and the user presses Backspace until the pattern matches `notes.txt` +- **THEN** the cursor is on a visible entry and cursor-targeted commands act on that entry again diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/selection/spec.md b/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/selection/spec.md new file mode 100644 index 0000000..a7e3a29 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/specs/selection/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Operations disclose selected entries hidden by the quick filter + +When Copy (F5), Move (F6), or Delete (F8 or the Delete key) acts on the panel's selection, it SHALL act on every selected entry, including entries the active quick filter currently hides. This applies equally when the operation is started from a selection-scoped file-action menu or by dragging selected entries; the Enter-opened file-action menu stays single-target as file-action-menu specifies. When one or more of the selected entries the operation acts on are hidden by the quick filter, two dialogs SHALL state how many of them are hidden by the filter: the delete confirmation dialog, and the copy/move destination dialog whether keyboard- or drop-initiated. The statement SHALL appear in addition to the dialog's existing content. When no selected entry the operation acts on is hidden, both dialogs SHALL be unchanged. + +#### Scenario: Delete confirmation states the hidden count + +- **WHEN** five entries are selected, a quick filter hides two of them, and the user presses F8 +- **THEN** the confirmation dialog shows the count of 5 items, states that the deletion is permanent, and states that 2 of the 5 selected entries are hidden by the filter +- **AND** confirming deletes all five entries + +#### Scenario: Destination dialog states the hidden count + +- **WHEN** five entries are selected, a quick filter hides two of them, and the user presses F5 +- **THEN** the destination input dialog opens pre-filled with the opposite panel's path and states that 2 of the 5 selected entries are hidden by the filter +- **AND** accepting the destination copies all five entries + +#### Scenario: A single hidden selected entry is disclosed + +- **WHEN** exactly one entry, `old.log`, is selected, the quick filter hides it, and the user presses F8 +- **THEN** the confirmation dialog names `old.log` and states that the selected item is hidden by the filter + +#### Scenario: No hidden selection leaves the dialogs unchanged + +- **WHEN** entries are selected and none of them is hidden by a quick filter, and the user presses F5 or F8 +- **THEN** the destination or confirmation dialog appears exactly as it does without a quick filter + +### Requirement: Wildcard pattern matching for group select and deselect + +The wildcard patterns entered for `+` (select a group) and `-` (deselect a group) SHALL be matched against entry names by one shared matcher. In that matcher `*` matches any run of characters (including none), `?` matches exactly one character, and every other pattern character matches itself case-insensitively. A character SHALL be one Unicode scalar value of the name, regardless of how many bytes it occupies in any encoding. Matching SHALL take time at most proportional to the product of the pattern length and the name length, so no pattern can make the panel unresponsive. + +#### Scenario: Question mark matches one multi-byte character + +- **WHEN** the panel lists `é.txt` and `ab.txt`, and the user presses `+`, enters `?.txt`, and confirms +- **THEN** `é.txt` is selected and `ab.txt` is not + +#### Scenario: Deselect uses the same matching + +- **WHEN** `é.txt` is selected and the user presses `-`, enters `?.txt`, and confirms +- **THEN** `é.txt` is no longer selected + +#### Scenario: Matching ignores case + +- **WHEN** the panel lists `README.TXT` and the user selects the group `*.txt` +- **THEN** `README.TXT` is selected + +#### Scenario: Pathological pattern completes promptly + +- **WHEN** the panel lists 1,000 entries, each named 200 `a` characters followed by a distinct four-digit number, and the user selects the group `*a*a*a*a*a*a*a*a*a*a*b` +- **THEN** the dialog closes and the panel responds to the next key without a perceptible delay, and no entry is selected diff --git a/openspec/changes/archive/2026-10-14-filter-selection-safety/tasks.md b/openspec/changes/archive/2026-10-14-filter-selection-safety/tasks.md new file mode 100644 index 0000000..fb2335a --- /dev/null +++ b/openspec/changes/archive/2026-10-14-filter-selection-safety/tasks.md @@ -0,0 +1,60 @@ +# Tasks: filter-selection-safety + +## 1. No current entry under an empty filter (`crates/filecommand-core/src/panel.rs`) + +- [x] 1.1 Make `PanelState::selected()` return `None` when `quick_filter` is `Some(p)` and the cursor entry fails `matches_quick_filter(entry, p)`. The check matches the single entry and does not allocate `visible_indices()` (quick-filter "Cursor and mini-status behavior under an active filter"; design D1). +- [x] 1.2 Make `toggle_selection_and_advance` (Ins) take its entry from `selected()`, so it toggles nothing when there is no current entry (quick-filter "Ins does nothing while no entry is visible"; design D1). +- [x] 1.3 Leave `cursor` untouched when nothing is visible, and confirm that `snap_cursor_to_visible` moves it to the nearest visible entry once the pattern widens (quick-filter "Widening the pattern restores a current entry"). + +## 2. Hidden-selection disclosure (R-17) + +- [x] 2.1 Add `PanelState::hidden_selected_count(&[SourceItem]) -> usize`: the number of sources that lie in the panel's `cwd`, are in its selection set, and whose entry fails the active `quick_filter`; 0 with no filter (design D2). +- [x] 2.2 `crates/filecommand-core/src/fs_ops/dialog.rs`: add `hidden_by_filter: usize` to `FileOpSetup::DestinationInput` and `FileOpSetup::DeleteConfirm`. Set it to 0 for Mkdir. +- [x] 2.3 `crates/filecommand-core/src/update.rs`: compute the field from the **source** panel in `enter_file_op_setup_for_sources` and `enter_delete_confirm_for_sources`. That covers F5/F6, F8/Delete, file-action-menu Copy/Move/Delete, and drag-and-drop drops. Carry it through every rebuild of these variants in the setup-dialog transitions. Leave `active_selection_sources` and job execution unchanged (selection "Operations disclose selected entries hidden by the quick filter"; design D2). +- [x] 2.4 `crates/filecommand-tui/src/views/delete_confirm.rs`: when `hidden_by_filter > 0`, add a disclosure row between the question and the buttons (`{h} of {n} selected hidden by filter`, or `Selected item is hidden by filter` for one source). Derive the row layout for both `render_delete_confirm` and `hit_buttons` from one shared helper, so the button rects move with the extra row (design D3). +- [x] 2.5 `crates/filecommand-tui/src/views/destination_input.rs`: when `hidden_by_filter > 0`, draw the same wording on the blank row below the field, truncated to the inner width. Leave the box size, field, caret, title, and drop buttons unchanged (design D3). + +## 3. Wildcard matcher (R-19) + +- [x] 3.1 Rewrite `panel::wildcard_match` as the iterative two-pointer glob over `Vec` with single-star backtracking and per-character case-insensitive comparison, with no recursion and no whole-string lowercasing. `select_matching` and `deselect_matching` keep calling it, so `+` and `-` share it (selection "Wildcard pattern matching for group select and deselect"; design D4). + +## 4. Tests + +- [x] 4.1 `panel.rs`: + - a drive-root-style listing (no `..`) with a filter matching nothing makes `selected()` return `None`; + - with a matching filter, `selected()` returns the cursor entry; + - with no filter, it returns the cursor entry exactly as before (quick-filter "No visible entry at a drive root means no current entry"). +- [x] 4.2 `panel.rs`: Ins with no current entry leaves `selected` unchanged (R-17 regression: today it toggles the hidden cursor entry). +- [x] 4.3 `update/tests.rs` (FakeFs root listing without `..`, filter matching nothing, empty selection): F8, F5, F6, F3, F4, Enter, and Ctrl+Enter each produce no effects and no phase change, `file_action_menu` stays `None`, and the command line is unchanged (quick-filter "Cursor-targeted operations do nothing while no entry is visible"). +- [x] 4.4 `update/tests.rs`: after Backspace widens the pattern to match `notes.txt`, F3 emits `Effect::OpenViewer` for `notes.txt` (quick-filter "Widening the pattern restores a current entry"). +- [x] 4.5 `update/tests.rs`: + - with 5 selected and 2 hidden, F8 yields `DeleteConfirm { hidden_by_filter: 2, sources.len() == 5 }`, and F5 yields `DestinationInput { hidden_by_filter: 2 }`; + - after confirming, the job's sources are all five; + - with no filter the field is 0; + - a drop from the inactive panel computes the count against the source panel; + - a right-click selection-scoped file-action-menu Delete reports the same count, and an Enter-opened (single-target) menu reports 0; + - a command-line `del` of an unselected entry hidden by the filter reports 0 (selection "Delete confirmation states the hidden count", "Destination dialog states the hidden count"; design D2). +- [x] 4.6 `views/delete_confirm.rs` and `views/destination_input.rs`: + - insta snapshots with `hidden_by_filter = 2` and `= 1` (single source); + - existing snapshots with `hidden_by_filter = 0` unchanged; + - a `hit_buttons` test confirming the `(Y)es`/`(N)o` rects sit on the shifted row when the disclosure is shown (selection "No hidden selection leaves the dialogs unchanged"). +- [x] 4.7 `panel.rs` `wildcard_match`: + - `?.txt` matches `é.txt` and `日.txt`, and does not match `ab.txt`; + - `*.TXT` matches `readme.txt`; + - `a*` matches `a`; + - `*` matches the empty string; + - `?` does not match the empty string; + - the existing `wildcard_match_supports_star_and_question_case_insensitive` cases still pass (selection "Question mark matches one multi-byte character", "Matching ignores case"). +- [x] 4.8 `panel.rs`: a pathological case, `*a*a*a*a*a*a*a*a*a*a*b` against a 200-`a` name, returns `false`, and the 1,000-name loop finishes within a generous time bound (selection "Pathological pattern completes promptly"). +- [x] 4.9 `panel.rs`: a proptest comparing `wildcard_match` with a simple reference matcher (recursive over `char`s, small inputs only) for ASCII and non-ASCII strings with `*`/`?`. +- [x] 4.10 `panel.rs`/`update/tests.rs`: `-` with `?.txt` deselects `é.txt` (selection "Deselect uses the same matching"). + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green, including `cargo insta test` review of the new snapshots. +- [x] 5.2 `cargo clippy --workspace --all-targets` with no new warnings. +- [x] 5.3 `openspec validate filter-selection-safety --strict` clean. +- [ ] 5.4 Manual check: + - at `C:\`, Ctrl+P, type `zzzz`, then press F8, F3, and Enter; nothing happens; + - select several files, filter some out, and press F8 and F5; both dialogs show `N of M selected hidden by filter`; + - `+` with `?.txt` selects `é.txt`. diff --git a/openspec/changes/archive/2026-10-14-git-info-timeout-memory/.openspec.yaml b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-git-info-timeout-memory/design.md b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/design.md new file mode 100644 index 0000000..c31559d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/design.md @@ -0,0 +1,86 @@ +# Design: git-info-timeout-memory + +## Context + +Verified on `build/open-proposals-2026-10` (commit `1bada6f`): + +- **Spawning.** `Effect::QueryGitInfo { panel, path, request }` is built by `git_info_query_effect` (`update.rs:3840-3844`), called from `State::initial` (`update.rs:450-451`), `begin_listing_inner` (`update.rs:3833`, every navigation and re-read), and `handle_swap_panels` (`update.rs:3652`). `run_effects` calls `worker::spawn_git_info_query` (`app.rs:924`), which spawns an outer thread that calls `race_with_timeout(GIT_INFO_TIMEOUT = 3 s, || git_info::query(&path))` (`worker.rs:74-98`); `race_with_timeout` spawns an inner thread and waits on `recv_timeout`, returning `None` both on a timeout and when the inner thread dies without sending (a panic). On `None` the outer thread sends `GitInfoResolved { info: GitInfo::none() }`; after a timeout the inner thread keeps walking until libgit2 returns, and its result is dropped. Nothing records the timeout and nothing limits how many queries run at once — the audit's claim is accurate. +- **Reducer.** `Command::GitInfoResolved { panel, path, request, info }` is applied only when `p.cwd == path && p.git_request == Some(request)` (`update.rs:1264-1269`). There is no "no info" memory in `State`. `PanelState::begin_new_listing` resets `git_info` to `GitInfo::none()` and `git_request` to `None` (`panel.rs:535-536`); `handle_swap_panels` does not — each panel's `git_info` moves with it and is re-queried. +- **Query.** `git_info::query` (`git_info.rs:86-93`) calls `Repository::discover(dir)` every time, resolves the branch, then `pathspec_status` (`git_info.rs:105-150`): it canonicalizes `dir` and the workdir, strips the prefix to get `rel_dir`, and — when `rel_dir` is non-empty — sets `opts.pathspec(rel_dir with '\\' replaced by '/')`. Every status entry is then folded through `full.strip_prefix(&canon_dir)`, so an entry outside the panel directory never reaches the result map whatever the pathspec matched. At the repository root `rel_dir` is empty, no pathspec is set, and the walk covers the whole working tree; that is correct by definition (the root *is* the panel directory) and unchanged here. +- **How libgit2 1.8.1 (vendored by `libgit2-sys 0.17.0+1.8.1`) treats the pathspec.** `git_attr_fnmatch__parse` flags any unescaped `[`, `*`, or `?` (`git__iswildcard`) as `HASWILD`, treats a leading `!` as negation, and drops a pattern whose first character is `#` as a comment (`attr_file.c:729`). `pathspec_match_one` (`pathspec.c:138-171`) tries an exact compare, then `wildmatch` (no `WM_PATHNAME`, so `*` and `?` also match `/`), then a "directory prefix" fallback that is skipped when `HASWILD` is set, then a literal fallback for paths that themselves begin with `!`. Separately, `git_pathspec_prefix` (`pathspec.c:21-46`) turns the text before the first wildcard into the iterator's start/end bound, so the walk only visits paths with that string prefix. Probed against a real repository with `git2 0.19.0` on Windows (`core.ignorecase = true`), with a modified `a.txt` and an untracked `new/` in each directory: + - `foo[1]` and `x/foo[1]` get **nothing**: `[1]` is a character class matching `1`, and the prefix fallback is skipped. On other platforms `*` and `?` in a name break the same way. This is the real bug. + - `!old` **works today**: the pattern becomes the negation of `old`, but the literal `!`-path fallback matches `!old/a.txt` and `!old/new/`. + - `#notes` **works today and stays scoped**: the comment-pattern is dropped, but the iterator bound `#notes` still limits the walk to paths starting with `#notes` (a sibling `#notes2` is walked too and removed by the `strip_prefix` fold). The query is not repo-wide. + - Names with spaces or `]` work today. +- **Backslash escaping is not available on Windows.** `git2 0.19`'s `StatusOptions::pathspec` passes the string through `util::cstring_to_repo_path`, which on Windows rewrites every `\` to `/` (`fixup_windows_path`). An escaped pathspec such as `foo\[1\]/*` reaches libgit2 as `foo/[1/]/*` and matches nothing; in the probe every backslash-escaped form (`foo\[1\]/*`, `\!old/*`, `\#notes/*`, `a\ b/*`) returned no entries at all. +- **`disable_pathspec_match(true)`** (the audit's suggestion) moves the pathspec to the iterator's literal path list, which includes a directory's children only when the entry ends in `/` (`iterator.c:302-356`), and still runs directories through `pathspec_match_one` with `wildmatch` off — where the prefix fallback is skipped for a `HASWILD` pattern. Probed: `foo[1]/` with the flag set reports `foo[1]/a.txt` but drops the untracked `foo[1]/new/`. + +## Goals / Non-Goals + +**Goals:** + +- The spec's existing "no info for the session" rule actually holds, observably and testably. +- No pile-up of status walks: at most one per panel in flight, plus at most one orphaned walk per panel per remembered timeout. +- Correct markers for directories whose names contain pattern-special characters, on Windows and elsewhere, without regressing names that already work (`!old`, `#notes`, spaces). +- The reducer stays pure; the remembered set is ordinary `State`, unit-testable in core. + +**Non-Goals:** + +- Cancelling a libgit2 status walk mid-walk (no API exists) or reusing `listing-request-guard`'s `CancelRequest` for git queries. +- Caching an open `Repository` per working directory or a long-lived git thread (open question). +- Changing the 3-second timeout, adding a way to retry a remembered repository within the session, or persisting the set across sessions. +- Worker panic handling in general (`process-lifecycle-hardening`); this change only keeps a panic from being mistaken for a timeout. +- `git2` feature trimming (B-04, `ci-and-build-tooling`). + +## Decisions + +### D1: The worker reports the timeout and the repository root; `State` remembers it + +`Command::GitInfoResolved` gains `timed_out_root: Option`, and `State` gains `git_no_info_roots: Vec` (session-only, never persisted). The reducer records a reported root (deduplicated) **before** its stale-reply check — a timeout is evidence about the repository, not about the panel's current directory, so it is kept even when the panel has already moved on. Recording a root changes no panel field, so a stale timed-out reply still leaves the current rendering unchanged (git-info "Late result after navigation is dropped"). `git_info_query_effect` returns `Option`: for a path under any remembered root it issues nothing, and sets the panel's `git_info` to `GitInfo::none()` and `git_request` to `None`. After `begin_new_listing` both are already reset; the explicit reset matters for `handle_swap_panels`, where a panel that resolved branch info before the timeout would otherwise keep showing it inside a "no info" repository. `State::initial`, `begin_listing_inner`, and `handle_swap_panels` handle the `None`. + +*Alternative:* a TUI-side `HashSet` consulted in `run_effects` — keeps the reducer unchanged, but the "is this directory under a slow repository" decision would live outside core, invisible to `update` tests, and the reducer would mint `git_request`s that are never answered. Rejected in favor of the pure-core version. + +The "Background repository detection" scenario "Query re-issued on navigation" is narrowed to directories outside a remembered repository: as written it promised a query on every navigation, which the existing "no info for the session" rule already contradicted. + +### D2: Which root is remembered, spelled the way the panel spells it + +`git_info::query` is split into `discover(dir) -> Option<(Repository, depth)>` and the status walk. The inner thread publishes the discovered root into an `Arc>>` before starting the walk. The root is expressed in the queried path's own spelling: `canon_dir.strip_prefix(canon_workdir)` has `k` components, so the root is `dir.ancestors().nth(k)` — the same spelling the panel's later `cwd`s will use, avoiding 8.3-short-name, `\\?\`, and separator mismatches between the panel and libgit2's `C:/…/` workdir. On timeout the outer thread reports the published root, or the queried directory itself when discovery had not finished (then only that directory and its descendants are remembered). Membership (`git_info::is_under_root`) is a component-wise `starts_with`, ASCII-case-insensitive on Windows; it lives in core so both the reducer and the worker use the same test. + +Only a real timeout reports a root. `race_with_timeout` distinguishes `RecvTimeoutError::Timeout` from `Disconnected` (the inner thread died without sending, i.e. panicked): a panicked query reports `GitInfo::none()` with `timed_out_root: None`, as today, so a one-off panic does not silence a repository for the session. + +### D3: One query in flight per panel, newest request waits + +`Runtime` owns a `GitQuerySlot` per panel side: `Mutex<{ running: bool, pending: Option<(PathBuf, u64)> }>`. `Effect::QueryGitInfo` either starts the slot's runner thread (when idle) or replaces `pending` (when running). The runner loops: run one query through `race_with_timeout`, send `GitInfoResolved`, then take `pending` and continue, or mark the slot idle and exit. A replaced pending request is never started — its id is already superseded in `PanelState::git_request`, so its reply would be dropped anyway. When the query just reported a timeout with root `R`, the runner also discards a pending request whose directory is under `R` (`git_info::is_under_root`): it was queued before the reducer learned about `R`, and starting it would begin a second walk of the slow repository — exactly the pile-up this change removes. Its panel shows no git info there, which is what the reducer would decide anyway once `R` is recorded. + +The slot is released when the reply is **sent**, not when an orphaned libgit2 walk eventually returns: holding the slot for a walk that may take minutes would starve git info for every other repository on that panel, and D1 already ensures no new query enters the slow repository. The `running` flag is cleared by a drop guard, so a panic in the runner cannot leave the slot marked busy (which would end git info for that panel for the session). + +*Alternatives:* drop the new request while one runs (the audit's "drop a query if one is already running") — the panel would never get git info for the directory it ended up in; rejected. A long-lived git thread per panel that also caches `Repository` handles — larger change, see Open Questions. + +### D4: Literal pathspec by bracket-quoting wildcard characters, plus `/*` + +`pathspec_status` builds its single pathspec with `literal_pathspec(rel_dir)`, joining `rel_dir`'s components with `/` (instead of replacing `\` in the whole string): + +- If the relative path contains none of libgit2's wildcard characters (`[`, `*`, `?`, and on non-Windows a `\`, which libgit2 reads as an escape), it is passed **unchanged** — today's form, which the probe shows already handles `!old`, `#notes`, spaces, and `]` with the walk scoped by the iterator prefix. +- Otherwise each `[`, `*`, and `?` becomes a one-character bracket class (`[[]`, `[*]`, `[?]`), a non-Windows `\` becomes `?`, a leading `!` or `#` becomes `?`, and `/*` is appended. The bracket classes match the literal character without needing a backslash (which git2-rs rewrites to `/` on Windows); `/*` is needed because the prefix fallback is off for any wildcard pattern, and since `*` also matches `/` it covers files at any depth and unexpanded untracked subdirectories (`new/`). A leading `!` or `#` would otherwise make the pattern a negation or a comment; `?` in its place can also match a sibling that differs only in that first character, and such entries are removed by `pathspec_status`'s existing `strip_prefix(&canon_dir)` fold. + +Probed on Windows: `foo[[]1]/*`, `x/foo[[]1]/*`, `[[]x]/*`, `x/!b[[]2]/*`, `?b[[]2]/*` (for `!b[2]`), and `?c[[]3]/*` (for `#c[3]`) each report the directory's modified `a.txt` and untracked `new/` and nothing else after the fold. Case folding is unchanged (libgit2 applies `core.ignorecase` through `WM_CASEFOLD`). + +The walk stays scoped by the iterator prefix up to the first quoted character (`foo` for `foo[1]`, `x/foo` for `x/foo[1]`). For a top-level name that starts with a pattern character, or with `!`/`#` and also contains one, the prefix is empty and libgit2 walks the whole working tree for that directory; the reported markers are still correct. + +*Alternatives:* backslash escaping (`foo\[1\]/*`, the original plan) — works on Unix but matches nothing on Windows because git2-rs rewrites `\` to `/`; rejected. `disable_pathspec_match(true)` with a trailing `/` (the audit's suggestion) — literal for files, but untracked subdirectories of a directory whose name contains `[`, `*`, or `?` lose their `?` marker (Context); rejected. Calling `git_status_list_new` through `libgit2-sys` directly to bypass the rewrite — needs a new direct dependency and `unsafe` for a rare case; rejected. + +## Risks / Trade-offs + +- [A repository that was only slow once (cold cache, momentary network stall) shows no git info for the rest of the session] → this is the existing spec's rule; restarting FileCommand retries. +- [Remembering the queried directory when discovery itself timed out can miss sibling directories of the same slow repository] → those get one query each, bounded by distinct directories visited; each timeout adds its own root. +- [An orphaned walk still runs to completion per timeout] → bounded to one per panel per remembered root, since no new or queued query enters it. +- [The quoting relies on libgit2's `wildmatch` bracket-class semantics and on git2-rs's Windows backslash rewrite] → covered by real-repository tests for `foo[1]`, a nested `x/foo[1]`, `[x]`, `!b[2]`, `#c[3]`, `!old`, `#notes`, and a name with a space on every platform, and `a*b`, `what?`, and `back\slash` on non-Windows (Windows forbids those characters in names); a `git2` upgrade that changes either behavior fails these tests. +- [A top-level directory whose name starts with a pattern character is walked repo-wide] → rare; correctness is unaffected and it is no worse than the repository root, which is always walked repo-wide. + +## Migration Plan + +None — no persisted data or configuration changes. + +## Open Questions + +- Should a long-lived git thread per panel cache an open `Repository` per working directory, avoiding `Repository::discover` and index loading on every navigation? Worth measuring on a large monorepo after this change; out of scope here. diff --git a/openspec/changes/archive/2026-10-14-git-info-timeout-memory/proposal.md b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/proposal.md new file mode 100644 index 0000000..ec9c0d5 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/proposal.md @@ -0,0 +1,34 @@ +## Why + +The git-info spec already says a status query that times out marks its repository "no info" for the rest of the session, but the code never remembers it (audit 2026-10-12 **P-07**, Medium; §6 "Timeout marks the repo 'no info' for the session": **DRIFT**). Every navigation spawns two threads — `spawn_git_info_query`'s outer thread and `race_with_timeout`'s inner one — and after the 3-second timeout the inner libgit2 status walk keeps running because it cannot be cancelled. The next navigation inside the same huge repository or SMB share starts another full walk, and rapid navigation piles up concurrent, uncancellable walks that compete for I/O and make every later query slower still. Separately, the panel directory is passed to libgit2 as a wildcard pattern, so a directory whose name contains `[` (for example `foo[1]`) — or, outside Windows, `*` or `?` — silently gets no status markers. (Names with a leading `!` or `#`, spaces, or `]` already work today; they are pinned by scenarios so the fix does not regress them.) + +## What Changes + +- A timed-out query's repository is remembered for the session: the worker reports the timeout together with the repository's working-directory root (or, if the repository could not even be located in time, the queried directory), the reducer stores it in `State`, and no further git query is issued for any directory under a remembered root — the panel simply shows no git indicators there, silently, as the spec already requires. +- At most one git status query runs per panel at a time: a navigation while one is running queues the newest request, replacing any older queued one, and starts it when the running query reports; superseded requests are never started, and neither is a queued request for a directory inside the repository the running query has just timed out on. +- Directory names are matched literally in the status pathspec: `[`, `*`, and `?` in a directory name are quoted as one-character bracket classes instead of being interpreted as wildcards (backslash escaping cannot be used, because `git2` rewrites `\` to `/` in pathspecs on Windows). Paths without those characters are passed exactly as today. +- Unchanged: the 3-second timeout, silent degradation, stale-result discarding via `git_request`, the repo-wide walk at the repository root (pathspec-scoping applies below the root, by definition). +- Non-goal: caching an open `Repository` per working directory on a long-lived git thread (left as an open question in design.md). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `git-info`: MODIFIED "Background repository detection" (no query is issued inside a repository marked "no info"), MODIFIED "Silent absence on timeout and stale-result discarding" (the remembered-repository rule made explicit and testable; one in-flight query per panel), MODIFIED "Pathspec-scoped status queries" (directory names matched literally). + +## Impact + +- **Core (`crates/filecommand-core`):** + - `git_info.rs` — `query` split so repository discovery can publish the working-directory root before the status walk; `is_under_root` (shared by reducer and worker); a literal-pathspec builder (`literal_pathspec`) used by `pathspec_status`; real-repository tests with directories named `foo[1]`, `x/foo[1]`, `[x]`, `!b[2]`, `#c[3]`, `!old`, `#notes`, and one containing a space. + - `update.rs` — `Command::GitInfoResolved` gains `timed_out_root: Option`; `State` gains a session-only set of "no info" roots; the `GitInfoResolved` handler records a root even when the reply itself is stale; `git_info_query_effect` (called from `State::initial`, `begin_listing_inner`, `handle_swap_panels`) issues no query for a path under a remembered root and clears that panel's git info. + - Tests in `update/tests.rs`. +- **TUI (`crates/filecommand-tui`):** `worker.rs` — `spawn_git_info_query` becomes a per-panel single-slot runner (a running flag plus one pending request) owned by `Runtime`; `race_with_timeout` tells a timeout apart from a panicked inner thread, and its timeout path reports the discovered root; `app.rs` `run_effects` routes `Effect::QueryGitInfo` through the panel's slot. +- **No persisted-format, config, or dependency changes.** The remembered set lives only for the session. + +**Audit findings:** P-07 + +**Coordination:** `listing-request-guard` introduces a generic `Effect::CancelRequest` + `CancelFlag` registry; it is deliberately not used here because a libgit2 status walk cannot be cancelled mid-walk — the single-slot runner and the remembered set bound the waste instead. `ci-and-build-tooling` (B-04) switches `git2` to `default-features = false`; nothing here depends on the default features. `idle-aware-event-loop` counts live worker threads to decide how often the loop wakes; the git slot runner's thread must hold a worker token while it can still send (the orphaned inner walk sends nothing to the UI channel and needs none). `process-lifecycle-hardening` wraps every `spawn_*` body in `catch_unwind` with `GitInfoResolved { info: GitInfo::none() }` as the git panic reply; whichever lands second keeps that reply inside the slot runner, with `timed_out_root: None`, and keeps the slot's drop guard. `reducer-overlay-structure` moves `git_info_query_effect` into `update/listing.rs`; whichever lands second rebases onto the other. No landing order is required. diff --git a/openspec/changes/archive/2026-10-14-git-info-timeout-memory/specs/git-info/spec.md b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/specs/git-info/spec.md new file mode 100644 index 0000000..a36b1d4 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/specs/git-info/spec.md @@ -0,0 +1,103 @@ +## MODIFIED Requirements + +### Requirement: Background repository detection + +The system SHALL detect the git repository enclosing a panel's current directory on a dedicated worker thread using `git2` (libgit2), and the single-threaded UI event loop SHALL NOT block on the repository detection or status query. Detection and status results SHALL be delivered to the reducer as job events, and a fresh query SHALL be issued when the panel navigates to a new directory or is re-read (Ctrl+R), since v1 performs no filesystem watching — except for a directory inside a repository that is marked "no info" for the session, for which no query is issued (see "Silent absence on timeout and stale-result discarding"). + +#### Scenario: Directory inside a repository + +- **WHEN** a panel's current directory lies within a git working tree +- **THEN** the worker thread resolves the enclosing repository and reports its branch name and per-file status back to the reducer without the UI thread waiting on the libgit2 call + +#### Scenario: Directory outside any repository + +- **WHEN** a panel's current directory is not inside any git working tree +- **THEN** the query resolves to "no git info" and the panel renders no branch suffix and no status-marker column + +#### Scenario: Query re-issued on navigation + +- **WHEN** the panel changes directory or the user triggers a re-read (Ctrl+R), and the panel's new current directory is not inside a repository marked "no info" for the session +- **THEN** a new git query is issued for the panel's new current directory rather than reusing a prior result + +### Requirement: Pathspec-scoped status queries + +The system SHALL scope git status queries to the panel's current directory via pathspec rather than querying the repository as a whole, and SHALL NOT enumerate the contents of untracked directories. The panel directory's path SHALL be matched literally: characters that pathspec patterns treat specially — `[`, `]`, `*`, `?`, `#`, spaces, and a leading `!` — in any directory name along that path SHALL NOT be interpreted as pattern syntax, so status is reported for a directory with such a name exactly as for any other directory, and the query stays scoped to that directory. + +#### Scenario: Status limited to panel directory + +- **WHEN** git status is computed for a panel deep inside a large repository +- **THEN** the query is pathspec-scoped to the panel directory and does not report status for files outside that directory + +#### Scenario: Untracked directory not expanded + +- **WHEN** the panel directory contains an untracked subdirectory +- **THEN** the subdirectory is marked `?` but the files inside it are not individually enumerated for status + +#### Scenario: Directory names are matched literally + +- **WHEN** the panel is inside a repository directory named `foo[1]` that contains a modified tracked file `a.txt` and an untracked subdirectory `new` +- **THEN** `a.txt` shows `M` and `new` shows `?`, exactly as they would in a directory named `foo1` + +#### Scenario: Pattern characters in a nested directory name + +- **WHEN** the panel is inside the repository directory `src/foo[1]`, and a sibling directory `src/foo1` also contains modified files +- **THEN** the files of `src/foo[1]` show their markers and nothing from `src/foo1` is reported for the panel + +#### Scenario: A leading exclamation mark is not a negation + +- **WHEN** the panel is inside a repository directory named `!old` that contains a modified tracked file +- **THEN** that file shows `M` + +#### Scenario: A leading hash does not widen the query + +- **WHEN** the panel is inside a top-level repository directory named `#notes` +- **THEN** the status query is scoped to `#notes` rather than walking the whole repository, and its entries show their markers + +### Requirement: Silent absence on timeout and stale-result discarding + +The system SHALL degrade to "no git info" silently on timeout, discarding the stale result and marking the repository as "no info" for the remainder of the session while leaving the uncancellable worker call to finish. While a repository is marked "no info", navigating to, re-reading, or swapping a panel into any directory inside that repository's working tree SHALL NOT start another git query, and the panel SHALL show no branch suffix, no marker column, and no error for it; when the repository could not be located before the timeout, the queried directory and everything beneath it SHALL be marked instead. A timeout SHALL mark the repository even when the panel has already moved elsewhere. Each panel SHALL have at most one git query running at a time: a navigation while a query is running SHALL wait for that query to report and then query only the panel's most recent directory, a waiting request superseded by a newer navigation SHALL never be started, and a waiting request for a directory inside the repository that the running query has just timed out on SHALL never be started either. Results keyed to a directory or generation the panel has since moved past SHALL be dropped and SHALL NOT alter panel rendering. + +#### Scenario: Timeout degrades silently + +- **WHEN** a git status query exceeds its timeout +- **THEN** the result is discarded, the repository is treated as "no info" for the session, and the panel shows no error and no indicator + +#### Scenario: Late result after navigation is dropped + +- **WHEN** a git result arrives for a directory the panel has already navigated away from +- **THEN** the result is discarded via its directory/generation key and the current panel rendering is unchanged + +#### Scenario: Timed-out repository is not queried again + +- **WHEN** a git query for `D:\mono\src` in the repository rooted at `D:\mono` has timed out, and the user then navigates the panel to `D:\mono\docs` or presses Ctrl+R there +- **THEN** no git query is started, and the panel shows no branch suffix and no marker column + +#### Scenario: Swapping a panel into a timed-out repository + +- **WHEN** the repository at `D:\mono` is marked "no info" and the user presses Ctrl+U while the other panel shows `D:\mono\lib` with branch info it had resolved before the timeout +- **THEN** no git query is started for that panel after the swap, and it shows no branch suffix and no marker column + +#### Scenario: Other repositories are still queried + +- **WHEN** the repository at `D:\mono` is marked "no info" and the user navigates to `C:\work\app`, inside a different repository +- **THEN** a git query is started for `C:\work\app` and its branch suffix and markers appear when it resolves + +#### Scenario: Timeout recorded after the panel moved on + +- **WHEN** the user leaves `D:\mono\src` before its git query times out, and the timeout is reported afterwards +- **THEN** the repository at `D:\mono` is still marked "no info", and a later navigation into it starts no git query + +#### Scenario: Repository not located before the timeout + +- **WHEN** a git query for `\\server\share\proj` times out before the enclosing repository was even located +- **THEN** `\\server\share\proj` and every directory beneath it are treated as "no info" for the session + +#### Scenario: Rapid navigation does not pile up queries + +- **WHEN** a panel's git query is still running and the user navigates through three more directories in quick succession, and the running query then completes within its timeout +- **THEN** no second query runs for that panel while the first is running, and once the first reports, only the last of the three directories is queried + +#### Scenario: Waiting query inside the timed-out repository is not started + +- **WHEN** a panel's git query for `D:\mono\src` is still running, the user navigates to `D:\mono\docs` while it runs, and the running query then times out +- **THEN** no git query is started for `D:\mono\docs`, and the panel shows no branch suffix and no marker column there diff --git a/openspec/changes/archive/2026-10-14-git-info-timeout-memory/tasks.md b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/tasks.md new file mode 100644 index 0000000..a3d6358 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-git-info-timeout-memory/tasks.md @@ -0,0 +1,37 @@ +# Tasks: git-info-timeout-memory + +## 1. Literal pathspec (`crates/filecommand-core/src/git_info.rs`) + +- [x] 1.1 Add `literal_pathspec(rel: &Path) -> String`: join components with `/`; if no component contains `[`, `*`, `?` (or, on non-Windows, `\`), return the joined path unchanged; otherwise quote `[`→`[[]`, `*`→`[*]`, `?`→`[?]`, non-Windows `\`→`?`, a leading `!` or `#`→`?`, and append `/*`. Unit tests for each rule, including that `!old`, `#notes`, `a b`, and `a]b` are returned unchanged and that no output contains a `\` (design D4) +- [x] 1.2 `pathspec_status`: when `rel_dir` is non-empty, set the single pathspec `literal_pathspec(rel_dir)`; the repository-root case (no pathspec) and the `strip_prefix(&canon_dir)` fold are unchanged (git-info "Pathspec-scoped status queries"; design D4) +- [x] 1.3 Real-repository tests, each directory holding a modified tracked `a.txt` and an untracked subdirectory `new` containing a file: `foo[1]` → `a.txt` `M`, `new` `?` (git-info "Directory names are matched literally"); `src/foo[1]` beside a modified `src/foo1` → only `src/foo[1]`'s entries ("Pattern characters in a nested directory name"); `[x]`, `!b[2]` beside a modified `Xb[2]`, and `#c[3]` → their own markers only; `!old` → `M` ("A leading exclamation mark is not a negation"); top-level `#notes` beside a modified `outside.txt` → markers present and `outside.txt` not reported ("A leading hash does not widen the query"); a name with a space; `a*b`, `what?`, and `back\slash` on non-Windows only +- [x] 1.4 Confirm `status_is_scoped_to_the_panel_directory_via_pathspec` and `untracked_directory_is_marked_but_its_contents_are_not_enumerated` pass unchanged (git-info "Status limited to panel directory", "Untracked directory not expanded") + +## 2. Root discovery (`git_info.rs`) + +- [x] 2.1 Split `query` into `discover(dir)` (repository plus the number of components between the workdir and `dir`) and the branch/status step, keeping `query(dir)` as their composition for existing callers (design D2) +- [x] 2.2 Add `root_in_dir_spelling(dir, depth)` = `dir.ancestors().nth(depth)` with tests for a subdirectory, the root itself, and an 8.3/long-name pair on Windows (design D2) +- [x] 2.3 Add `is_under_root(path, root)` — component-wise `starts_with`, ASCII-case-insensitive on Windows — with tests for equal paths, a descendant, a sibling sharing a string prefix (`D:\mono2` is not under `D:\mono`), and a Windows-only case-difference test (design D2) + +## 3. Reducer memory (`crates/filecommand-core/src/update.rs`) + +- [x] 3.1 Add `timed_out_root: Option` to `Command::GitInfoResolved` and `git_no_info_roots: Vec` to `State` (session-only) (design D1) +- [x] 3.2 `GitInfoResolved` handler: record `timed_out_root` (deduplicated) before the stale-reply check, then apply `info` only when `cwd` and `git_request` match, as today (git-info "Timeout recorded after the panel moved on") +- [x] 3.3 `git_info_query_effect` returns `Option`; under a remembered root (`git_info::is_under_root`) it issues nothing and sets the panel's `git_info` to `GitInfo::none()` and `git_request` to `None`; update `State::initial`, `begin_listing_inner`, `handle_swap_panels` (git-info "Background repository detection"; design D1) +- [x] 3.4 `update/tests.rs`: timed-out reply for `D:\mono\src` with root `D:\mono` → navigation to `D:\mono\docs` and `RereadPanel` there emit no `QueryGitInfo` and leave no git info (git-info "Timed-out repository is not queried again", "Query re-issued on navigation"); navigation to `C:\work\app` emits one ("Other repositories are still queried"); a stale timed-out reply still records the root and leaves the current panel unchanged ("Timeout recorded after the panel moved on", "Late result after navigation is dropped"); a root equal to the queried directory covers its descendants only ("Repository not located before the timeout"); Ctrl+U with the other panel showing resolved info inside a remembered root issues no query for it and clears its git info ("Swapping a panel into a timed-out repository") +- [x] 3.5 Confirm the existing stale-reply tests pass unchanged (git-info "Late result after navigation is dropped", "Timeout degrades silently") + +## 4. One query per panel (`crates/filecommand-tui/src/worker.rs`, `app.rs`) + +- [x] 4.1 Add `GitQuerySlot` (`Mutex<{ running, pending }>`) per panel side on `Runtime`; `Effect::QueryGitInfo` starts the slot's runner when idle or replaces `pending` when running; the runner clears `running` through a drop guard (design D3) +- [x] 4.2 `race_with_timeout` reports timeout and disconnection separately; only a timeout yields `timed_out_root` (the published root, or the queried directory when none was published); a disconnected (panicked) query reports `GitInfo::none()` with `timed_out_root: None` (design D2) +- [x] 4.3 Runner loop: run `discover` + status under `race_with_timeout` with the discovered root published to a shared cell; send `GitInfoResolved`; after a timeout with root `R`, discard a pending request under `R`; then take `pending` or mark the slot idle (design D3) +- [x] 4.4 Make the runner generic over the query function so tests can inject a blocking fake; tests: a second and third request while the first runs and completes in time → only the third runs after the first reports, the second never starts (git-info "Rapid navigation does not pile up queries"); a pending request under the root of a query that then times out → never started ("Waiting query inside the timed-out repository is not started"); a fake that blocks past the timeout after publishing a root → `timed_out_root` is that root; one that blocks before publishing → the queried directory; one that panics → `timed_out_root: None` and the slot is idle afterwards +- [x] 4.5 Keep `race_with_timeout`'s existing tests passing + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green +- [x] 5.2 `cargo clippy --workspace --all-targets` with no new warnings +- [x] 5.3 `openspec validate git-info-timeout-memory --strict` clean +- [ ] 5.4 Manual (Windows): in a large repository on a network share, navigate repeatedly after the first timeout → no further status walks (Task Manager thread count and disk activity stay flat); a directory named `foo[1]` inside a repository shows its `M` and `?` markers diff --git a/openspec/changes/archive/2026-10-14-history-persistence-hardening/.openspec.yaml b/openspec/changes/archive/2026-10-14-history-persistence-hardening/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-history-persistence-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-history-persistence-hardening/design.md b/openspec/changes/archive/2026-10-14-history-persistence-hardening/design.md new file mode 100644 index 0000000..271f2c1 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-history-persistence-hardening/design.md @@ -0,0 +1,158 @@ +# Design: history-persistence-hardening + +## Context + +This was verified against `build/open-proposals-2026-10` at `1bada6f`. + +- **One file, two histories.** `config::HistoryFile { commands: Vec, directories: Vec }` is the whole of `history.json`. + - `render_history_file` writes `{"commands": [...], "directories": [{"path", "visit_count", "last_visited_ms"}, ...]}`. + - `parse_history_file` extracts the two arrays with `extract_value_span`. + - `load_history_file` is called once in `app::run`, which copies the result into `state.history` and `state.dir_history`. + - Command history is ordered oldest to newest and capped at `MAX_HISTORY = 200` by `push_history`. Today it only grows from a successful `cd` in `update::dispatch_cd`. +- **Recording.** `update::begin_listing` is the navigation choke point. It calls: + - `quicksearch::record_visit(&mut state.dir_history, &path, state.clock_ms)`, which does a linear `find` and then either increments the count and refreshes the time, or appends a new entry with no cap; + - `begin_listing_inner`; + - then it pushes `Effect::PersistHistory(HistoryFile { commands: state.history.clone(), directories: state.dir_history.clone() })`. + + `dispatch_cd` pushes the command, emits its own `Effect::PersistHistory`, and then calls `begin_listing`, which emits a second one. The stale-tab refresh and the Tree preview bypass this through `begin_listing_inner`. Ctrl+R and job-completion re-reads do go through `begin_listing`, so they also count as visits and persists. That is existing behavior, unchanged here. +- **Writing.** `run_effects` handles `Effect::PersistHistory(file)` on the event-loop thread with `config::save_history_file_atomic(&rt.history_path, &file)`, skipped when `rt.persist_enabled` is false: + - it calls `std::fs::write` on the fixed sibling `history.json.tmp`, then `std::fs::rename`; + - there is no `sync_all`; + - there is no read of the current file, so with two instances the last writer wins. +- **Clock.** `filecommand-tui/src/clock.rs`: + - `RealClock::now_ms` returns `Instant::elapsed()` since `RealClock::new()` at process start. + - The main loop sends `Command::Tick(clock.now_ms())` whenever `event::poll(POLL_INTERVAL)` times out, and `update` stores it in `state.clock_ms` (`update.rs`, top of `update_impl`). + - `state.clock_ms` drives animations (`dialog_opened_at_ms`, the splash hold), double-click detection, and clipboard-feedback expiry, and also frecency: `record_visit`, and `rank_directories(…, state.clock_ms)` both in the Ctrl+J reducer handlers and in the TUI's `render_fuzzy_jump` (called from `views/mod.rs`). + - `frecency_score` is `visit_count * (RECENCY_WINDOW - min(age, RECENCY_WINDOW) + 1)`, with a one-week linear window (the constant is named `RECENCY_HALF_LIFE_MS`, but the decay is linear). Across sessions, `now.saturating_sub(last)` compares two unrelated time bases. + - `WallClock` exists in core for the on-screen `h:mm` clock and returns only `(hour, minute)`. + +## Goals / Non-Goals + +**Goals:** + +- Ctrl+J recency means the same thing across restarts, and existing `history.json` files migrate without losing visit counts. +- The directory index stays bounded, and the size of the per-navigation work does not depend on how long the user has used FileCommand. +- No `history.json` write happens on the UI thread, and bursts of navigation coalesce into one write. +- Two concurrent instances do not erase each other's commands or directories. +- A power loss during a write leaves either the old or the new complete file. + +**Non-Goals:** + +- BOM tolerance and JSON escape decoding (surrogate pairs, and no truncation on a lone `\uD83D`) in the history parser. These belong to `config-parsing-robustness` (B-05, R-15 config part), which owns the shared tokenizer and the decoders. +- `config.toml` durability (`config-parsing-robustness`) and moving the theme and split writes off the UI thread (`offload-blocking-io`, because the audit lists those persist writes under P-09). +- Changing which navigations count as visits, the frecency formula, or the one-week window. +- Live sharing between running instances. Each instance sees the other's entries after its own next restart. +- A cross-process lock that would close the millisecond-scale race described in Risks. + +## Decisions + +### D1: Epoch milliseconds arrive with the existing tick; the monotonic clock stays for animations + +- **The tick.** `Command::Tick(u64)` becomes `Command::Tick { now_ms: u64, epoch_ms: u64 }`. `update` stores `now_ms` in `state.clock_ms` exactly as today, and stores `epoch_ms` in a new field, `state.epoch_ms`. +- **Who reads which clock.** `record_visit` and every `rank_directories`/`frecency_score` call read `state.epoch_ms`. That includes the TUI renderer: `views::mod` passes `state.clock_ms` to `views::fuzzy_jump::render_fuzzy_jump` today, which calls `rank_directories` to draw the list, so it must pass `state.epoch_ms` too, or the drawn order would differ from the order the reducer's cursor and Enter act on. Nothing else reads the epoch: animations, the splash hold, and clipboard-feedback expiry keep using `state.clock_ms`, so a wall-clock correction during a session cannot stall or skip an animation. +- **Where epoch time comes from.** `WallClock` gains `fn epoch_ms(&self) -> u64`. `RealWallClock` implements it with `SystemTime::now().duration_since(UNIX_EPOCH)`, and `TestWallClock` gets a pinnable epoch. +- **Startup.** `State::initial` takes the initial epoch, so visits recorded before the first tick are stamped correctly. +- **The reducer still reads no clock.** Both values come in through the command, which keeps "deterministic under a pinned clock". + +Alternatives considered: + +- **A one-time epoch offset (`epoch = clock_ms + offset_at_start`).** This avoids changing `Tick`, but `Instant` does not advance during system sleep on every platform, so the derived epoch drifts behind by the total suspended time of a long-running session. +- **A separate `Command::WallTime` message.** This doubles the commands per idle poll for no benefit. +- **Letting the TUI write `state.epoch_ms` directly before each `apply`.** This breaks the rule that `update`'s inputs arrive only as commands. + +### D2: Format marker plus load-time migration + +- `render_history_file` writes a top-level `"version": 2` key. +- `load_history_file` reports whether the key was absent, which marks a legacy file whose timestamps are session-relative. The key is looked up the same way as `"commands"` and `"directories"`; if `config-parsing-robustness` has landed, that is its top-level-only key lookup, so a `"version"` inside a command string is never mistaken for the marker. +- A new pure function, `config::migrate_loaded_history(file, legacy, now_epoch)`, runs at startup: + - for a legacy file, every directory's `last_visited_ms` is set to `now_epoch`; + - for any file, a `last_visited_ms` greater than `now_epoch` is clamped to `now_epoch`; + - the directory list is pruned to the cap (D3). + +Setting legacy entries to "now" follows the audit's recommendation. Right after an upgrade the user's established directories rank by visit count, ahead of anything new, and then decay normally over the one-week window as fresh visits take over. + +Alternatives considered: + +- **Setting legacy timestamps to 0 ("ancient").** Every directory visited after the upgrade would immediately outrank a directory with hundreds of past visits, which is a worse first week. +- **Guessing legacy files by value magnitude** (timestamps below roughly 1e12 are monotonic). This works in practice, but it is heuristic, and the marker is explicit and also future-proofs the format. + +The clamp protects against a clock that was ahead and then corrected: without it, an entry stamped in the future stays at maximum recency until real time catches up. + +### D3: Cap the directory index at 1,000, pruning the lowest frecency + +`quicksearch::MAX_DIR_HISTORY = 1_000`. `record_visit(history, path, now)` behaves as today, then calls `prune_to_cap(history, MAX_DIR_HISTORY, now)` when the list exceeds the cap: + +- it removes the entry with the lowest `frecency_score` at `now`; +- among equal scores it removes the one that appears last in list order, so the result is deterministic; +- it never removes the entry just visited. That entry is excluded explicitly rather than relying on its score: a one-visit entry stamped now can tie with another one-visit entry stamped in the same millisecond, and the tie rule above would then pick the newest entry, which is the visited one. + +The same prune runs after migration at load and after every merge (D5). Each visit is O(n) for n ≤ 1,000. This keeps the per-navigation `find` and the snapshot clone bounded. + +Alternatives considered: + +- **Time-based expiry (drop entries older than N days).** This can drop a heavily used directory after a vacation. +- **Least recently visited.** This ignores frequency, which frecency exists to capture. + +### D4: A background history writer with a latest-snapshot mailbox, debounce, and a final flush + +The reducer keeps emitting `Effect::PersistHistory(HistoryFile)` from `begin_listing`. `dispatch_cd`'s duplicate emission is deleted: `begin_listing` already runs after `push_history` and snapshots both lists. The snapshot clone is bounded by D3 (≤1,000 directories, ≤200 commands). + +In the TUI, `run_effects` no longer writes. It calls `rt.history_writer.submit(file)`, which posts the snapshot to a new `history_writer` thread spawned in `app::run` only when `persist_enabled` is true; otherwise `submit` is a no-op. The thread: + +- loops on `recv_timeout`, keeping only the newest snapshot it has received (a single-slot mailbox: older snapshots are dropped unread); +- writes when 2 s have passed with no new snapshot, or when 10 s have passed since the oldest unwritten snapshot arrived, whichever comes first; +- on a `Flush` message, writes any pending snapshot and exits. + +`HistoryWriter` implements `Drop`: it sends `Flush` and joins the thread with a bounded wait (2 s), so that a hung network profile directory cannot hold the process open. `Runtime` owns the writer, so every exit from `run` flushes: the `return Ok(())` quit sites, early `?` error returns, and panic unwinding. + +Alternatives considered: + +- **Debouncing in the reducer**, with a dirty flag flushed on a later `Tick`. This depends on ticks continuing to arrive, and `idle-aware-event-loop` intends to stop idle ticks. It also still writes on the UI thread. +- **A spawned thread per write.** Concurrent writers would race each other for the same file. +- **Writing only at quit.** A crash would lose the whole session's history. + +### D5: Merge with the on-disk file before every write + +Before each write the writer calls `config::load_history_file(path)`, migrates the disk copy's timestamps if it is legacy, and then calls the pure `config::merge_history_files(disk, mine, now_epoch) -> HistoryFile`: + +- **Directories.** The result is the union by path, using the same `PathBuf` equality `record_visit` uses. + - For a path present in both, `visit_count = max` and `last_visited_ms = max`. + - Order: this instance's entries first, in their order, then disk-only entries in disk order. This preserves `rank_directories`' tie order for the instance's own entries. + - The union is then pruned to `MAX_DIR_HISTORY` (D3). +- **Commands.** Disk-only commands (those not present in `mine.commands`) come first, in disk order, followed by `mine.commands`. The oldest entries are dropped to fit `MAX_HISTORY`. Commands that only the other instance ran therefore rank as older than this instance's own, and nothing is duplicated. + +`max` is idempotent: merging the same pair twice gives the same file, so repeated flushes from either instance never inflate counts. The cost is that concurrent visits to the same directory from two instances count as the larger of the two increments, not their sum. + +The merged result is written to disk only. The writer does not send it back to the reducer, so this instance's Up-arrow recall and Ctrl+J list keep showing what it recorded itself. That leaves command-line "Command history navigation" (an in-flight requirement) untouched: its "Executed command persisted to history" scenario still holds, because the command is appended to the in-memory history at once and `history.json` is still replaced atomically, now within seconds instead of inline. The Alt+F8 command-history dialog (pulldown-menu-completion, in flight) also lists the in-memory history, so it is unaffected too. Because every flush re-merges the instance's whole in-memory state, the other instance's entries survive every later write. + +Alternatives considered: + +- **Replaying only this instance's new commands and visits on top of the disk file** (a journal of deltas). This gives exact counts, but it needs per-flush bookkeeping and is not idempotent if a write fails halfway. +- **Feeding the merged result back into `State`.** This changes in-session recall under the user and needs a new command, for little benefit. + +### D6: Durable write through the shared helper + +`save_history_file_atomic` becomes `durable::replace_file(path, render_history_file(&merged).as_bytes())`: + +- the temp file has a unique name, `....fc-tmp`, in the same directory, so two instances never share a temp file; +- the temp file is flushed with `sync_all` before the replace; +- on failure the temp file is removed and `history.json` is left as it was. + +The helper comes from `editor-encoding-safety` (D4 there). Failures stay silent, as today: a history write must never take the session down. Reporting them is `error-diagnostics`' concern. + +## Risks / Trade-offs + +- **[Risk] Lost update between two instances that flush within the same few milliseconds.** Both read the disk copy, then both write, and the second write lacks the first one's newest entries. → Each instance re-merges its whole in-memory state on every later flush, so only an instance's final flush at exit can be overwritten for good. A lock file would close this gap, at the cost of stale-lock handling. It is not done here. +- **[Risk] Up to 10 s of history lost on a hard crash or a power loss.** → This is accepted in exchange for taking writes off the input path. Graceful exits flush. `process-lifecycle-hardening` should route Ctrl+C through the same exit path. +- **[Risk] Running an old and a new FileCommand at the same time.** The old one writes legacy, session-relative timestamps. Merged by the new writer, those values lose every `max` comparison, and disk-only legacy entries look decades old (they rank by visit count only). → This is a rare, transitional mix. Nothing is lost, and only the recency of those entries is understated. +- **[Trade-off] Max-merge undercounts concurrent visits to the same directory.** → This is acceptable for a ranking heuristic, and it is the idempotent choice. +- **[Risk] A hung profile directory (an offline network path) stalls the writer thread.** → Only the writer stalls. The UI never waits, and the final-flush join is bounded at 2 s. + +## Migration Plan + +- **First launch after upgrade:** the file has no `"version"` key. Every directory's last-visit time is set to the load time and the index is pruned to 1,000. The first write adds `"version": 2`. Visit counts and command history carry over unchanged. +- **Downgrade:** older builds ignore the `"version"` key. They see the epoch timestamps as "just visited" (saturating age 0) and write the file back without the key. The next upgraded launch migrates it again. Nothing is lost. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-history-persistence-hardening/proposal.md b/openspec/changes/archive/2026-10-14-history-persistence-hardening/proposal.md new file mode 100644 index 0000000..744cdec --- /dev/null +++ b/openspec/changes/archive/2026-10-14-history-persistence-hardening/proposal.md @@ -0,0 +1,96 @@ +# Change: history-persistence-hardening + +## Why + +`history.json` holds the command history and the Ctrl+J directory-frecency index. It has three problems from the 2026-10-12 code audit: + +- **Unbounded growth, written on the UI thread on every navigation (P-08, Medium).** + - `quicksearch::record_visit` appends every distinct directory forever. + - `update::begin_listing` clones both histories into `Effect::PersistHistory` on every navigation. + - `run_effects` writes the whole file synchronously on the event-loop thread. + - A `cd` emits the effect twice, once in `dispatch_cd` and once in `begin_listing`. + + After months of use, every directory change serializes and rewrites thousands of entries on the input thread, with antivirus scanning each temp file on Windows, and the latency grows without anyone noticing. +- **Frecency recency is meaningless across restarts (R-13, Medium).** `last_visited_ms` is stamped from `state.clock_ms`, which is `RealClock::now_ms()`: milliseconds since *this process* started. The next session's clock restarts near zero, so an entry written late in a long previous session looks younger than one visited a minute ago. Ctrl+J ranking across restarts is effectively random with respect to recency. That breaks fuzzy-jump "Recency raises rank" and "History survives restart … using their persisted frecency ranking". +- **Not crash-durable and not multi-instance safe (R-15, history part, Medium).** `save_history_file_atomic` writes a temp file and renames it, but never flushes the temp file. A power loss on NTFS can leave a zero-length `history.json`. Each instance also rewrites the file from its own in-memory copy, so with two FileCommand windows open the last writer silently discards the other window's history. Both instances write the same `history.json.tmp` name too, so concurrent writes can interleave. + +## What Changes + +- **Wall-clock recency.** + - Frecency timestamps are Unix-epoch milliseconds, supplied by the TUI alongside the existing monotonic tick. Animations and every other timing keep using the monotonic clock. + - `history.json` gains a format marker. + - A file without the marker (written by earlier versions) is migrated at load: every directory's last-visit time becomes the load time, so legacy entries rank by visit count and then age normally. + - A last-visit time in the future, for example after a system clock correction, is clamped to the load time. +- **Bounded history.** The directory index is capped at 1,000 entries. When a new directory would exceed the cap, the lowest-frecency entry is dropped. An oversized file is pruned at load. +- **Writes leave the UI thread and are coalesced.** + - Navigation hands the latest history snapshot to a background history writer. + - The writer saves once a burst of changes has settled, and no later than 10 seconds after the first unwritten change. + - Pending changes are flushed when FileCommand quits. + - The duplicate persist in `dispatch_cd` is removed. +- **Multi-instance merge.** + - Before each write the writer re-reads `history.json` and merges it with its snapshot. Directories are the union by path, keeping the larger visit count and the later visit time. + - Commands that exist only on disk are kept as older than this instance's commands, and the 200-command cap still applies. + - Two instances no longer erase each other's history. + - The in-memory session state does not change, so Up-arrow recall and the Ctrl+J list keep showing what this instance recorded. +- **Durable writes.** The temp file has a unique name and is flushed to stable storage (`sync_all`) before it replaces `history.json`. This uses the shared durable-replace helper. +- The scope covers the whole of `history.json`, because command history shares the file and the write path. Command-line recall behavior itself does not change. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `fuzzy-jump`: + - "Frecency ranking": recency is measured from persisted wall-clock time, with legacy-file migration and future-timestamp clamping. + - "Directory history persistence": 1,000-entry cap pruned by lowest frecency; coalesced background writes with a final flush at quit; merge with the on-disk file before each write, covering command history too; flushed temp file before the replace. + +## Impact + +- **Specs:** `fuzzy-jump` (two MODIFIED requirements). +- **Code (core):** + - `crates/filecommand-core/src/quicksearch.rs`: + - `record_visit` enforces the cap through a new `prune_to_cap`; + - `frecency_score`/`rank_directories` receive epoch time; + - a new `MAX_DIR_HISTORY` constant. + - `crates/filecommand-core/src/config.rs`: + - `render_history_file` writes the format marker; + - `parse_history_file`/`load_history_file` report whether the file was legacy; + - new pure `merge_history_files` and `migrate_loaded_history`; + - `save_history_file_atomic` uses the durable-replace helper. + - `crates/filecommand-core/src/clock.rs`: `WallClock::epoch_ms`; `TestWallClock` gets a pinnable epoch. + - `crates/filecommand-core/src/update.rs`: + - `Command::Tick` also carries `epoch_ms`; + - new `State::epoch_ms` field; + - `begin_listing` stamps visits with `epoch_ms`; + - fuzzy-jump ranking calls use `epoch_ms`; + - `dispatch_cd` drops its duplicate `Effect::PersistHistory`. +- **Code (TUI):** + - `crates/filecommand-tui/src/clock.rs`: `RealWallClock::epoch_ms` from `SystemTime`. + - `crates/filecommand-tui/src/views/mod.rs`: the fuzzy-jump overlay passes `state.epoch_ms` instead of `state.clock_ms` to `views::fuzzy_jump::render_fuzzy_jump`, which ranks with `rank_directories` itself. Otherwise the drawn order and the reducer's Enter/cursor order would disagree. + - `crates/filecommand-tui/src/app.rs`: + - the main loop's `Command::Tick` carries `epoch_ms`; + - startup history load runs the migration; + - `Effect::PersistHistory` hands the snapshot to a new history writer instead of writing inline; + - the writer is flushed and joined on every exit path from `run`. + - New `crates/filecommand-tui/src/history_writer.rs`: a background thread with a single-slot latest-snapshot mailbox, debounce, and merge-then-write. +- **Tests:** + - core unit tests for cap, prune, merge, migration, clamping, and epoch-based ranking; + - the `update/tests.rs` `Tick` call sites move to the new shape; + - writer tests in a temp dir for coalescing, final flush, and two-writer merge. +- **Compatibility:** + - Old `history.json` files load and are migrated. + - An older FileCommand reading a new file ignores the marker. It sees epoch timestamps as "very recent", which is harmless, and writes the file back without the marker, which the new version migrates again. + +**Audit findings:** P-08, R-13, R-15 (history.json part) + +**Coordination:** +- `config-parsing-robustness` (R-15 config part, R-16, B-05) owns `history.json` **BOM stripping** and the **JSON string-escape fix** (surrogate pairs, and no truncation on a lone surrogate) in `parse_history`/`extract_string_field`, plus the shared line tokenizer. This change does not touch those decoders. Both changes edit `config.rs`'s history section, so whichever lands second rebases onto the other's helpers. +- `editor-encoding-safety` introduces the shared durable-replace helper (temp file, `sync_all`, replace). This change routes `save_history_file_atomic` through it. If this change lands first, it introduces the helper. +- `offload-blocking-io` (R-09/P-09) moves the remaining synchronous effects off the UI thread. Only `Effect::PersistHistory` is owned here (history writer thread). `PersistTheme`/`PersistPanelSplit` are not: the audit lists those writes under P-09, so they belong to `offload-blocking-io`, which may reuse this writer pattern. (The two changes already agree: `offload-blocking-io`'s non-goals and tasks assign `PersistHistory` to this change and keep `PersistTheme`/`PersistPanelSplit` for its `ConfigWriter`, so no further coordination is needed.) +- `error-diagnostics` (B-06) logs swallowed persistence failures. Once it lands, the history writer thread routes its write failures through the same log call. +- `line-editing-in-text-fields` and `pulldown-menu-completion` (in flight) touch the fuzzy-jump dialog and the Alt+F8 command-history dialog. Neither changes how history is recorded or stored; the Alt+F8 dialog reads the in-memory command list, which this change leaves as it is. +- `idle-aware-event-loop` (P-05) changes when `Command::Tick` is delivered. Visit timestamps come from the latest tick, the same as animation start times, so that change must deliver a fresh tick (both clocks) before it applies input that follows an idle period. +- `process-lifecycle-hardening` (R-10/R-11) adds a console Ctrl+C handler and changes the quit path. Its graceful-exit paths should run the history writer's final flush. diff --git a/openspec/changes/archive/2026-10-14-history-persistence-hardening/specs/fuzzy-jump/spec.md b/openspec/changes/archive/2026-10-14-history-persistence-hardening/specs/fuzzy-jump/spec.md new file mode 100644 index 0000000..e1fa962 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-history-persistence-hardening/specs/fuzzy-jump/spec.md @@ -0,0 +1,100 @@ +## MODIFIED Requirements + +### Requirement: Frecency ranking + +The system SHALL rank the visited-directory list by a frecency score combining recency and visit frequency, with more recently and more frequently visited directories ranked higher, so the most likely target appears near the top. The ranking computation SHALL live in `filecommand-core` and be deterministic under a pinned clock for testability. Recency SHALL be measured from wall-clock time. Each directory's last-visit time SHALL be recorded and persisted as Unix-epoch milliseconds, so that recency comparisons stay meaningful across application restarts. Animation and other in-session timing SHALL NOT depend on the wall clock. History entries loaded from a `history.json` written in the earlier session-relative timestamp format SHALL be treated as last visited at the time the history was loaded. A persisted last-visit time later than the current wall-clock time SHALL be treated as the current time. + +#### Scenario: Frequency raises rank + +- **WHEN** two directories match the pattern equally and one has been visited more times +- **THEN** the more frequently visited directory is ranked above the other + +#### Scenario: Recency raises rank + +- **WHEN** two directories have equal visit frequency and one was visited more recently +- **THEN** the more recently visited directory is ranked above the other + +#### Scenario: Recency survives a restart + +- **WHEN** in an earlier session that ran for several days the user visited `C:\old` on the first day and `C:\new` one minute before exiting, both directories have equal visit counts, and FileCommand is relaunched a few minutes later +- **THEN** the fuzzy jump dialog ranks `C:\new` above `C:\old` + +#### Scenario: Legacy history file is migrated at load + +- **WHEN** FileCommand starts with a `history.json` written in the earlier session-relative timestamp format +- **THEN** every loaded directory is treated as last visited at load time, so those directories rank among themselves by visit count, and visits recorded afterwards are stamped with wall-clock time + +#### Scenario: Future last-visit time is clamped + +- **WHEN** `history.json` holds a directory whose last-visit time is later than the current wall-clock time, for example after the system clock was set back +- **THEN** that directory is treated as visited at the current time and does not keep an inflated recency as time passes + +### Requirement: Directory history persistence + +The system SHALL record each directory the user navigates into in the frecency history and persist that history in `history.json` in the platform config directory, written atomically and shared with command history. The system SHALL load this history on startup so the fuzzy jump dialog reflects directories visited in prior sessions, and SHALL fall back to an empty history when the file is absent or malformed rather than failing to launch. + +The directory history SHALL hold at most 1,000 entries. When recording a visit to a directory not yet in a full history, and when loading or merging a history larger than the cap, the system SHALL remove the entries with the lowest frecency score until the cap is met. It SHALL never remove the directory being visited. + +Writes of `history.json` SHALL NOT be performed on the UI thread. Navigation SHALL hand the latest history to a background writer and return without waiting. The writer SHALL coalesce a burst of changes into one write, and SHALL write any unwritten change within 10 seconds of it being recorded. When the application quits, any change not yet written SHALL be written before the process exits. The exit SHALL wait for that final write for a bounded time of about 2 seconds, so that an unreachable profile directory cannot keep the process from exiting. + +Before each write, the system SHALL re-read `history.json` and merge it with its own history so that concurrently running instances do not discard each other's entries: + +- Directories SHALL be combined by path, keeping the larger visit count and the later last-visit time for a path present in both. +- Commands present only in the file SHALL be kept as older than this instance's commands, subject to the existing command-history cap. + +Each write SHALL go to a uniquely named temporary file in the same directory, which SHALL be flushed to stable storage before it replaces `history.json`, so an interrupted write leaves either the previous or the new complete file. + +#### Scenario: Navigation records history + +- **WHEN** the user navigates the active panel into a directory +- **THEN** that directory's frecency entry is created or updated with an incremented visit count and refreshed last-visit time + +#### Scenario: History survives restart + +- **WHEN** the user visits directories, exits FileCommand, and relaunches +- **THEN** the fuzzy jump dialog lists those previously visited directories using their persisted frecency ranking + +#### Scenario: Missing or malformed history file + +- **WHEN** `history.json` is absent or cannot be parsed at startup +- **THEN** the fuzzy jump history loads as empty and the application launches normally without error + +#### Scenario: History is capped by frecency + +- **WHEN** the directory history holds 1,000 entries and the user navigates into a directory that is not in it +- **THEN** the entry with the lowest frecency score is removed, the new directory is recorded, and the history still holds 1,000 entries + +#### Scenario: Navigation does not wait for the history write + +- **WHEN** the user navigates the active panel into a directory +- **THEN** the navigation completes without the UI thread writing `history.json`, and the visit is written by the background writer afterwards + +#### Scenario: A burst of navigation is written once + +- **WHEN** the user navigates through 20 directories within one second and then pauses +- **THEN** `history.json` is written once after the pause, and that write contains all 20 visits + +#### Scenario: Quitting flushes unwritten history + +- **WHEN** the user navigates into `C:\work` and quits FileCommand before the pending change has been written +- **THEN** after the process exits, `history.json` contains the visit to `C:\work` + +#### Scenario: Two instances keep each other's history + +- **WHEN** two FileCommand instances share one profile directory, the first navigates into `C:\a`, the second navigates into `C:\b`, and both are then closed +- **THEN** `history.json` contains directory entries for both `C:\a` and `C:\b` + +#### Scenario: Merge keeps the larger count and the later visit + +- **WHEN** `history.json` records `C:\src` with visit count 9 last visited at time T1, and this instance's history records `C:\src` with visit count 4 last visited at a later time T2 +- **THEN** the next write records `C:\src` with visit count 9 and last-visit time T2 + +#### Scenario: Another instance's commands are kept + +- **WHEN** `history.json` contains the command `cd D:\build`, which this instance never ran, and this instance writes its history +- **THEN** the written command history still contains `cd D:\build`, ordered before this instance's own commands + +#### Scenario: Interrupted write leaves a complete file + +- **WHEN** the process is terminated while `history.json` is being written +- **THEN** `history.json` holds either its complete previous content or its complete new content, never a truncated or empty file diff --git a/openspec/changes/archive/2026-10-14-history-persistence-hardening/tasks.md b/openspec/changes/archive/2026-10-14-history-persistence-hardening/tasks.md new file mode 100644 index 0000000..ac5a09d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-history-persistence-hardening/tasks.md @@ -0,0 +1,73 @@ +# Tasks: history-persistence-hardening + +## 1. Wall-clock time in the reducer (R-13) + +- [x] 1.1 `crates/filecommand-core/src/clock.rs`: add `WallClock::epoch_ms(&self) -> u64` and give `TestWallClock` a pinnable epoch (constructor or setter) (fuzzy-jump "Frecency ranking"; design D1). +- [x] 1.2 `crates/filecommand-tui/src/clock.rs`: implement `RealWallClock::epoch_ms` with `SystemTime::now().duration_since(UNIX_EPOCH)`, saturating to 0 if the clock reads before the epoch. +- [x] 1.3 `crates/filecommand-core/src/update.rs`: change `Command::Tick(u64)` to `Command::Tick { now_ms, epoch_ms }`, add `State::epoch_ms`, set both fields in the top-of-`update` tick handling, and update the splash and other `Tick` match arms. `State::initial` takes the initial epoch (design D1). +- [x] 1.4 Stamp visits in `begin_listing` with `state.epoch_ms`, and pass `state.epoch_ms` to every `quicksearch::rank_directories` call in the Ctrl+J handlers and to `render_fuzzy_jump` in `crates/filecommand-tui/src/views/mod.rs`, so the drawn list and the reducer rank with the same time. Leave every animation, splash, double-click, and clipboard-feedback use of `state.clock_ms` unchanged (design D1). Add a test that the reducer's Enter target equals the first row the renderer draws when `clock_ms` and `epoch_ms` differ widely. +- [x] 1.5 `crates/filecommand-tui/src/app.rs`: send `Command::Tick { now_ms: clock.now_ms(), epoch_ms: wall_clock.epoch_ms() }` from the main loop, and pass the epoch to `State::initial`. Update `crates/filecommand-tui/tests/integration_smoke.rs` and the `update/tests.rs` `Tick` call sites to the new shape. + +## 2. Format marker, migration, and cap (R-13, P-08) + +- [x] 2.1 `crates/filecommand-core/src/config.rs`: `render_history_file` writes `"version": 2`. `load_history_file`/`parse_history_file` report whether the key was absent (legacy) without changing the tolerance for malformed files (design D2). +- [x] 2.2 Add the pure function `config::migrate_loaded_history(&mut HistoryFile, legacy, now_epoch)`. For a legacy file it sets every `last_visited_ms` to `now_epoch`; for any file it clamps future values to `now_epoch`; then it prunes to the cap (fuzzy-jump "Legacy history file is migrated at load", "Future last-visit time is clamped"; design D2). +- [x] 2.3 `crates/filecommand-core/src/quicksearch.rs`: add `MAX_DIR_HISTORY = 1_000` and `prune_to_cap(history, cap, now)`. `prune_to_cap` removes the lowest `frecency_score` first, and among equal scores the entry that comes last in list order. Make `record_visit` call it after an insert that exceeds the cap, never pruning the visited path (fuzzy-jump "History is capped by frecency"; design D3). +- [x] 2.4 `app::run`: apply `migrate_loaded_history` to the loaded file with the startup epoch before copying it into `State`. + +## 3. Merge and durable write (R-15 history part) + +- [x] 3.1 Add the pure function `config::merge_history_files(disk, mine, now_epoch) -> HistoryFile`: + - directories: union by path with `max` count and `max` time, this instance's entries first, then pruned to `MAX_DIR_HISTORY`; + - commands: disk-only commands first, in disk order, then `mine.commands`, keeping the newest `MAX_HISTORY` (fuzzy-jump "Two instances keep each other's history", "Merge keeps the larger count and the later visit", "Another instance's commands are kept"; design D5). +- [x] 3.2 Route `save_history_file_atomic` through the shared durable-replace helper: a unique temp name in the same directory, `sync_all` before the replace, and temp-file cleanup on failure. Introduce the helper here if `editor-encoding-safety` has not landed yet. The legacy plain-array `save_history_atomic` has no caller outside `config.rs` tests and also uses the fixed-name `temp_sibling`; route it through the helper too, or delete it with its tests, so no `history.json` write keeps the shared temp name (fuzzy-jump "Interrupted write leaves a complete file"; design D6). + +## 4. Background history writer (P-08) + +- [x] 4.1 New `crates/filecommand-tui/src/history_writer.rs`: `HistoryWriter::spawn(path, debounce, max_wait)`, `submit(HistoryFile)`, and an internal `Flush`. The thread keeps only the newest snapshot. It writes after 2 s with no new snapshot or 10 s after the oldest unwritten one. Each write is load → migrate the disk copy if legacy → `merge_history_files` → durable write (fuzzy-jump "A burst of navigation is written once"; design D4). +- [x] 4.2 `HistoryWriter` implements `Drop`: it sends `Flush` and joins with a bounded 2 s wait. Hold the writer in `Runtime`, spawned only when `persist_enabled`, so every exit path from `run` flushes (fuzzy-jump "Quitting flushes unwritten history"; design D4). +- [x] 4.3 `run_effects` → `Effect::PersistHistory(file)`: replace the inline `save_history_file_atomic` call with `rt.history_writer.submit(file)` (fuzzy-jump "Navigation does not wait for the history write"). +- [x] 4.4 `update.rs` `dispatch_cd`: remove the duplicate `Effect::PersistHistory`. The `begin_listing` that follows already snapshots the updated command history (design D4). + +## 5. Tests + +- [x] 5.1 `quicksearch.rs`: + - `prune_to_cap` removes the lowest score and breaks ties by the last list position; + - `record_visit` on a full 1,000-entry history adds the new path, keeps the length at 1,000, and never prunes the visited path; + - revisiting an existing path in a full history prunes nothing. +- [x] 5.2 `quicksearch.rs`/`update/tests.rs` R-13 regression: an earlier "session" stamps `C:\old` at epoch E and `C:\new` at E + 3 days. A fresh `State` with `epoch_ms` = E + 3 days + 1 min ranks `C:\new` first (fuzzy-jump "Recency survives a restart"). +- [x] 5.3 `config.rs`: + - a legacy file (no `"version"`) migrates every `last_visited_ms` to now; + - a v2 file keeps its timestamps except future ones, which clamp to now; + - an oversized file prunes to 1,000; + - a render/parse round trip keeps `"version": 2`; + - the existing malformed-file tests still yield an empty history. +- [x] 5.4 `config.rs` `merge_history_files`: + - disjoint directories union; + - an overlapping path takes `max` count and `max` time; + - merging is idempotent (merging the result with `mine` again gives the same result); + - disk-only commands come first; + - duplicates are not repeated; + - the 200-command cap keeps the newest; + - a merged directory union above 1,000 is pruned. +- [x] 5.5 `history_writer.rs` (temp dir, short debounce injected): + - 20 rapid submits produce one write containing the last snapshot; + - a submit followed by drop writes before `drop` returns; + - `submit` with persistence disabled writes nothing. +- [x] 5.6 `history_writer.rs` (temp dir): two writers on one path, each submitting a distinct directory and then dropped, leave a file containing both directories. Also, `history.json` already holding a foreign command keeps it after a write. +- [x] 5.7 `update/tests.rs`: a successful `cd` emits exactly one `Effect::PersistHistory`, and its snapshot contains the new command and the new visit. +- [x] 5.8 Durable write: with the helper's fault-injection seam, a failure before the replace leaves the previous `history.json` byte-identical and no `.fc-tmp` sibling. + +## 6. Docs + +- [x] 6.1 README `history.json` section: replace "written atomically so a crash mid-write never corrupts it" with the actual guarantees (flushed and atomically replaced; written in the background within seconds and at quit; merged across concurrent instances; directory history capped at 1,000 entries). + +## 7. Verify + +- [x] 7.1 `cargo test --workspace` green. +- [x] 7.2 `cargo clippy --workspace --all-targets` with no new warnings. +- [x] 7.3 `openspec validate history-persistence-hardening --strict` clean. +- [ ] 7.4 Manual check: + - start with an existing pre-change `history.json`, and confirm Ctrl+J lists the old directories ranked by count and that the file gains `"version": 2` after a navigation; + - run two instances, navigate in both, close both, and confirm both sets of directories are in the file; + - navigate quickly through many directories and confirm the UI never stutters and the file updates a few seconds later. diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/.openspec.yaml b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/design.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/design.md new file mode 100644 index 0000000..1c060ca --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/design.md @@ -0,0 +1,91 @@ +# Design: idle-aware-event-loop + +## Context + +Verified on `build/open-proposals-2026-10` (commit `1bada6f`): + +- **Loop shape** (`app.rs:349-451`). Each iteration: `drain_events` (its `applied` flag seeds `dirty`), then one terminal event — a stashed `pending_event`, or `event::poll(POLL_INTERVAL = 33 ms)` (`app.rs:33`). A pressed key is mapped against the current state (`map_key`, or `map_viewer_key` + `resolve_viewer_navigation`, or `map_editor_key`) and applied; `dirty = true`. A resize is applied; `dirty = true`. A mouse event drains all queued mouse events into one batch (`app.rs:409-431`). Any other event (key release, focus, paste) is ignored. A poll timeout applies `Command::Tick(clock.now_ms())` and sets `dirty = true` unconditionally (`app.rs:433-440`). If `dirty`, it drains again and draws (`app.rs:443-450`). +- **A lost redraw.** When a key maps to no command the branch does `continue` (`app.rs:397`), skipping the draw even if the drain at the top of that iteration applied worker results; the next iteration recomputes `dirty` from a fresh (empty) drain. Today the next 33 ms Tick repaints; with an idle loop the screen could stay stale indefinitely. +- **What `Tick` drives** (`update.rs:1227-1236`, `1405-1410`, `1738`): it sets `State::clock_ms`, expires clipboard feedback whose `expires_at_ms` has passed, and ends the splash after `SPLASH_MIN_HOLD_MS = 800`. Animations are pure functions of `clock_ms − stamp`. The animation clock/stamps foundation (`anim.rs`/`stamps.rs`) was verified on that branch but is **not on the current branch** — it comes from the still-open `ui-animations` change, and this change assumes `ui-animations` has landed. In that model `stamps::Snapshot::apply` stamps opened or changed surfaces with the current `state.clock_ms` after every `update`. Durations: splash entrance 760 ms, panel wipe 150 ms, menu-bar sweep 100 ms, pop-up unfold 100 ms, highlight settle 50 ms, dialog iris 100 ms. +- **Other `clock_ms` readers:** mouse double-click detection (`input/mouse.rs` `MouseTracker::last_click`, compared against `state.clock_ms` within ~400 ms), frecency visits recorded by `begin_listing`, and fuzzy-jump ranking at render time (`views/mod.rs:474`). +- **Header clock.** `draw` reads `RealWallClock::now_local()` (hour, minute) on every frame (`app.rs:1219`); it is not part of `State`. +- **Worker senders.** Only worker threads post to `rt.tx` asynchronously (`spawn_listing`, `spawn_job`, `spawn_info_query`, `spawn_git_info_query`, `spawn_drive_label`, `spawn_tree_expand`, `spawn_find_subtree`, `spawn_viewer_search`); `run_effects` and `reload_user_menu` send synchronously from the loop thread, and those messages are drained before the next draw. The timed-out inner git thread sends to a private channel only. +- **Terminal modes.** `terminal.rs` `enter_commands` writes `EnterAlternateScreen, Hide[, EnableMouseCapture]` (`terminal.rs:147-153`); `suspend_with`/`resume_with` order raw mode, mouse capture, and the alternate screen through injected closures (unit-tested); `restore_terminal` (Drop and panic hook) unconditionally disables mouse capture. Bracketed paste is never enabled. +- **crossterm 0.28.1** (`Cargo.lock`; checked in the registry source): `Event::Paste` is produced only by the Unix input parser (`event/sys/unix/parse.rs`); the Windows event source (`event/sys/windows/parse.rs`) reads console input records and never emits it. `EnableBracketedPaste` returns `Unsupported` only on the legacy-console path (`execute_winapi`); on an ANSI-capable Windows console, which is what `execute!` uses whenever `supports_ansi()` is true (Windows 10+ conhost, Windows Terminal), it writes `CSI ?2004h` like any other platform. The terminal may then wrap pastes in `ESC[200~`/`ESC[201~`, which the Windows reader can only deliver as ordinary key events. `DisableBracketedPaste` writes `CSI ?2004l` or, on the legacy path, is an `Ok(())` no-op. On Windows a paste therefore always arrives as individual key events — so the audit's "enable bracketed paste and map `Event::Paste`" fixes P-06's paste case only off Windows; the key-burst coalescing below is what fixes it on Windows, and the mode must not be enabled there. +- **Paste plumbing.** As verified on that branch, Ctrl+V / Shift+Ins map to `Command::RequestPaste` wherever a single-line text field owns the keyboard (dialog fields, and Ctrl+V over the panels for the command line; `input/mod.rs:364`, `:762-763`); `Effect::ReadClipboardText` flattens line breaks to a space and sends `Command::TextEdit(TextEditOp::Paste(text))` (`app.rs:1015-1030`), which the reducer routes to whichever field owns the keyboard. `TextEditOp::Paste` is **not on the current branch** — it comes from the still-open `line-editing-in-text-fields` change, and this change assumes it has landed (tasks.md's landing note already requires archiving that change first). The built-in editor maps Ctrl+V to `EditorPaste` (its own buffer) and typed characters to `EditorChar`/`EditorNewline`; its search/replace prompts and the viewer's search prompt take characters one at a time. +- **Progress.** `copy_one_file` (`fs_ops/worker.rs:268-286`) clones `ProgressInfo` (including the `current_file` `OsString`) and calls `sink.progress` on every 256 KiB chunk; start-of-job and per-file emitters exist for mkdir, copy, move, delete, and rename (`fs_ops/worker.rs:77, 136, 169, 334, 411, 444, 495, 525, 570`). `ChannelSink::progress` (`worker.rs:230-232`) forwards each one as `Command::JobProgress`, a full reducer pass including the stamps snapshot (`update.rs:1142-1144`). + +## Goals / Non-Goals + +**Goals:** + +- Zero redraws while nothing visible can change; unchanged responsiveness to input and worker results. +- Animations, clipboard-feedback expiry, splash hold, and the minute clock keep their current timing. +- A burst of queued input costs one frame, with input order and per-key routing exactly as today. +- Off Windows, a terminal paste is one insertion into the focused field, never a stream of keystrokes that can trigger commands. +- Job progress traffic bounded at ~20 Hz without ever showing stale figures at a decision point or at completion. + +**Non-Goals:** + +- A unified input+worker queue or a dedicated input thread (would require pausing the reader around every suspended child process). +- Making `Event::Paste` work on Windows (needs a different input backend), changing what Ctrl+V/Shift+Ins do, or making typed multi-line text in the command line behave differently on Windows. +- The budgeted worker drain itself (`streaming-listing-performance`), moving copies to `CopyFileExW` (`durable-file-copy`), Ctrl+C handling and quit/join behavior (`process-lifecycle-hardening`), resize semantics (`resize-preserves-session-state`). + +## Decisions + +### D1: Redraw on change or deadline; persistent "needs draw" + +`dirty` becomes `needs_draw`, declared outside the loop and cleared only by a draw, so a drained worker result followed by an unmapped key still repaints (fixes the lost redraw in Context). It is set by applied worker results, applied input, resize, and by a deadline Tick (D2). A poll timeout with no due deadline applies nothing and draws nothing. + +### D2: Deadlines come from a pure core query plus the wall clock + +Core gets `anim::next_wake_ms(&State) -> Option`: the earliest reducer-clock time at which `update(Tick)` or a render would differ from now — `clock_ms + FRAME_MS` (33 ms) while any stamped surface is still inside its animation duration (only when `state.animations` is on), the splash hold end in `UiPhase::Splash`, and the earliest clipboard-feedback `expires_at_ms`. The TUI adds the next wall-clock minute boundary from `clock.rs` (`60_000 − unix_ms % 60_000`; local offsets are whole minutes). It sleeps until the earliest of them; when one is due it applies `Tick(now)` and sets `needs_draw`. + +*Alternatives:* have `update` report "did this Tick change anything" — the reducer cannot know whether a *render* changes (animations are render-only), and the minute clock is TUI-side; rejected. Keep ticking at 30 Hz but skip identical frames by diffing buffers — still renders 30×/s, the cost P-05 is about; rejected. + +### D3: Wake for workers only while workers exist + +crossterm's `event::poll` cannot also wait on the `mpsc` channel, so while any worker that can post results is alive the poll timeout is capped at `POLL_INTERVAL` (33 ms) — today's latency for worker results. A `WorkerGauge` (`Arc`) counts them: every `spawn_*` in `worker.rs` moves a `WorkerToken` into its thread, which decrements on drop (normal exit or panic) after the thread's last send. With no live workers the loop sleeps until the next deadline (at most the next minute boundary). Race: a worker can send and then drop its token between the loop's drain and its gauge read, so after reading zero the loop drains once more before sleeping; the send happens-before the decrement (release/acquire), so that drain sees the message. + +*Alternatives:* a dedicated input thread forwarding crossterm events into the worker channel, letting the loop block on one `recv_timeout` — clean, but that thread would keep reading console input while a shell command, external editor, or scrollback view owns the terminal, stealing the child's keystrokes unless paused by a handshake around every suspend; rejected for this change. A fixed longer idle poll (e.g. 250 ms) — adds up to 250 ms to every worker result; rejected. + +### D4: Refresh the reducer clock before input + +Before applying the first input event of a batch (key burst, mouse batch, paste, resize), the loop applies `Tick(now)` when the last delivered tick is older than `POLL_INTERVAL`. This keeps today's invariant that `clock_ms` is at most ~33 ms stale whenever input is applied, so a menu opened after a minute of idling is stamped with the real time and unfolds over its full 100 ms; double-clicks separated by idle time are not mistaken for one; frecency visits get real timestamps. It applies no transition early: a key during the splash hold still dismisses the splash and is consumed, exactly as before, because the splash-end deadline already fires a Tick at 800 ms. + +### D5: Key bursts — drain ready input, then draw once + +After applying a pressed key, the loop keeps reading with `event::poll(Duration::ZERO)` while the burst budget (64 events or 8 ms) lasts: each pressed key is mapped against the **current** state through the same mapping function as the first key (factored out of the loop body) and applied; key releases and focus events are skipped; a resize is applied; a paste is handled per D6; a mouse event is stashed in `pending_event` and ends the burst (the mouse-batch path takes it next iteration). A `Quit` effect ends the loop immediately as today. Then one draw. Mapping each key after the previous one is applied preserves every state-dependent routing rule (Enter opening a dialog routes the next characters into the dialog). + +### D6: Bracketed paste where the terminal reports it; one paste into the focused field + +`enter_commands`, `resume_with`, and `suspend_with` gain an enable/disable step for bracketed paste, `cfg(not(windows))` only (see Context: crossterm cannot report pastes on Windows, and on an ANSI-capable Windows console enabling the mode does write `CSI ?2004h`, so the terminal could wrap pastes in delimiters the Windows reader would deliver as stray keys). Order: enabled after the alternate screen is entered (beside mouse capture), disabled before it is left; `restore_terminal` disables it unconditionally (a no-op where unsupported). The closure-injected order tests are extended. + +`Event::Paste(text)` goes through `input::map_paste(text, &state)`: + +- If Ctrl+V in the current context maps to `Command::RequestPaste` — i.e. a single-line text field owns the keyboard (a dialog field, or the command line over the panels) — produce `Command::TextEdit(TextEditOp::Paste(flatten_line_breaks(text)))`, the exact command Ctrl+V's clipboard read produces; `flatten_line_breaks` is shared with `Effect::ReadClipboardText`. Deriving the target from the Ctrl+V mapping means no second routing table. +- In the built-in editor's text: the commands the characters would produce if typed (`EditorChar`, `EditorNewline` for each line break), applied as one batch. +- In the editor's search/replace prompts and the viewer's search prompt: the per-character commands with line breaks flattened to spaces (so a pasted line break never confirms the prompt). +- Anywhere else (menus, confirmation and conflict dialogs, help): ignored — a pasted `y` can no longer answer a confirmation. + +### D7: Progress throttle at the sink, with flush points + +`fs_ops::job::ProgressThrottle` is pure: `offer(info, now) -> Option` returns `info` if at least 50 ms passed since the last delivered update (or none was delivered yet), otherwise keeps it as `pending` and returns `None`; `flush() -> Option` returns the pending update. `ChannelSink` owns one and calls `Instant::now()`; it flushes before sending `JobConflict`, `JobError`, and `JobDone`. Placing it at the sink rather than inside `copy_one_file` throttles every emitter — per-chunk copy progress, per-file delete/move progress, and the `CopyFileExW` progress routine `durable-file-copy` introduces — in one place, and keeps `run_job` and its `FakeFs` tests unthrottled and deterministic. The per-chunk `ProgressInfo` clone remains; it is negligible next to a 256 KiB read and write. Cancellation is unaffected: the worker still calls `is_cancelled` at every file and chunk boundary. + +*Alternative:* throttle in `copy_one_file` with an `Instant` check (the audit's suggestion) — covers only the chunk path, not 100,000-file deletes, and puts wall-clock reads into the engine its tests drive; rejected. + +## Risks / Trade-offs + +- [A future deadline source is forgotten, so something stops updating while idle] → `next_wake_ms` is the single place that enumerates timed state; a unit test opens each animated surface and asserts a wake inside its duration; the minute clock and clipboard expiry have their own tests. +- [Worker results arrive up to 33 ms late while workers live — unchanged from today] → accepted; zero-latency would need D3's rejected input thread. +- [A burst defers drawing by up to 8 ms] → bounded; key-repeat bursts become smoother overall because each key no longer costs a frame. +- [Ignoring pastes outside text contexts changes behavior off Windows] → today the same paste would fire arbitrary hotkeys and dialog answers; ignoring is the safe reading of "a paste is text". +- [Windows gets no single-paste insert] → documented; key-burst coalescing gives it one redraw per burst. + +## Migration Plan + +None — no persisted data or configuration changes. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/proposal.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/proposal.md new file mode 100644 index 0000000..76ea238 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/proposal.md @@ -0,0 +1,42 @@ +## Why + +The event loop spends work where nothing changes and spends too much where a lot changes at once (audit 2026-10-12): + +- **P-05 (Medium):** every 33 ms poll timeout applies `Command::Tick` and sets `dirty = true` (`app.rs:433-440`), so the full frame — `views::render`, `build_hitmap`, and the buffer diff — runs about 30 times per second forever, even with nothing on screen moving. The header clock only changes once a minute and animations last 50–760 ms. On large directories this compounds the per-frame cost, and laptop batteries pay for it. +- **P-06 (Medium):** each loop iteration reads exactly one terminal event and draws after every key. Mouse events are batched, keys are not, and bracketed paste is never enabled — so holding PgDn on a large directory, or pasting a long path, renders one frame per key and the screen lags behind the keyboard. +- **P-11 (Low):** a copy reports progress on every 256 KiB chunk with no throttling — about 10,000 `JobProgress` messages per second on NVMe, each a full reducer pass and a clone of the current file name — work that also feeds the unbounded drain loop. + +## What Changes + +- The loop redraws only when something changed: input or a worker result changed state, or a time-driven change became due — a frame of a running animation, a clipboard-feedback expiry, the end of the splash hold, or a minute boundary of the header clock. While idle it sleeps until the next such deadline, waking at the usual ~33 ms cadence only while worker threads that can post results are alive. +- Before applying input after an idle period, the loop brings the reducer clock up to date, so surfaces opened after idling still animate from their opening moment (the in-flight `ui-animations` model) and double-click detection compares real times. +- After handling one key, the loop keeps applying further already-queued input events — each to the state the previous one produced — up to 64 events or about 8 ms, then draws once. +- Bracketed paste is enabled on platforms whose terminal input reports pastes (crossterm 0.28 reports them only on non-Windows; on Windows a paste arrives as key events and is covered by the burst handling). A reported paste is inserted into the focused text field as one paste operation, through the same path as Ctrl+V; it is disabled on suspend, on restore, and on every exit path, and re-enabled on resume. +- Job progress reaching the UI is rate-limited to about 20 updates per second, and the latest progress is always delivered before a conflict or error question and before completion. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: ADDED "Idle event loop does not redraw" and "Bursts of key input render once". +- `text-field-editing`: ADDED "Terminal paste arrives as one paste". This capability is created by the in-flight `line-editing-in-text-fields` change; it is not yet in `openspec/specs/`. +- `file-operations`: ADDED "Progress updates are rate-limited". + +## Impact + +- **Core (`crates/filecommand-core`):** `anim.rs` — a pure "next wake" query over `State` (running animations, splash hold end, clipboard-feedback expiry); `fs_ops/job.rs` — a pure `ProgressThrottle` (offer/flush with an injected `Instant`). +- **TUI (`crates/filecommand-tui`):** + - `app.rs` — `run`'s loop: deadline-based poll timeout, a persistent "needs draw" flag, clock refresh before input, key-burst draining (`POLL_INTERVAL` stays as the worker-alive cadence), `Event::Paste` handling; `Runtime` gains a live-worker gauge. + - `worker.rs` — every `spawn_*` holds a gauge token for the thread's lifetime; `ChannelSink` throttles `progress` and flushes before `conflict`, `error`, and `done`. + - `input/mod.rs` — a paste router that targets exactly the field Ctrl+V would paste into, plus editor/prompt handling. + - `terminal.rs` — `enter_commands`, `suspend_with`/`resume_with`, and `restore_terminal` enable/disable bracketed paste (non-Windows), keeping the closure-injected order testable. + - `clock.rs` — milliseconds until the next wall-clock minute. +- **No persisted-format, config, or dependency changes** (crossterm's `bracketed-paste` feature is on by default). + +**Audit findings:** P-05, P-06, P-11 + +**Coordination:** `streaming-listing-performance` adds the budgeted worker-message drain and its backlog signal to this same loop; the backlog must force a zero poll timeout here — land it first, or fold its budget in when rebasing. `durable-file-copy` moves copying to a `CopyFileExW` progress routine; the throttle sits at the job sink, so progress from that routine is rate-limited without further work, and that change must keep reporting progress through `JobSink::progress`. `line-editing-in-text-fields` (in-flight) creates the `text-field-editing` capability and the `TextEditOp::Paste` path this change reuses — it must be archived before this change is archived. `ui-animations` (in-flight) defines the reducer-clock animation model the deadlines are derived from; nothing in it changes, and the pre-input `Tick` (design D4) keeps open stamps fresh after idling. `history-persistence-hardening` turns `Tick` into `{ now_ms, epoch_ms }` and relies on that same pre-input tick for fresh frecency timestamps; whichever lands second makes the pre-input tick carry both clocks. `resize-preserves-session-state` and `process-lifecycle-hardening` touch the same loop (resize handling, quit/Ctrl+C) and should rebase onto whichever lands first. diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/application-shell/spec.md new file mode 100644 index 0000000..090370f --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/application-shell/spec.md @@ -0,0 +1,59 @@ +## ADDED 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 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. 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, and no minute boundary passing for 30 seconds +- **THEN** no frame is drawn during those 30 seconds + +#### 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 + +### Requirement: Bursts of key input render once + +When several input events are already queued, the system SHALL apply them in order — mapping each key against the state produced by the previous one — and SHALL draw once after the burst instead of once per key. A burst SHALL end after 64 events, after about 8 ms of processing, when no further input is queued, or when a mouse event is reached; the system SHALL then draw before handling further input, so the screen never falls far behind. Applying a burst SHALL produce the same final state as applying the same keys one at a time. + +#### Scenario: Holding PgDn renders one frame per burst + +- **WHEN** five PgDn key events are queued at once in a 100,000-entry directory +- **THEN** all five page movements are applied and the screen is drawn once, showing the final position + +#### Scenario: Routing follows the state each key produces + +- **WHEN** the queued keys are F7 followed by the characters `new` and Enter while the panels are focused +- **THEN** F7 opens the Make-directory dialog, `new` is typed into its field, and Enter confirms it, exactly as if the keys had been pressed slowly + +#### Scenario: A paste delivered as key events renders once per burst + +- **WHEN** the terminal delivers a 60-character pasted path as 60 key events while the command line has focus +- **THEN** all 60 characters are inserted in order and the screen is drawn at most once for every 64 of them diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/file-operations/spec.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/file-operations/spec.md new file mode 100644 index 0000000..7cdea73 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/file-operations/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Progress updates are rate-limited + +While a file-operation job runs, the system SHALL deliver progress updates to the UI at most about 20 times per second, however quickly the job processes bytes or files. The system SHALL always deliver the job's latest progress before it asks an overwrite-conflict or error question and before it reports completion or cancellation, so those moments never show stale file names or counts. Rate-limiting SHALL NOT delay cancellation: the job SHALL still observe the cancel signal at every file boundary and between chunk copies. + +#### Scenario: A fast copy does not flood the UI + +- **WHEN** a single 10 GB file is copied at about 2 GB/s +- **THEN** the UI receives no more than about 20 progress updates per second, rather than one per 256 KiB chunk + +#### Scenario: Deleting many small files does not flood the UI + +- **WHEN** a delete job removes 100,000 small files in a few seconds +- **THEN** the UI receives no more than about 20 progress updates per second + +#### Scenario: The final progress is always delivered + +- **WHEN** a copy job of 3 files totalling 6,000 bytes completes in less than 50 ms +- **THEN** the last progress update the UI receives before completion reports `files_done` = 3 and `bytes_done` = 6,000 + +#### Scenario: Progress is current when a conflict is asked + +- **WHEN** a copy job reports progress for having copied `a.txt` and, less than 50 ms later, asks an overwrite-conflict question for `b.txt` +- **THEN** the progress update for `a.txt` reaches the UI before the conflict question does diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/text-field-editing/spec.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/text-field-editing/spec.md new file mode 100644 index 0000000..c342b3d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/specs/text-field-editing/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Terminal paste arrives as one paste + +On platforms where FileCommand's terminal input layer reports a bracketed paste as a single event — every supported platform except Windows — the system SHALL enable bracketed-paste mode while it owns the terminal, SHALL disable it before suspending for a shell command, the external editor, or the scrollback view and on every exit path including a panic, and SHALL re-enable it on resume. A reported paste received while a single-line text field owns the keyboard — the same field Ctrl+V would paste into, including the command line over the panels — SHALL be inserted at that field's caret as one paste operation, with line breaks replaced by a single space, exactly as "Paste into a text field" inserts clipboard text. A paste into the built-in editor's text SHALL be inserted at the caret as if typed, line breaks included; a paste into the editor's or the viewer's search or replace prompt SHALL be entered as if typed, with line breaks replaced by a single space. A paste received while no text input has focus SHALL be ignored. On Windows the system SHALL NOT enable bracketed-paste mode; a paste arrives as individual key events, which SHALL be handled as typed input, rendered as described in application-shell "Bursts of key input render once". The single-paste guarantees of this requirement, including flattening line breaks and ignoring pastes outside text input, therefore apply only off Windows. + +#### Scenario: Paste a path into the command line + +- **WHEN** the panels are focused and the terminal reports a paste of `C:\Program Files\App` +- **THEN** the command line contains `C:\Program Files\App` inserted at its caret in one operation, and the panel cursor, selection, and quick search are unaffected by the pasted characters + +#### Scenario: Multi-line paste is flattened and not executed + +- **WHEN** the command line has focus and the terminal reports a paste of two lines +- **THEN** the two lines are inserted joined by one space and no command is executed + +#### Scenario: Paste into a dialog field + +- **WHEN** the Copy destination field is focused with the caret at the end of `D:\` and the terminal reports a paste of `backup` +- **THEN** the field reads `D:\backup` with the caret at the end + +#### Scenario: Paste with no focused text input is ignored + +- **WHEN** the delete confirmation dialog is open and the terminal reports a paste of `y` +- **THEN** nothing is deleted and the dialog stays open + +#### Scenario: Child programs get a terminal without bracketed paste + +- **WHEN** off Windows, the user runs a command from the F2 user menu, which suspends FileCommand while it runs +- **THEN** bracketed-paste mode is disabled while the command runs and re-enabled when FileCommand resumes + +#### Scenario: On Windows a paste is typed input + +- **WHEN** FileCommand runs on Windows and the user pastes `C:\Program Files\App` while the command line has focus +- **THEN** bracketed-paste mode was never enabled, the characters are inserted as typed keys in order, and the screen is redrawn once per burst of keys rather than once per character diff --git a/openspec/changes/archive/2026-10-14-idle-aware-event-loop/tasks.md b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/tasks.md new file mode 100644 index 0000000..b8f5a41 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-idle-aware-event-loop/tasks.md @@ -0,0 +1,42 @@ +# Tasks: idle-aware-event-loop + +Landing note: `line-editing-in-text-fields` (in-flight) must be archived before this change is archived, since it creates the `text-field-editing` capability. If `streaming-listing-performance` has landed, keep its drain budget and make its backlog force a zero poll timeout in 2.3. + +## 1. Core: wake deadlines and progress throttle (`crates/filecommand-core`) + +- [x] 1.1 `anim.rs`: add `next_wake_ms(&State) -> Option` — `clock_ms + FRAME_MS` while any stamped surface (splash entrance, panel wipe, menu-bar sweep, pop-up unfold, highlight settle, dialog iris, quit dialog) is inside its duration and animations are on; the splash hold end in `UiPhase::Splash`; the earliest clipboard-feedback `expires_at_ms` (design D2) +- [x] 1.2 `anim.rs` tests: for each animated surface, opening it at clock `t` yields a wake before `t + duration` and none once finished; animations off yields no animation wake; splash and clipboard expiry wakes match their deadlines (application-shell "Animations still run after idling", "Clipboard feedback expires without input") +- [x] 1.3 `fs_ops/job.rs`: add `ProgressThrottle` with `offer(info, now) -> Option` (50 ms interval, first offer always delivered) and `flush() -> Option` (design D7) +- [x] 1.4 `fs_ops/job.rs` tests: offers within 50 ms are held and only the latest is flushed; an offer after 50 ms is delivered; 1,000 offers over 1 s deliver ≤ 21 (file-operations "A fast copy does not flood the UI") + +## 2. TUI: idle-aware loop (`crates/filecommand-tui/src/app.rs`, `worker.rs`, `clock.rs`) + +- [x] 2.1 `worker.rs`: add `WorkerGauge`/`WorkerToken` (RAII decrement on drop, release ordering) and move a token into every `spawn_*` thread; `Runtime` owns the gauge (design D3) +- [x] 2.2 `clock.rs`: add milliseconds until the next wall-clock minute boundary, with a test at known instants (application-shell "Header clock still advances while idle") +- [x] 2.3 `app.rs`: factor the poll-timeout choice into a pure `next_timeout(now, core_wake, minute_wake, workers_alive, backlog)` — zero with a drain backlog, at most `POLL_INTERVAL` while workers are alive, otherwise the earliest deadline — with unit tests (design D2, D3) +- [x] 2.4 `app.rs` loop: replace per-iteration `dirty` with a persistent `needs_draw` cleared only by a draw; on a timeout apply `Tick(now)` and set `needs_draw` only when a deadline is due; after reading a zero gauge, drain once more before a long sleep (application-shell "Idle screen is not redrawn", "Redraw survives an unmapped key"; design D1, D3) +- [x] 2.5 `app.rs`: before applying the first event of an input batch, apply `Tick(now)` when the last tick is older than `POLL_INTERVAL` (application-shell "Clicks separated by idle time are not a double-click"; design D4) +- [x] 2.6 `app.rs`: factor the per-key mapping (`map_key` / `map_viewer_key` + `resolve_viewer_navigation` / `map_editor_key`) into one function; after the first pressed key keep reading with a zero poll up to 64 events or 8 ms, mapping each key against the current state, applying resizes and pastes, skipping releases, stashing a mouse event in `pending_event`, then draw once (application-shell "Bursts of key input render once"; design D5) +- [x] 2.7 Tests: an injectable event source drives the loop helper with a queued burst and asserts one draw and the same final state as one-at-a-time application, including the F7/`new`/Enter routing case (application-shell "Holding PgDn renders one frame per burst", "Routing follows the state each key produces") + +## 3. TUI: bracketed paste (`terminal.rs`, `input/mod.rs`, `app.rs`) + +- [x] 3.1 `terminal.rs`: enable bracketed paste in `enter_commands` and `resume_with`, disable it in `suspend_with` and `restore_terminal`, `cfg(not(windows))` for the enable; extend the closure-injected order tests (text-field-editing "Child programs get a terminal without bracketed paste"; design D6) +- [x] 3.2 `input/mod.rs`: add `map_paste(text, &state) -> Vec` — `TextEdit(Paste(flattened))` where Ctrl+V maps to `RequestPaste`; `EditorChar`/`EditorNewline` in the editor text; per-character prompt commands with line breaks flattened in editor/viewer prompts; empty elsewhere (design D6) +- [x] 3.3 Share one `flatten_line_breaks` helper between `map_paste` and `Effect::ReadClipboardText` +- [x] 3.4 `app.rs`: handle `Event::Paste` through `map_paste`, applying the commands as one batch with one draw +- [x] 3.5 `input/tests.rs`: command line over the panels, quick filter active (still the command line, as Ctrl+V), destination field, find-file pattern, editor text with a line break, viewer search prompt, delete confirmation (ignored), menu open (ignored) (text-field-editing "Paste a path into the command line", "Multi-line paste is flattened and not executed", "Paste into a dialog field", "Paste with no focused text input is ignored") + +## 4. TUI: progress throttling (`worker.rs`) + +- [x] 4.1 `ChannelSink`: own a `ProgressThrottle`; `progress` sends only what `offer(info, Instant::now())` returns; `conflict`, `error`, and `done` send the `flush()`ed update first (file-operations "Progress updates are rate-limited"; design D7) +- [x] 4.2 Tests with a recording channel: a 3-file job finishing within 50 ms delivers a final progress of 3 files / 6,000 bytes before `JobDone`; a pending update precedes `JobConflict` (file-operations "The final progress is always delivered", "Progress is current when a conflict is asked") +- [x] 4.3 Confirm the core `run_job` tests (unthrottled recording sink) and the cancellation tests are unchanged + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green +- [x] 5.2 `cargo clippy --workspace --all-targets` with no new warnings +- [x] 5.3 `openspec validate idle-aware-event-loop --strict` clean +- [ ] 5.4 Manual (Windows): idle FileCommand shows ~0% CPU in Task Manager and the clock still ticks over; F9 after idling unfolds with animation; a pasted long path into the command line appears without per-character lag; a multi-GB copy's progress dialog updates smoothly +- [ ] 5.5 Manual (Linux, best-effort): a terminal paste into the command line arrives in one piece and is not executed diff --git a/openspec/changes/archive/2026-10-14-link-aware-listing/.openspec.yaml b/openspec/changes/archive/2026-10-14-link-aware-listing/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-link-aware-listing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-link-aware-listing/design.md b/openspec/changes/archive/2026-10-14-link-aware-listing/design.md new file mode 100644 index 0000000..da327c1 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-link-aware-listing/design.md @@ -0,0 +1,142 @@ +# Design: link-aware-listing + +## Context + +All of the following was verified on `build/open-proposals-2026-10` @ `1bada6f`, and the `std::fs` behavior with probes compiled by the audited toolchain (`rustc 1.98.1`) on this Windows 11 machine. + +**The reader.** `StdFsReader::read_dir` (`listing.rs`) enumerates `std::fs::read_dir(to_long_path(path))` and, for every entry, runs `let entry = entry?; let metadata = entry.metadata()?;`. It then builds `RawDirEntry { name, is_dir: metadata.is_dir(), size, modified, hidden }`, where `hidden` comes from the same metadata's attribute bits (hidden-entry-styling D1: "never a per-file stat"). `Entry::from(RawDirEntry)` maps `is_dir` to `EntryKind::Directory` or `File`. + +Four consumers share the reader: + +- `list_dir_chunked`, the panel listing, driven by `filecommand-tui`'s `spawn_listing`, which sends `ListingChunk` and `ListingComplete { panel, total }`, or `ListingFailed { panel, message }` on any `Err` +- `list_child_dirs` (Tree mode) +- `find_in_subtree` (Alt+F7), which descends into every `raw.is_dir` +- `gather_info` (Info mode counts) + +**What `std` reports for links.** A `mklink /J` probe printed the following: + +| Call | Result for the junction | +|---|---| +| `read_dir` entry `file_type()` / `metadata()` | `is_dir=false`, `is_symlink=true`, attributes `0x410` (DIRECTORY \| REPARSE_POINT) | +| `std::fs::metadata` (follow) | `is_dir=true` | +| `std::fs::metadata` on a dangling junction | `NotFound` | + +Rust's Windows `is_symlink()` is true for every *name-surrogate* reparse tag, so it covers both junctions and symlinks. Other reparse points keep `is_symlink() == false` and are unaffected: OneDrive cloud placeholders already report `is_dir()` correctly, and App Execution Aliases are files. Unix `DirEntry::metadata` is `lstat`, so a symlink to a directory also reports `is_dir=false`. + +**Where per-entry metadata can fail.** On Windows, `DirEntry::metadata()` is assembled from the enumeration's own find data. A probe that deleted an entry mid-iteration and then called `metadata()` on it got `Ok`. So the R-21 `entry.metadata()?` failure is a non-Windows concern: on Unix, `lstat` fails with `ENOENT` (raced deletion) or `EACCES` (a directory readable but not searchable). The `entry?` iterator error is different: it is an error from the enumeration itself (`FindNextFileW` or `readdir` failing). On Windows the iterator re-calls `FindNextFileW` after an error, so "skip and continue" there could repeat the error indefinitely. + +**The mini-status.** `views/panel.rs::render_bottom_border` shows, in priority order: + +1. a status override (Tree or Quick View) +2. `panel.last_error` +3. `Reading… N` while streaming +4. the selection summary or the cursored entry's details + +`last_error` is the established inline-message surface. It carries failed listings, failed F3/F4/open-with launches and the editor's "too large" redirect, is cleared by `begin_new_listing` and by successful launches, and is stored per tab. + +**Second delete confirmation.** `enter_delete_confirm_for_sources` sets `needs_second_confirm = sources.iter().any(|s| s.is_dir)`, and the second prompt reads "`` contains a directory — delete its entire contents?". `SourceItem::is_dir` comes from `Entry::is_dir_like()`. Today a junction is a `File`, so it gets one prompt. + +## Goals / Non-Goals + +**Goals:** + +- Junctions, directory symlinks and Unix directory symlinks list as directories and can be entered. +- Links to files list as files with their target's size, and dangling links list as non-enterable entries without failing the listing. +- One entry whose metadata cannot be read no longer fails the whole listing, and the user is told how many were skipped. +- Keep the link distinction available internally (`link` flag) for the consumers that need it. + +**Non-Goals:** + +- A visual link marker in the entry row. "Entry row rendering" is modified in flight by `hidden-entry-styling` and `full-mode-column-widths`. A marker can follow as its own change. +- Recreating or managing links, and any `fs_ops` reparse behavior (`move-reparse-point-safety`). +- The same per-entry pattern in `fs_ops`' job enumeration (`RealFs::read_dir`). See D6. +- Streaming the enumeration (`streaming-listing-performance`) and stale-listing guards (`listing-request-guard`). +- Showing the link's own date versus its target's date as a user choice. The link's own date is shown (D1). + +## Decisions + +### D1: Classify a link by following it once, and keep the link's own name, date and hidden flag + +For each enumerated entry, the reader computes `link = metadata.file_type().is_symlink()` from the enumeration's own metadata, at no extra I/O. + +- **Not a link:** classification is unchanged and costs no extra call. +- **A link:** the reader calls `std::fs::metadata()` once. On success: + - `is_dir` and, for a file target, `size` come from the target. + - `modified` and `hidden` stay those of the link entry. That keeps "what is in this directory" accurate and preserves hidden-entry-styling's "flag from the enumeration's own metadata" rule. + +Entering a directory link sets the panel's `cwd` to the link's own path (`cwd.join(name)`). The listing reads the target's contents through it, and `parent_path` is lexical, so `..` and Ctrl+PgUp return to the directory containing the link. This matches Explorer and `cmd`. + +*Alternative considered:* on Windows, decide from the link's own `FILE_ATTRIBUTE_DIRECTORY` bit with zero I/O. Rejected: it cannot tell a dangling link from a live one, it would list a dangling junction as an enterable directory that then fails to read, and Unix has no equivalent. +*Alternative considered:* follow every entry. Rejected: it adds a stat per entry to every listing, and hidden-entry-styling D1 deliberately avoids per-file stats. + +### D2: A link that cannot be followed lists as a non-enterable file entry + +When following fails for any reason (`NotFound` for a dangling target, or access denied on the target), the entry becomes `RawDirEntry { is_dir: false, size: 0, link: true, .. }`, keeping its own date and hidden flag. Enter on it opens the file-action menu, as it does for every link today. The listing never fails because of it. + +*Alternative considered:* omit dangling links. Rejected: the user could not see or delete the broken link. + +### D3: `link: bool` on `RawDirEntry` and `Entry`, internal only + +Both structs gain `link`. `Entry::parent_dir()` sets it to `false`, and `Entry::from(RawDirEntry)` copies it. Nothing renders it (non-goal). It has two consumers today, D4 and D5. Sorting, selection and every other `is_dir_like()` user treat a directory link exactly as a directory: F3 does nothing on it, F4 does not launch the editor, Quick View shows its directory indicator, Brief and Full render it as a directory, and drop-target validation treats it as a directory. + +### D4: Find-file does not descend into links; Tree mode shows directory links + +`find_in_subtree` pushes a child onto its walk stack only when `raw.is_dir && !raw.link`. It still *matches* a link by name. The rule keeps today's find-file results, since links were files, and avoids junction cycles. Windows ships such cycles: `C:\ProgramData\Application Data` points back to `C:\ProgramData`, and only an ACL stops traversal there today. + +`list_child_dirs` (Tree) needs no special case. Directory links appear as children, and expansion is lazy and user-driven (additional-panel-modes "Tree lazy expansion"), so a cycle costs only what the user explicitly expands. + +*Alternative considered:* a visited-identity set in `find_in_subtree`. Rejected because it needs per-directory identity queries for no user-facing benefit over "links are leaves," the rule `fs_ops` Delete already uses. + +### D5: Deleting a directory link asks for one confirmation, not two + +`enter_delete_confirm_for_sources` builds the set of the active panel's entry names whose `link` is true, which is usually empty. It then sets `needs_second_confirm` only for a source that `is_dir` and is not a listed link of that panel (`s.path.parent() == Some(&panel.cwd)` and its name is in the set). + +The second prompt says "contains a directory — delete its entire contents?". Deleting a link removes only the link (the scenario "Delete removes the link, not the target" under file-operations requirement "Reparse-point (symlink/junction) semantics"), so the prompt would be false. One confirmation is also today's behavior for junctions. operation-dialogs "Delete confirmation dialog" asks for a second confirmation "before removing a non-empty directory," which deleting a link is not, so that requirement is not modified. A directory link does now *look* like a non-empty directory, though, so the ADDED "Directory links list as directories" requirement states the single confirmation explicitly, with its own scenario. `SourceItem` is not changed. + +### D6: An unreadable entry is skipped and counted; enumeration errors still fail; `fs_ops` is out of scope + +**Panel reader.** In `StdFsReader::read_dir`: + +- A failing `entry.metadata()`, the entry's own non-followed metadata, increments `unreadable` and `continue`s. +- A failing `entry?` (the enumeration itself) still returns `Err`, which becomes `ListingFailed` and the existing inline read-error state. Continuing after an iterator error could loop on a persistent `FindNextFileW` failure, and an enumeration error does not identify a single entry. + +`FsReader::read_dir` returns `RawDirListing { entries, unreadable }`. `list_dir_chunked` returns `ListingTotals { entries, unreadable }`, and `spawn_listing` sends `ListingComplete { panel, total, unreadable }`. `list_child_dirs`, `find_in_subtree` and `gather_info` read `.entries` and ignore the count. + +*Alternative considered:* a placeholder entry per unreadable name. Rejected: there is no kind, size or date to show, and in the raced-deletion case the name no longer exists, so any operation on it would fail. + +**Job enumeration (not changed).** `fs_ops`' `RealFs::read_dir` has the same `entry.metadata()?` pattern (R-21's second location). It is listed as a non-goal rather than routed through the error-recovery dialog, for three reasons: + +- On Windows it cannot fail per entry: it uses the same find data, as verified above. +- Elsewhere, a failure turns that one directory into a single `SkippedItem` in the end-of-job summary. The user is told, and no data is lost: a move leaves the non-empty source directory in place. +- The walkers (`walk_copy`, `walk_delete`) read only `RawEntry::name` and re-stat every child through `fs.metadata`, whose per-child failure already becomes its own skipped item. The real fix is to drop the unused `RawEntry::metadata`, a `FileSystem` trait change that `durable-file-copy` and `move-reparse-point-safety` are reshaping in the same file. + +Routing it through the error-recovery dialog would not fit. Enumeration runs in `plan_copy`/`walk_delete` before the job's per-file dialog loop exists, and every other walk-phase failure is reported as a skipped item, not a dialog. A small follow-up after those two changes land is the right vehicle. + +### D7: The count is shown on the mini-status through `last_error` + +On `ListingComplete` with `unreadable > 0`, the reducer sets the panel's `last_error` to `"1 entry could not be read"` or `"N entries could not be read"`, with N comma-grouped by `format_count`. + +`last_error` is the existing inline-message surface. It needs no new state or rendering, so there is no snapshot churn, it is cleared by the next `begin_new_listing` of that panel (navigation, Ctrl+R, job re-read), and it is stashed per tab. + +The message replaces the reverted name/size/date/time line for that listing, which the existing "Streaming listing mini-status" requirement would otherwise demand on every completion. That requirement is therefore MODIFIED: its description gains the exception, and the WHEN of its "Mini-status reverts on completion" scenario becomes "completes and no entry was skipped as unreadable". Its other three scenarios are unchanged, and one scenario for the exception is added. No other new or in-flight change modifies that requirement. + +*Alternative considered:* a transient notice. Rejected: the only transient surface is the clipboard-specific `ClipboardFeedback`, which is cleared by any non-`Tick` command. A git-info or Info reply arriving right after `ListingComplete` could wipe it before it is seen. +*Alternative considered:* a count inside the `Reading… N` text. Rejected: it disappears at completion. + +The trade-off: while the message stands, it hides the selection summary and the cursored entry's details, just as a failed F3 does today. A Ctrl+R that reads everything clears it. + +### D8: Testability via a pure classification helper + +The per-entry decision ("unreadable", "plain", "link → followed", "link → dangling") is a pure function over a small `EntryFacts { is_dir, is_symlink, len, modified, hidden }` value plus a follow closure returning `io::Result`. `StdFsReader` adapts `std::fs::Metadata` into it. Unit tests exercise every branch on any platform. Real-filesystem tests cover junctions on Windows (`#[cfg(windows)]`, `mklink /J`, including a dangling one made by deleting the target) and symlinks on Unix (`#[cfg(unix)]`, `std::os::unix::fs::symlink`). + +## Risks / Trade-offs + +- [A link to an unreachable network target stalls the listing for the SMB timeout while it is followed] → Only link entries are followed. The read runs on the listing worker, so the UI stays interactive, and `listing-request-guard` makes a superseded listing harmless. +- [Hidden or system profile junctions (`Application Data`, `My Documents`) become enterable directories whose listing fails with "Access is denied"] → This is correct and matches Explorer and `cmd`. The panel shows its existing inline read-error state, and `..` leaves. +- [`last_error` hides the selection summary while the unreadable-count message stands] → Rare in practice (non-Windows races and unsearchable directories), and cleared by the next listing. Documented in D7. +- [Struct-literal churn] Every `Entry { .. }` and `RawDirEntry { .. }` literal in tests gains `link: false`. → Mechanical. `hidden-entry-styling` set the precedent. +- [Enter on a junction cycle (for example `ProgramData\Application Data`) keeps nesting the path] → User-driven only, one level per Enter, and blocked by the ACL on stock Windows. find-file never follows (D4). + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-link-aware-listing/proposal.md b/openspec/changes/archive/2026-10-14-link-aware-listing/proposal.md new file mode 100644 index 0000000..4c91557 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-link-aware-listing/proposal.md @@ -0,0 +1,71 @@ +# Change: link-aware-listing + +## Why + +Two audit findings live in the panel's directory reader, `StdFsReader::read_dir` (`listing.rs`). + +**R-05 (High): junctions and directory symlinks list as files and cannot be entered.** The reader classifies each entry with `DirEntry::metadata()`, which never follows a reparse point. Rust reports every name-surrogate reparse point (junction or symlink) with `is_dir() == false`. A probe against a `mklink /J` junction printed `is_dir=false is_symlink=true attrs=0x410`. Unix `lstat` behaves the same way for a symlink to a directory. A junction therefore becomes `EntryKind::File`: + +- Enter opens the file-action menu instead of descending. +- F3 tries to view a directory. +- The Size column shows `0` instead of `▶SUB-DIR◀`. + +This breaks panel-navigation "Enter descends into a directory" for pnpm `node_modules` (junction-based on Windows), profile junctions (`Application Data`, `My Documents`), `C:\Users\All Users`, and developer symlinks. + +**R-21 (Low): one unreadable entry fails the whole listing.** `let entry = entry?; let metadata = entry.metadata()?;` turns a single entry whose metadata cannot be read into `ListingFailed` for the entire directory. On Unix the classic case is a file deleted between `readdir` and `lstat` in a busy temp or build directory. On Windows, `DirEntry::metadata()` is served from the enumeration's own find data and does no I/O. A probe deleted an entry mid-iteration and its metadata still read fine. So on Windows the per-entry failure comes only from the new link-following step this change adds, and that step handles failure itself (a dangling link lists as a file). + +## What Changes + +- **Directory links list as directories.** When an enumerated entry is a link (`file_type().is_symlink()`: junctions and symlinks on Windows, symlinks on Unix), the reader follows it with one `std::fs::metadata` call to classify it: + - A link to a directory lists as a directory: directory style, `▶SUB-DIR◀`, Enter descends. + - A link to a file lists as a file with the target's size. + - A dangling or unfollowable link lists as a non-enterable file entry instead of failing the listing. + - The name, date/time and hidden flag still come from the link entry itself. + - Entering a directory link makes the link path the panel's directory. `..` returns to the directory that contains the link, not the target's parent. +- **Entries carry an internal link flag** (`RawDirEntry::link`, `Entry::link`). The flag is not rendered. Two consumers use it: + - Find-file (Alt+F7) does not descend into directory links, which keeps today's behavior and avoids junction cycles. + - The F8 delete confirmation asks once, not twice, for a directory link, because Delete removes only the link. This keeps today's behavior: junctions were files before this change. +- **One unreadable entry does not fail a listing.** An entry whose own metadata cannot be read is skipped and counted. On completion, a listing that skipped N entries shows `N entries could not be read` (or `1 entry could not be read`) on the panel's mini-status line, through the existing inline-message surface (`last_error`). The message stays until that panel next lists a directory. An error from the enumeration itself, not tied to one entry, still fails the listing with the inline read-error state, as today. +- Tree mode's lazy child read and Info's directory counts use the same reader, so they also see directory links as directories and stop failing on an unreadable entry. Nothing else changes for them. +- Non-breaking. No rendering changes, so no snapshot changes. A link marker in the entry row is out of scope, because "Entry row rendering" is being modified in flight. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: new requirements "Directory links list as directories" and "One unreadable entry does not fail a listing." "Streaming listing mini-status" is modified: its "revert on completion" rule gains the skipped-entries exception, and its existing scenarios are kept. None of the in-flight requirements ("Entry row rendering", "Full display mode layout", "Scroll offset is core panel state") is modified. + +## Impact + +- **Specs:** `panel-navigation` (two added requirements, one modified requirement). +- **Code:** + - `crates/filecommand-core/src/listing.rs`: + - `RawDirEntry` and `Entry` gain `link: bool`. + - `FsReader::read_dir` returns `RawDirListing { entries, unreadable }`. + - `StdFsReader::read_dir` (lines 465-482) skips and counts unreadable entries, and follows links through a small pure classification helper. + - `list_dir_chunked` (lines 499-513) returns `ListingTotals { entries, unreadable }`. + - `list_child_dirs` and `find_in_subtree` (lines 525-583) consume `.entries`, and `find_in_subtree` skips descent into links. + - `crates/filecommand-core/src/update.rs`: + - `Command::ListingComplete` gains `unreadable: usize`. + - `apply_listing_event` (lines 3846-3912) sets the mini-status message. + - `enter_delete_confirm_for_sources` (lines 2751-2756) excludes directory links from the second-confirmation trigger. + - `crates/filecommand-tui/src/worker.rs`: `spawn_listing` (lines 178-203) forwards the count, and `gather_info` (lines 102-124) reads `.entries`. + - Every `Entry { .. }` and `RawDirEntry { .. }` struct literal in core and tui tests gains `link: false`, as `hidden-entry-styling` did for `hidden`. + - The `FsReader` test doubles (`listing.rs` `FakeReader`/`TreeReader`, `tui/worker.rs` `FakeReader`) adopt the new return type. +- **Out of scope:** `fs_ops`' job enumeration (`RealFs::read_dir`, `fs.rs:177-187` and `311-317`) has the same `entry.metadata()?` pattern. See design D6 for why it stays as is. + +**Audit findings:** R-05, R-21 + +**Coordination:** +- `listing-request-guard` (R-04, P-10) adds a request id to `StartListing` and the three listing commands. That is a textual overlap on `ListingComplete` and `spawn_listing`, with no semantic conflict. +- `streaming-listing-performance` (P-01..P-04) adds a streaming `FsReader::for_each_entry` (with `read_dir` collecting through it) and moves `Entry` construction to an `Entry::new(..)` constructor with a private sort key. Whichever change lands second rebases. If streaming lands first: + - `classify_entry` runs inside `StdFsReader::for_each_entry`'s per-entry loop. + - The unreadable count becomes that method's `Ok` value and rides on `ListingComplete`. + - `link` becomes an `Entry::new` parameter rather than a struct-literal field. + + The behavior in this proposal is unchanged either way. +- `move-reparse-point-safety` makes `fs_ops` treat reparse points as leaves when a move removes its source. It is independent: `fs_ops` classifies from its own `FsMetadata` and does not read `Entry::link`. It also fixes a pre-existing `RealFs` defect where Delete of a junction fails with "Access is denied." Junctions become far more visible and actionable once they list as directories, so landing `move-reparse-point-safety` first, or in the same batch, is recommended. There is no hard dependency. diff --git a/openspec/changes/archive/2026-10-14-link-aware-listing/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-14-link-aware-listing/specs/panel-navigation/spec.md new file mode 100644 index 0000000..2e0177e --- /dev/null +++ b/openspec/changes/archive/2026-10-14-link-aware-listing/specs/panel-navigation/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Directory links list as directories + +When a directory listing contains a link (a junction or symbolic link on Windows, a symbolic link elsewhere), the system SHALL classify the entry by following the link to its target: + +- A link whose target is a directory SHALL be listed, sorted, and rendered exactly as a directory: the directory style, `▶SUB-DIR◀` in the Size column, and Enter descends into it. +- A link whose target is a file SHALL be listed as a file, showing the target's size. +- A link whose target cannot be reached (dangling, or not followable) SHALL be listed as a file entry that Enter does not navigate into. It SHALL NOT cause the listing to fail. + +In every case, the entry's name, date and time, and hidden state SHALL be those of the link entry itself. The system SHALL record internally that the entry is a link. No link-specific marker is required in the entry row. + +Descending into a directory link SHALL make the link's own path (the containing directory joined with the link's name) the panel's current directory. Parent navigation from there SHALL return to the directory that contains the link. + +Deleting a directory link removes only the link (the scenario "Delete removes the link, not the target" under file-operations requirement "Reparse-point (symlink/junction) semantics"), so the delete confirmation for it SHALL NOT raise the second confirmation that a non-empty directory requires. + +#### Scenario: A junction to a directory lists as a directory +- **WHEN** a panel lists `C:\proj\node_modules`, which contains a junction `lodash` pointing to an existing directory +- **THEN** `lodash` is listed as a directory: it renders in the directory style with `▶SUB-DIR◀` in the Size column and sorts among the directories + +#### Scenario: Enter descends into a directory link +- **WHEN** the cursor is on the junction `lodash` in `C:\proj\node_modules` and Enter is pressed +- **THEN** the panel's current directory becomes `C:\proj\node_modules\lodash` and a listing of the link target's contents begins + +#### Scenario: Parent navigation from inside a link returns to the link's directory +- **WHEN** the panel entered `C:\proj\node_modules\lodash` through the junction and the user presses Ctrl+PgUp (or Enter on `..`) +- **THEN** the panel navigates to `C:\proj\node_modules`, not to the parent of the link's target + +#### Scenario: A directory symbolic link behaves like a junction +- **WHEN** a listed entry is a symbolic link whose target is a directory +- **THEN** it is listed as a directory and Enter descends into it + +#### Scenario: A link to a file lists as a file with the target's size +- **WHEN** a listed entry is a symbolic link to a 4,096-byte file +- **THEN** it is listed as a file and its Size column shows 4,096 + +#### Scenario: A dangling link does not fail the listing +- **WHEN** a directory contains a junction `old` whose target directory no longer exists, alongside other entries +- **THEN** the listing completes with every entry shown, `old` is listed as a file entry rather than a directory, and pressing Enter on `old` does not change the panel's directory + +#### Scenario: Deleting a directory link asks once +- **WHEN** the cursor is on the junction `lodash`, whose target directory contains files, and the user presses F8 and confirms +- **THEN** no second "non-empty directory" confirmation is shown, and the delete job starts for the link + +### Requirement: One unreadable entry does not fail a listing + +When the system reads a directory and the metadata of an individual entry cannot be read, it SHALL skip that entry, count it, and continue the listing. It SHALL NOT fail the whole listing because of it. A failure to enumerate the directory itself, rather than a single entry, SHALL still put the panel into the inline read-error state. + +When a listing completes after skipping one or more entries, the panel's mini-status line SHALL report how many were skipped: `1 entry could not be read`, or `N entries could not be read` with N comma-grouped. The message SHALL remain until that panel next lists a directory. It is shown in place of the highlighted entry's details that "Streaming listing mini-status" otherwise restores on completion. + +#### Scenario: One unreadable entry is skipped and reported +- **WHEN** a panel lists a directory of 50 entries and one entry's metadata cannot be read +- **THEN** the other 49 entries are listed and the panel is not in the read-error state +- **AND** when the listing completes the mini-status line reads `1 entry could not be read` + +#### Scenario: The message clears on the next listing +- **WHEN** the mini-status shows `2 entries could not be read` and the user presses Ctrl+R, and this time every entry is read +- **THEN** after the re-read completes the mini-status shows the highlighted entry's name, size, date, and time again + +#### Scenario: A directory that cannot be enumerated still shows the read-error state +- **WHEN** a panel attempts to list a directory and enumerating the directory itself fails (for example, access denied or the drive was removed) +- **THEN** the panel enters the inline read-error state exactly as before + +## MODIFIED Requirements + +### Requirement: Streaming listing mini-status + +While a directory listing is still being read, the mini-status line SHALL show `Reading… N` with a running, comma-grouped entry count that updates as chunks arrive, and SHALL revert to the normal name/size/date/time line when the read completes. The one exception is a listing that skipped unreadable entries: on completion its mini-status SHALL show the skipped count instead, as "One unreadable entry does not fail a listing" defines. Entries SHALL be inserted in sorted position as they arrive, and the panel SHALL remain interactive while the listing is incomplete, with the cursor held on the first row until the user moves it. + +#### Scenario: Mini-status shows running count while reading + +- **WHEN** a directory listing is incomplete and 12345 entries have arrived +- **THEN** the mini-status line SHALL read `Reading… 12,345` + +#### Scenario: Mini-status reverts on completion + +- **WHEN** a directory listing completes and no entry was skipped as unreadable +- **THEN** the mini-status line SHALL revert to showing the highlighted entry's name, size, date, and time + +#### Scenario: Cursor holds on first row during streaming + +- **WHEN** entries continue to arrive and the user has not moved the cursor +- **THEN** the cursor SHALL remain on the first row while newly arriving entries are inserted in sorted position + +#### Scenario: Partial listing is interactive + +- **WHEN** a directory listing is still streaming +- **THEN** cursor-movement commands SHALL operate on the entries already received without waiting for the read to complete + +#### Scenario: Completion after skipped entries shows the count + +- **WHEN** a directory listing completes after skipping 2 unreadable entries +- **THEN** the mini-status line reads `2 entries could not be read` instead of the highlighted entry's details diff --git a/openspec/changes/archive/2026-10-14-link-aware-listing/tasks.md b/openspec/changes/archive/2026-10-14-link-aware-listing/tasks.md new file mode 100644 index 0000000..2ca5851 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-link-aware-listing/tasks.md @@ -0,0 +1,49 @@ +# Tasks: link-aware-listing + +## 1. Data model (`crates/filecommand-core/src/listing.rs`) + +- [x] 1.1 Add `link: bool` to `RawDirEntry` and `Entry`. `Entry::parent_dir()` sets `link: false`, and `Entry::from(RawDirEntry)` copies it (design D3) +- [x] 1.2 Add `RawDirListing { entries: Vec, unreadable: usize }` and change `FsReader::read_dir` to return `io::Result` (design D6) +- [x] 1.3 Change `list_dir_chunked` to return `io::Result`. `list_child_dirs` reads `.entries` (design D6) +- [x] 1.4 Update every `Entry { .. }` / `RawDirEntry { .. }` struct literal in core and tui sources and tests with `link: false`. If `streaming-listing-performance` has landed, add `link` to `Entry::new` instead, and run the classification inside `for_each_entry`. Also update the `FsReader` test doubles (`listing.rs` `FakeReader`/`TreeReader`, `filecommand-tui/src/worker.rs` `FakeReader`) to the new return type + +## 2. Reader (`listing.rs`) + +- [x] 2.1 Add a pure `classify_entry(name, own: io::Result, follow: impl FnOnce() -> io::Result) -> Option` (design D8): + - `own` is `Err` → `None` (counted as unreadable). + - Not a link → as today. + - A link with `follow` `Ok` → target's `is_dir` and size, the link's own `modified` and `hidden`, `link: true`. + - A link with `follow` `Err` → `is_dir: false, size: 0, link: true` (design D1, D2). +- [x] 2.2 Rewrite `StdFsReader::read_dir` on top of it. `entry?` errors still return `Err`. `entry.metadata()` is adapted into `EntryFacts` (`is_symlink` from `file_type()`, `hidden` via the existing `is_hidden`). `follow` is `std::fs::metadata(long.join(name))`, and the count of `None` results becomes `unreadable` (panel-navigation: "One unreadable entry does not fail a listing"; design D6) +- [x] 2.3 In `find_in_subtree`, push a child onto the walk stack only when `raw.is_dir && !raw.link`. Matching by name is unchanged (design D4) + +## 3. Reducer and worker + +- [x] 3.1 `update.rs`: add `unreadable: usize` to `Command::ListingComplete`. In `apply_listing_event`, after the existing completion work, set `last_error` to `1 entry could not be read` or `N entries could not be read` (N via `format_count`) when `unreadable > 0`. Update every existing `ListingComplete { .. }` construction in tests (design D7) +- [x] 3.2 `update.rs` `enter_delete_confirm_for_sources`: build the active panel's set of `link` entry names and exclude sources that are listed links of that panel from `needs_second_confirm` (design D5) +- [x] 3.3 `filecommand-tui/src/worker.rs`: `spawn_listing` sends `ListingComplete { panel, total: total + totals.entries, unreadable: totals.unreadable }`, and `gather_info` counts over `.entries` (design D6) + +## 4. Tests + +- [x] 4.1 `listing.rs`: `classify_entry` unit tests for each branch: unreadable, plain file, plain dir, link to a directory, link to a file (target size, link date), dangling link (design D8) +- [x] 4.2 `listing.rs`: `list_dir_chunked` with a `FakeReader` returning `unreadable: 2` reports `ListingTotals { entries, unreadable: 2 }`, and the `link` flag survives `Entry::from` and sorting (design D3, D6) +- [x] 4.3 `listing.rs`: `find_in_subtree` over a `TreeReader` fixture with a directory-link entry matches the link by name but never reads beneath it (design D4) +- [x] 4.4 `listing.rs` `#[cfg(windows)]`: in a temp directory, create a real directory, a file, a `mklink /J` junction to the directory, and a dangling junction (junction to a directory that is then removed). `StdFsReader::read_dir` reports the junction with `is_dir: true, link: true`, the dangling junction with `is_dir: false, link: true`, `unreadable == 0`, and does not fail (panel-navigation: "A junction to a directory lists as a directory", "A dangling link does not fail the listing") +- [x] 4.5 `listing.rs` `#[cfg(unix)]`: `std::os::unix::fs::symlink` to a directory, to a 4,096-byte file, and to a missing path, classified as directory, as a file of 4,096 bytes, and as a file respectively (panel-navigation: "A directory symbolic link behaves like a junction", "A link to a file lists as a file with the target's size") +- [x] 4.6 `update/tests.rs`: Enter on a `Directory` entry with `link: true` emits `StartListing` for `cwd.join(name)`. Ctrl+PgUp from there lists the containing directory (panel-navigation: "Enter descends into a directory link", "Parent navigation from inside a link returns to the link's directory") +- [x] 4.7 `update/tests.rs`: Enter on a dangling link (a `File` entry with `link: true`) does not start a listing (panel-navigation: "A dangling link does not fail the listing") +- [x] 4.8 `update/tests.rs`: `ListingComplete { unreadable: 1 }` sets `last_error` to `1 entry could not be read`, and `unreadable: 1234` gives `1,234 entries could not be read`. A following re-read with `unreadable: 0` leaves `last_error` as `None` (panel-navigation: "One unreadable entry is skipped and reported", "The message clears on the next listing", "Completion after skipped entries shows the count") +- [x] 4.9 `update/tests.rs`: `ListingFailed` still sets the inline error, unchanged (panel-navigation: "A directory that cannot be enumerated still shows the read-error state") +- [x] 4.10 `update/tests.rs`: F8 on a directory-link entry opens the delete confirmation with `needs_second_confirm == false`. F8 on a real directory still requires the second confirmation (panel-navigation: "Deleting a directory link asks once"; design D5) +- [x] 4.11 `filecommand-tui/src/worker.rs` tests: `gather_info` with the new `FakeReader` shape counts directory links as directories +- [x] 4.12 Confirm no insta snapshot changes. Rendering is untouched + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green on Windows, including the `#[cfg(windows)]` junction test +- [x] 5.2 `cargo clippy --workspace --all-targets` introduces no new warnings +- [x] 5.3 `openspec validate link-aware-listing --strict` clean +- [ ] 5.4 Manual (Windows): + - In a scratch directory run `mklink /J lnk `. Confirm `lnk` shows `▶SUB-DIR◀`, Enter enters it, and Ctrl+PgUp returns. + - Remove the target, Ctrl+R, and confirm `lnk` lists as a file and the listing does not fail. + - F8 on a live junction asks once. If `move-reparse-point-safety` has landed, it removes only the link and leaves the target intact. Before that change, the Delete job itself fails with "Access is denied", a pre-existing `RealFs` defect that change fixes. diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/.openspec.yaml b/openspec/changes/archive/2026-10-14-listing-request-guard/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/design.md b/openspec/changes/archive/2026-10-14-listing-request-guard/design.md new file mode 100644 index 0000000..7a0b331 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/design.md @@ -0,0 +1,107 @@ +# Design: listing-request-guard + +## Context + +Verified on `build/open-proposals-2026-10` (commit `1bada6f`): + +- **Listing replies have no generation key.** `Command::ListingChunk { panel, entries }`, `ListingComplete { panel, total }`, and `ListingFailed { panel, message }` (`update.rs:708-710`) and `Effect::StartListing { panel, path }` (`update.rs:1006`) carry only a `PanelSide`. `update_impl` folds them in before any phase or overlay gating (`update.rs:1241-1244`), and `apply_listing_event` (`update.rs:3846-3911`) inserts into whatever directory and tab that side shows *now*. `StartListing` is emitted by `State::initial` (`update.rs:444-445`) and by `begin_listing_inner` (`update.rs:3813-3839`), which every navigation, re-read, job-completion refresh, stale-tab refresh, and the Tree-mode preview of the opposite panel go through. +- **Workers are never told to stop.** `worker::spawn_listing` (`worker.rs:178-203`) sends a synthetic `..` chunk first when the directory has a parent, then whatever `list_dir_chunked` produces, then `ListingComplete`/`ListingFailed`. Nothing can stop it. +- **Tabs.** `Command::SwitchTab`/`CloseTab` (`update.rs:1557-1579`) have no streaming guard. `PanelState::to_tab_data` (`panel.rs:764-789`) stashes the active tab — including `progress: Streaming` — with `stale: false`, so the stashed tab is never re-read and stays at `Reading…` forever, while its worker's chunks land in the newly active tab. `switch_tab`/`close_tab` return the target tab's `stale` flag and the reducer then re-reads it through `begin_listing_inner(.., true)` without a frecency visit (panel-tabs "Stale background tab refresh on activation"). +- **Ctrl+U** (`handle_swap_panels`, `update.rs:3632-3638`) refuses while either side streams — the hazard was known and patched at that one call site. +- **Find-file** already ignores stale replies (`FindFileState::request`, `push_match`/`mark_done` compare ids). `FindFileCancel` just sets `state.find_file = None` (`update.rs:3507`); `FindFileConfirm` takes it (`update.rs:3509`). The walk (`worker::spawn_find_subtree`, `worker.rs:169-176` → `listing::find_in_subtree`, `listing.rs:566-583`) has no stop signal and keeps posting every match into the unbounded channel. Re-submitting inside one dialog is impossible (Enter maps to `FindFileConfirm` once a search is in flight), so a "new search" means Esc, reopen, submit. +- **Viewer search** already ignores stale replies (`ViewerState::search_request`, `update.rs:2154`). `ViewerClose` returns immediately (`update.rs:2110`); `spawn_viewer_search` (`worker.rs:133-145`) opens its own `ByteSource` and `find_forward` (`viewer/search.rs:22-49`) scans to the end of the file. A second F7 while one runs mints a new id but the old scan continues. +- **The flag already exists.** `fs_ops::job::CancelFlag` (`fs_ops/job.rs:94-110`) is a cloneable `Arc`; the TUI keeps one per running job in `Runtime::active_job` (`worker::JobHandle`). + +## Goals / Non-Goals + +**Goals:** + +- No listing result ever lands in a directory, tab, or listing generation other than the one that requested it. +- One cancellation mechanism shared by listings, find-file walks, and viewer searches, with the supersession decision made where the request ids already live — the reducer. +- A tab left mid-listing recovers by itself on its next activation, reusing the existing stale-refresh path. +- Core stays pure: no flags, threads, or atomics enter `State`. + +**Non-Goals:** + +- Lifting Ctrl+U's "refused while streaming" rule (in-flight `pulldown-menu-completion` adds it; it stays). +- Making enumeration itself interruptible mid-`read_dir` — that needs the streaming reader from `streaming-listing-performance` (P-01); this change checks the flag between chunks. +- Cancelling git-info queries (libgit2 status walks have no cancel hook; `git-info-timeout-memory` handles their pile-up), Info queries, drive-label fetches, or Tree-node expansion (all bounded, single-directory work). +- Error-message content (B-06, `error-diagnostics`), worker panic reporting (R-12, `process-lifecycle-hardening`), streaming performance (P-01..P-04). + +## Decisions + +### D1: A per-listing request id on `PanelState`, minted like `git_request` + +`begin_listing_inner` and `State::initial` mint `state.next_request_id()` for each listing, store it in a new `PanelState::listing_request: Option`, and put it on `Effect::StartListing { panel, path, request }`. The worker echoes it on every `ListingChunk`, `ListingComplete`, and `ListingFailed`. `listing_request` is part of `TabData` (stash/restore beside `git_request`), and moves with the panel on Ctrl+U like every other field. + +*Alternatives:* key on the directory path — rejected: a re-read of the same directory (double Ctrl+R, job-completion refresh during a manual re-read) must still distinguish generations. A per-panel generation counter separate from `next_request_id` — rejected: the shared counter is already the house pattern (Info, git, drive labels, find-file, viewer search) and ids from it are globally unique, which the TUI's cancel registry (D3) relies on. + +### D2: Drop mismatches first, before any mutation + +`apply_listing_event` begins with `if state.panel(panel).listing_request != Some(request) { return; }`, ahead of the entry insert, the progress count, the in-flight `reread-preserves-cursor` anchor application, the find-file `pending_cursor_target` settle, and the selection reconcile. `update()`'s post-condition that clears a drag on `ListingFailed` (`update.rs:1141-1153`) only fires when the failure was accepted, so a stale failure from a directory the user left cannot cancel a drag in progress elsewhere. A completed listing keeps its `listing_request` (late replies still mismatch nothing, because the worker sends nothing after completion), so no clearing on completion is needed. + +### D3: One cancel mechanism — `Effect::CancelRequest(u64)` + a TUI registry of `CancelFlag`s + +The reducer decides supersession (it mints and compares ids), so it emits `Effect::CancelRequest(id)`; the TUI owns the flags, exactly as it owns `JobHandle`. `Runtime` gains a small `CancelRegistry` (`HashMap` behind `register`, `cancel`, `retire`): + +- `run_effects` creates and registers a fresh flag when executing `StartListing`, `FindInSubtree`, and `RunViewerSearch`, passing a clone to the worker. +- `CancelRequest(id)` sets the flag and removes the entry; an unknown id (already finished, never cancellable) is a silent no-op, so the reducer never needs to know whether a worker has finished — this matters for the viewer, whose `search_request` stays `Some` after its result arrives. +- `apply` retires the entry when the request's final reply passes through it (`ListingComplete`/`ListingFailed`, `FindFileSearchDone`, `ViewerSearchResult`), the same pre-`update` interception point `apply` already uses for `ViewerClose`. + +*Alternatives:* a `CancelFlag` inside `State` — rejected: an `Arc` breaks the pure-core contract (application-shell "Pure core update function": equal states must yield equal results) and the core/tui boundary. A per-panel "current request" atomic the worker polls — rejected: listing-only, and find-file/viewer would need their own. Per-request channels that the worker notices closing — rejected: all workers share the one `Sender` by design (application-shell "Single-threaded UI event loop with worker threads"). + +### D4: Where the reducer emits `CancelRequest` + +| Site | Cancels | When | +|---|---|---| +| `begin_listing_inner` | the panel's previous `listing_request` | the previous listing is still `Streaming` | +| `SwitchTab` | the leaving tab's `listing_request` | the leaving tab is `Streaming` | +| `CloseTab` | the closed tab's `listing_request` | the closed tab is `Streaming` | +| `FindFileCancel`, `FindFileConfirm` | `FindFileState::request` | a search was submitted and is not `done` | +| `ViewerClose`, `ViewerSearchConfirm` | the previous `search_request` | one was ever issued in this session | + +`OpenTab` (Ctrl+T) emits nothing: the new tab inherits the active tab's state, including `listing_request`, so the in-progress listing keeps filling it — it is the same directory. Ctrl+U cannot reach a streaming panel (refused). + +### D5: Workers check the flag and go silent + +- `spawn_listing` checks before sending the `..` chunk, before each chunk, and before the final `ListingComplete`/`ListingFailed`; `list_dir_chunked` takes `&CancelFlag` and stops slicing once it is set. Until `streaming-listing-performance` replaces `read_dir`'s collect-everything call with a streaming reader, a superseded listing still finishes its one `read_dir` call but sends nothing afterwards; after that change the check moves inside the enumeration callback. +- `find_in_subtree` takes `&CancelFlag` and checks before each directory read and between entries. +- `find_forward` takes `&CancelFlag`, checks before each chunk read, and returns `None` once set; `spawn_viewer_search` then sends no reply and drops its `ByteSource`, releasing its own mapping of the file. + +A cancelled worker sends no terminal message: its id is already superseded in core, so any message would be dropped anyway, and staying silent keeps the channel quiet. + +### D6: Tabs reuse the stale-refresh path + +`to_tab_data` stamps `stale: self.progress.is_streaming()` instead of a constant `false`. Every path that stashes the active tab goes through it (`open_tab`, `close_tab`, `switch_tab` via `full_tab_list`), so a tab stashed mid-listing is re-read when next activated: `switch_tab`/`close_tab` already return the flag and the reducer already calls `begin_listing_inner(.., true)` — a non-navigation re-read (no frecency visit, no history persist). While stashed, the tab's chunks are dropped by D2 because the inline `listing_request` belongs to the newly active tab. + +This widens what "stale" means, so the panel-tabs delta MODIFIES "Stale background tab refresh on activation": its description names the second source of staleness, and its scenario "Switching to a tab with no pending staleness is unchanged" gains the condition "and whose listing had finished being read when it was left". Without that, the scenario (a tab untouched by any job activates from cache) would contradict "Returning to an interrupted tab re-reads it". The other three scenarios are kept verbatim. No other new or in-flight change modifies that requirement. + +**Keeping an interrupted re-read's anchor.** The in-flight `reread-preserves-cursor` requires a stale tab's activation refresh to restore the cursor, and its design D5 stashes `reread_anchor` in `TabData` so "a tab switched away mid-re-read keeps its anchor for when its listing completes". Under this change that listing is cancelled and never completes; the stale refresh replaces it. But `capture_reread_anchor` runs first in `begin_listing_inner` and, because the stashed tab's progress is `Streaming`, it *clears* the anchor (it only anchors to a `Complete` listing), so the refreshed tab would jump to the top. The stale-tab path therefore calls `begin_listing_inner(.., capture_anchor: false)` when the activated tab is still `Streaming`, and `true` (today's call) otherwise. `begin_new_listing` keeps an existing anchor for the same `cwd`, so: + +- a tab interrupted mid-re-read keeps the anchor that re-read captured from its last complete listing, and the refresh restores that position; +- a tab interrupted mid-navigation has no anchor (navigation cleared it), so it starts from the top, which is correct for a directory that never finished listing. + +`capture_reread_anchor`, `apply_reread_anchor`, and every other caller are unchanged, so `reread-preserves-cursor`'s own behavior (including its accepted jump-to-top for a Ctrl+R issued while the previous listing still streams) is untouched. + +### D7: Ctrl+U rule unchanged + +With D1/D2 a mid-stream swap could no longer misdeliver chunks, but lifting the refusal is a behavior change owned by `pulldown-menu-completion`'s requirement. Only `handle_swap_panels`' doc comment is updated (it currently cites the missing request id as the reason). + +### D8: Test ergonomics + +About 60 test sites in `update/tests.rs` construct listing commands directly. A helper `listing_chunk(state, side, entries)` (and `listing_complete`, `listing_failed`) reads `state.panel(side).listing_request` so existing tests keep their meaning; new tests construct mismatched ids explicitly. + +## Risks / Trade-offs + +- [A registry entry outlives a worker that dies without a final reply] → bounded and small; `process-lifecycle-hardening` turns a listing-worker panic into `ListingFailed { request }`, which retires the entry. Find-file and viewer workers always send their final reply unless cancelled (and a cancel removes the entry). +- [Before streaming lands, a superseded listing of a huge directory still finishes its `read_dir` in the background] → nothing reaches the UI and it sends nothing; the cost is one wasted enumeration, removed by `streaming-listing-performance`. +- [Cancelling the closed or left tab's listing loses work already read] → intended: the tab re-reads on activation, and a half-read listing is never shown as if complete. +- [Mechanical churn in tests] → contained by the D8 helpers; assertions about effects match `Effect::StartListing { panel, path, .. }`. + +## Migration Plan + +None — no persisted data or configuration changes. The change is internal plumbing plus the observable fixes in the specs. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/proposal.md b/openspec/changes/archive/2026-10-14-listing-request-guard/proposal.md new file mode 100644 index 0000000..e8351fe --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/proposal.md @@ -0,0 +1,43 @@ +## Why + +Directory-listing replies are addressed only by panel side, so a listing the user has already moved past can still pour its entries into whatever the panel shows now (audit 2026-10-12 **R-04**, High). Enter a slow directory and press Backspace before it finishes: the old worker's entries — including its own `..` — stream into the parent's listing, and its `ListingComplete` can mark the new listing complete early. Two quick Ctrl+R presses on a directory that takes a moment to read list its entries twice, because chunks are inserted whatever listing they belong to. Switching tabs mid-listing sends the old tab's entries into the newly active tab and leaves the old tab stuck at `Reading…` forever. The panel then shows files that do not exist in that directory, and F5/F6/F8 on them resolve against the *current* directory — either failing or hitting a different file with the same name. The codebase already guards Info, git, drive-label, find-file, and viewer-search replies with request ids; listings are the one async reply without one (Ctrl+U refuses to swap while streaming because of exactly this hazard). + +Separately, superseded background work never stops (audit **P-10**, Low): Esc in find-file hides the dialog but the subtree walk keeps reading the whole tree and posting every match; a viewer F7 search keeps scanning a multi-GB file (holding its own mapping) after the viewer closes or a newer search starts. A search from `C:\` with a short pattern keeps a disk and a core busy long after it was dismissed. + +## What Changes + +- Every directory listing is tagged with a fresh request id (minted like `git_request`, via `State::next_request_id`) and recorded as the panel's current listing request. `ListingChunk`, `ListingComplete`, and `ListingFailed` carry that id; results whose id does not match the panel's current listing request are dropped before they touch any panel state. +- One generic cancellation mechanism for superseded background work: a new `Effect::CancelRequest(u64)` that the reducer emits whenever it supersedes or abandons a cancellable request, and a TUI-side registry mapping request id → `fs_ops::job::CancelFlag` (the flag that already backs job cancellation). Listing, find-file, and viewer-search workers receive the flag and stop — sending nothing further — once it is set. +- A tab that stops being active while its listing is still being read (Alt+1..9, Ctrl+W) has that read cancelled and is marked stale, so it re-reads on its next activation through the existing "Stale background tab refresh on activation" path instead of keeping a half-read listing. Ctrl+T keeps the in-progress listing flowing into the new tab, which shows the same directory, and marks the original tab stale. +- Find-file's subtree walk stops on Esc, on confirming a result while it is still running, and when a new search replaces it. A viewer F7 search stops when the viewer closes or another F7 search starts. +- Non-goal: lifting the Ctrl+U "refused while streaming" rule (being added by the in-flight `pulldown-menu-completion` change). It stays as specified. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: ADDED "Superseded directory listings are discarded and cancelled". +- `panel-tabs`: ADDED "Switching or closing a tab mid-listing never mixes listings". MODIFIED "Stale background tab refresh on activation": a tab left mid-listing is a second source of staleness, and the "no pending staleness" scenario is qualified accordingly; the other scenarios are kept verbatim. +- `find-file`: ADDED "Dismissed or superseded searches stop". +- `viewer`: ADDED "Search stops when superseded or when the viewer closes". + +## Impact + +- **Core (`crates/filecommand-core`):** + - `update.rs` — `Command::ListingChunk`/`ListingComplete`/`ListingFailed` and `Effect::StartListing` gain `request: u64`; new `Effect::CancelRequest(u64)`; `State::initial` and `begin_listing_inner` mint the id; `apply_listing_event` drops mismatched ids; `update()`'s listing-failure drag clear only fires for an accepted failure; `Command::SwitchTab`/`CloseTab` handlers cancel the leaving tab's in-progress listing; `handle_find_file` (`FindFileCancel`, `FindFileConfirm`) and `handle_viewer` (`ViewerClose`, `ViewerSearchConfirm`) emit `CancelRequest`. + - `panel.rs` — `PanelState::listing_request` and `TabData::listing_request`; `to_tab_data` marks a tab stashed mid-listing stale. + - `listing.rs` — `find_in_subtree` and `list_dir_chunked` take a `&CancelFlag`; `viewer/search.rs` — `find_forward` takes a `&CancelFlag`. + - Tests: `update/tests.rs` (≈60 listing-command sites move to a request-aware helper), `panel.rs`, `listing.rs`, `viewer/search.rs`, `find_file.rs`. +- **TUI (`crates/filecommand-tui`):** + - `worker.rs` — `spawn_listing`, `spawn_find_subtree`, `spawn_viewer_search` take the request id and a `CancelFlag`, check it, and send nothing after cancellation. + - `app.rs` — `Runtime` gains a cancel registry; `run_effects` registers flags on `StartListing`/`FindInSubtree`/`RunViewerSearch` and executes `CancelRequest`; `apply` retires a registry entry when the request's final reply passes through. + - Tests: `worker.rs` unit tests, `tests/integration_smoke.rs`, `tests/snapshot_views.rs` call sites. +- **No persisted-format, config, or dependency changes.** + +**Audit findings:** R-04, P-10 + +**Coordination:** `streaming-listing-performance` reworks the same listing pipeline (`spawn_listing`, `list_dir_chunked`, `apply_listing_event`) and must land **after** this change, moving the cancel check inside its streaming enumeration. `link-aware-listing` changes `StdFsReader` classification in the same worker path (no ordering constraint; rebase whichever lands second). `process-lifecycle-hardening` makes a panicking listing worker report `ListingFailed` — it must carry the request id introduced here. `resize-preserves-session-state` keeps the viewer alive across a too-small resize, so `ViewerClose` remains the only viewer exit that cancels a search. `reducer-overlay-structure` restructures the `update_impl` gates these handlers sit behind. The in-flight `reread-preserves-cursor` anchor logic runs inside `apply_listing_event` after the new id check; the in-flight `pulldown-menu-completion` Ctrl+U rule is unchanged. diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/specs/find-file/spec.md b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/find-file/spec.md new file mode 100644 index 0000000..a4f5eca --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/find-file/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Dismissed or superseded searches stop + +When a find-file search is no longer wanted — the dialog is dismissed with Esc, a result is confirmed while the walk is still running, or a new search replaces it — the system SHALL signal the subtree walk to stop. The walk SHALL check that signal before reading each directory and between the entries it examines, SHALL stop promptly once it is set, and SHALL send no further results. Results of a stopped or superseded walk SHALL never appear in any find-file dialog. + +#### Scenario: Esc stops the walk + +- **WHEN** a search started at `C:\` with the pattern `a` is still walking the subtree and the user presses Esc +- **THEN** the dialog closes and the walk stops before reading another directory, producing no further results + +#### Scenario: Confirming a result while the walk is running stops it + +- **WHEN** the user highlights a result and presses Enter while the walk is still running +- **THEN** the panel navigates to the result's directory and the walk stops + +#### Scenario: A new search after Esc runs alone + +- **WHEN** the user presses Esc during a running search, reopens the dialog with Alt+F7, and submits a different pattern +- **THEN** only the new search's walk is running and only its matches appear in the result list diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-navigation/spec.md new file mode 100644 index 0000000..fcc5c97 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-navigation/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Superseded directory listings are discarded and cancelled + +Every directory listing the system starts for a panel SHALL be tagged with a fresh request id that becomes that panel's current listing request, and every streamed batch of entries, completion notice, and failure notice SHALL carry the id of the listing that produced it. The system SHALL apply a listing result only when its id matches the panel's current listing request and SHALL silently discard every other listing result, so that a listing superseded by a navigation, a re-read, a drive change, a file-operation refresh, or any other new listing of the same panel never adds entries to, completes, or fails the listing that replaced it. When a listing is superseded while it is still being read, the system SHALL signal its reader to stop; the reader SHALL stop at its next check and SHALL send no further entries, completion notice, or failure notice. + +#### Scenario: Backspace out of a slow directory mid-listing + +- **WHEN** the active panel enters `C:\big` and, while that listing is still streaming, the user presses Backspace on an empty command line to return to `C:\` +- **THEN** the panel shows only the entries of `C:\` — no entry from `C:\big`, not even `C:\big`'s `..` entry — and the `Reading… N` count covers only `C:\`'s entries +- **AND** the read of `C:\big` is signalled to stop + +#### Scenario: cd elsewhere mid-listing + +- **WHEN** the panel's listing of `D:\logs` is still streaming and the user runs `cd C:\Work` from the command line +- **THEN** only `C:\Work`'s entries appear in the panel, and none of `D:\logs`'s entries arrive later + +#### Scenario: Superseded completion does not complete the new listing + +- **WHEN** a superseded listing's completion notice arrives after the panel has started reading a different directory +- **THEN** the mini-status keeps showing `Reading… N` for the new listing until the new listing's own completion arrives + +#### Scenario: Superseded failure does not mark the new listing failed + +- **WHEN** the read of a directory the user has already left fails with access denied after the panel started reading another directory +- **THEN** no error is shown for the current directory and its listing continues normally + +#### Scenario: Two quick re-reads do not duplicate entries + +- **WHEN** the user presses Ctrl+R twice in quick succession on a directory with 500 entries +- **THEN** after both reads finish the panel lists each of the 500 entries exactly once + +#### Scenario: Superseded reader stops + +- **WHEN** a listing is superseded while its reader is still producing entries +- **THEN** the reader stops at its next check and sends no further entries, completion notice, or failure notice diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-tabs/spec.md b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-tabs/spec.md new file mode 100644 index 0000000..3c20fed --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/panel-tabs/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Switching or closing a tab mid-listing never mixes listings + +A tab's directory listing SHALL only ever add entries to that tab. When the active tab stops being active while its listing is still being read — by Alt+1..9 switching to another tab, or by Ctrl+W closing it — the system SHALL stop that read and SHALL NOT let any of its results reach the newly active tab. A tab switched away from in this state SHALL be marked stale and SHALL be refreshed with a fresh read when it next becomes active, through the same non-navigation refresh as "Stale background tab refresh on activation", instead of keeping or showing a partial listing. When the interrupted listing was a re-read of the tab's own directory, that refresh SHALL restore the cursor and scroll position the interrupted re-read was restoring, as panel-navigation "Same-directory re-read preserves cursor and scroll position" requires for a stale tab's activation refresh. When Ctrl+T opens a new tab while the active tab's listing is still being read, the listing SHALL continue to fill the new tab, which shows the same directory, and the original tab, now in the background, SHALL be marked stale. + +#### Scenario: Switching tabs mid-listing + +- **WHEN** tab 1 of the active panel is still streaming the listing of `C:\big` and the user presses Alt+2 to activate tab 2 at `C:\Work` +- **THEN** tab 2 shows only `C:\Work`'s entries, no entry of `C:\big` ever appears in it, and the read of `C:\big` is stopped + +#### Scenario: Returning to an interrupted tab re-reads it + +- **WHEN** the user later presses Alt+1 to return to the tab whose listing of `C:\big` was interrupted +- **THEN** the tab starts a fresh read of `C:\big` and shows `Reading… N` until that read completes, rather than a partial listing that never completes +- **AND** the refresh does not record a fuzzy-jump history visit or persist configuration + +#### Scenario: An interrupted re-read still keeps the user's place + +- **WHEN** the cursor is on `report.txt` partway down tab 1's complete listing, the user presses Ctrl+R, switches to tab 2 with Alt+2 before the re-read finishes, and later returns with Alt+1 +- **THEN** the refreshed listing of tab 1 puts the cursor back on `report.txt` with the scroll offset it had before Ctrl+R + +#### Scenario: Closing a tab mid-listing + +- **WHEN** the active tab is still streaming a listing and the user presses Ctrl+W +- **THEN** that read is stopped and the neighboring tab that becomes active receives none of its entries + +#### Scenario: New tab mid-listing keeps the listing + +- **WHEN** the active tab is still streaming the listing of `C:\big` and the user presses Ctrl+T +- **THEN** the new tab, showing `C:\big`, keeps receiving the listing until it completes +- **AND** activating the original tab later re-reads `C:\big` instead of showing the partial listing it held when Ctrl+T was pressed + +## MODIFIED Requirements + +### 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"), or by stopping being the active tab while its own listing was still being read (see "Switching or closing a tab mid-listing never mixes listings") — 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` +- **THEN** the tab re-reads its directory instead of showing its stale cached entries + +#### Scenario: Closing a tab activates a neighbor that is stale +- **WHEN** Ctrl+W closes the active tab and falls back to an adjacent tab that was marked stale +- **THEN** the newly-active tab re-reads its directory instead of showing its stale cached entries + +#### Scenario: Switching to a tab with no pending staleness is unchanged +- **WHEN** the user switches to a tab that was not affected by any completed job since it was last active, and whose listing had finished being read when it was left +- **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 diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/specs/viewer/spec.md b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/viewer/spec.md new file mode 100644 index 0000000..3ab9454 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/specs/viewer/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: Search stops when superseded or when the viewer closes + +The system SHALL stop an F7 search that is still scanning when the viewer closes or when the user starts another F7 search in the same viewer. The search SHALL check for this before reading each chunk, SHALL stop at the next chunk boundary once signalled, SHALL release its own handle on the file, and SHALL deliver no result. A superseded search's result SHALL never move the view or set the match highlight. + +#### Scenario: Closing the viewer stops a running search + +- **WHEN** an F7 search for a pattern that does not occur is scanning a multi-GB file and the user presses F10 +- **THEN** the viewer closes, the search stops within one chunk read, and the search no longer holds the file open + +#### Scenario: A newer search supersedes a running one + +- **WHEN** F7 search A is still scanning and the user starts F7 search B in the same viewer +- **THEN** search A stops at its next chunk boundary and only search B's result moves the view and highlights a match diff --git a/openspec/changes/archive/2026-10-14-listing-request-guard/tasks.md b/openspec/changes/archive/2026-10-14-listing-request-guard/tasks.md new file mode 100644 index 0000000..c0e5e74 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-listing-request-guard/tasks.md @@ -0,0 +1,61 @@ +# Tasks: listing-request-guard + +## 1. Core: listing request ids (`crates/filecommand-core`) + +- [x] 1.1 `update.rs`: add `request: u64` to `Effect::StartListing`, `Command::ListingChunk`, `Command::ListingComplete`, `Command::ListingFailed`; add `Effect::CancelRequest(u64)` with a doc comment naming the three cancellable request kinds (design D1, D3) +- [x] 1.2 `panel.rs`: add `PanelState::listing_request: Option` and `TabData::listing_request`, carried by `to_tab_data`/`adopt_tab_data` beside `git_request` (design D1) +- [x] 1.3 `update.rs`: mint the id with `State::next_request_id` in `State::initial` (both panels) and in `begin_listing_inner`, storing it in `listing_request` after `begin_new_listing` (design D1) +- [x] 1.4 `update.rs` `begin_listing_inner`: when the panel's previous listing is still `Streaming`, push `Effect::CancelRequest(previous)` before `StartListing` (panel-navigation "Superseded directory listings are discarded and cancelled"; design D4) +- [x] 1.5 `update.rs` `apply_listing_event`: return early, before any mutation (entries, progress, reread anchor, `pending_cursor_target`, selection reconcile), when `request != listing_request` (design D2) +- [x] 1.6 `update.rs` `update()`: clear `state.drag` on `ListingFailed` only when the failure was accepted by 1.5 (design D2) +- [x] 1.7 Update `handle_swap_panels`' doc comment to say the streaming refusal is kept by spec, not because chunks lack an id; behavior unchanged (design D7) + +## 2. Core: tabs + +- [x] 2.1 `panel.rs` `to_tab_data`: set `stale: self.progress.is_streaming()` and update its doc comment (panel-tabs "Switching or closing a tab mid-listing never mixes listings"; design D6) +- [x] 2.2 `update.rs` `Command::SwitchTab`: before switching, if the active tab is `Streaming`, push `Effect::CancelRequest` for its `listing_request` (design D4) +- [x] 2.3 `update.rs` `Command::CloseTab`: if the tab being closed is `Streaming`, push `Effect::CancelRequest` for its `listing_request` (design D4) +- [x] 2.4 Confirm `Command::OpenTab` emits no cancel and the new tab inherits `listing_request` (design D4) +- [x] 2.5 `update.rs` stale-tab activation path (`SwitchTab`/`CloseTab`): call `begin_listing_inner(.., capture_anchor: false)` when the newly active tab's progress is `Streaming`, so an interrupted re-read's stashed `reread_anchor` survives; keep `true` otherwise (panel-tabs "Switching or closing a tab mid-listing never mixes listings", "Stale background tab refresh on activation"; design D6) + +## 3. Core: find-file and viewer cancellation + +- [x] 3.1 `listing.rs`: `find_in_subtree` takes `cancel: &CancelFlag`, checks before each directory read and between entries, and returns early when set (find-file "Dismissed or superseded searches stop"; design D5) +- [x] 3.2 `listing.rs`: `list_dir_chunked` takes `cancel: &CancelFlag` and stops delivering chunks once set (design D5) +- [x] 3.3 `viewer/search.rs`: `find_forward` takes `cancel: &CancelFlag`, checks before each chunk read, returns `None` once set (viewer "Search stops when superseded or when the viewer closes"; design D5) +- [x] 3.4 `update.rs` `handle_find_file`: `FindFileCancel` and `FindFileConfirm` push `Effect::CancelRequest(request)` when a search was submitted and is not `done` (design D4) +- [x] 3.5 `update.rs` `handle_viewer`: `ViewerClose` pushes `Effect::CancelRequest` for `search_request` when set; `ViewerSearchConfirm` pushes it for the previous `search_request` before minting the new one (design D4) + +## 4. TUI: workers and cancel registry (`crates/filecommand-tui`) + +- [x] 4.1 `app.rs`: add a `CancelRegistry` (`register(id) -> CancelFlag`, `cancel(id)`, `retire(id)`) to `Runtime` (design D3) +- [x] 4.2 `app.rs` `run_effects`: register a flag for `StartListing`, `FindInSubtree`, and `RunViewerSearch` and pass it to the worker; execute `CancelRequest(id)` via `cancel` (unknown ids are a no-op) (design D3) +- [x] 4.3 `app.rs` `apply`: `retire` the entry when `ListingComplete`, `ListingFailed`, `FindFileSearchDone`, or `ViewerSearchResult` for that id passes through, before `update` consumes it (design D3) +- [x] 4.4 `worker.rs` `spawn_listing`: take `request` and the flag; echo `request` on every reply; check the flag before the `..` chunk, before each chunk, and before the final reply; send nothing after cancellation (design D5) +- [x] 4.5 `worker.rs` `spawn_find_subtree` and `spawn_viewer_search`: take the flag, pass it to `find_in_subtree`/`find_forward`, and send no `FindFileSearchDone`/`ViewerSearchResult` once cancelled (design D5) + +## 5. Tests + +- [x] 5.1 `update/tests.rs`: add `listing_chunk`/`listing_complete`/`listing_failed` helpers that read the panel's current `listing_request`, and migrate the existing listing-command call sites (design D8) +- [x] 5.2 `update/tests.rs`: enter `C:\big`, Backspace to `C:\` mid-stream, feed a chunk (including `..`) and a `ListingComplete` with the old id → `C:\` entries unchanged, still `Streaming`, `CancelRequest(old)` was emitted (panel-navigation "Backspace out of a slow directory mid-listing", "Superseded completion does not complete the new listing") +- [x] 5.3 `update/tests.rs`: a stale `ListingFailed` sets no `last_error` and does not clear an active drag (panel-navigation "Superseded failure does not mark the new listing failed"; design D2) +- [x] 5.4 `update/tests.rs`: two `RereadPanel`s, chunks from both ids fed → each entry present once; first id cancelled (panel-navigation "Two quick re-reads do not duplicate entries") +- [x] 5.5 `update/tests.rs`: `SwitchTab` mid-stream → `CancelRequest(old)`, stashed tab `stale`, chunk with old id ignored by the new tab; switching back emits a fresh `StartListing` and no `PersistHistory` (panel-tabs "Switching tabs mid-listing", "Returning to an interrupted tab re-reads it") +- [x] 5.5a `update/tests.rs`: on a complete listing with the cursor on `report.txt` and a non-zero offset, `RereadPanel`, then `SwitchTab` away mid-stream and back → the fresh listing's chunks put the cursor back on `report.txt` at the old offset; a tab interrupted mid-*navigation* refreshes from the top; switching to a non-stale, fully listed tab emits no `StartListing` (panel-tabs "An interrupted re-read still keeps the user's place", "Switching to a tab with no pending staleness is unchanged") +- [x] 5.6 `update/tests.rs`: `CloseTab` mid-stream → `CancelRequest(old)`, neighbor untouched by old-id chunks (panel-tabs "Closing a tab mid-listing") +- [x] 5.7 `update/tests.rs`: `OpenTab` mid-stream → no `CancelRequest`, later chunks fill the new tab, original tab stale (panel-tabs "New tab mid-listing keeps the listing") +- [x] 5.8 `update/tests.rs`: `FindFileCancel` and `FindFileConfirm` while running emit `CancelRequest(request)`; after `FindFileSearchDone` they emit none (find-file "Esc stops the walk", "Confirming a result while the walk is running stops it") +- [x] 5.9 `update/tests.rs`: `ViewerClose` with an issued search and a second `ViewerSearchConfirm` emit `CancelRequest` for the earlier id (viewer "Closing the viewer stops a running search", "A newer search supersedes a running one") +- [x] 5.10 `update/tests.rs`: Ctrl+U is still refused while a panel streams (regression for the unchanged in-flight rule) +- [x] 5.11 `listing.rs` tests: `find_in_subtree` with a counting fake reader stops reading directories after the flag is set from inside `on_match`; `list_dir_chunked` with a pre-set flag delivers nothing (find-file "Esc stops the walk"; panel-navigation "Superseded reader stops") +- [x] 5.12 `viewer/search.rs` tests: `find_forward` with a pre-set flag returns `None` without scanning past the first chunk check +- [x] 5.13 `worker.rs` tests: `spawn_listing` and `spawn_find_subtree` given a pre-cancelled flag send nothing on the channel within the timeout; a normal run echoes the request id on every reply +- [x] 5.14 `app.rs` tests: `CancelRegistry` — `cancel` sets the registered flag and removes it, `cancel` of an unknown id is a no-op, `retire` removes without setting +- [x] 5.15 Update `tests/integration_smoke.rs` and `tests/snapshot_views.rs` listing-command call sites; snapshots unchanged + +## 6. Verify + +- [x] 6.1 `cargo test --workspace` green +- [x] 6.2 `cargo clippy --workspace --all-targets` with no new warnings +- [x] 6.3 `openspec validate listing-request-guard --strict` clean +- [ ] 6.4 Manual (Windows): enter a large directory on a network share and press Backspace immediately → parent shows only its own entries; switch tabs mid-listing and back → the tab re-reads; Alt+F7 from `C:\` with pattern `a`, Esc → disk activity stops diff --git a/openspec/changes/archive/2026-10-14-move-reparse-point-safety/.openspec.yaml b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-move-reparse-point-safety/design.md b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/design.md new file mode 100644 index 0000000..e9d58b0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/design.md @@ -0,0 +1,137 @@ +# Design: move-reparse-point-safety + +## Context + +All of the following was verified on `build/open-proposals-2026-10` @ `1bada6f`. + +**How a cross-volume move is planned.** `run_copy_or_move(job, fs, sink, is_move)` (`fs_ops/worker.rs`) sends a move to `run_move_same_volume` only when every source is on the destination's volume. Otherwise it builds a `CopyPlan` with `plan_copy` → `walk_copy`: + +- `walk_copy` takes the non-following `fs.metadata(abs)`. +- For a reparse point it decides `is_dir_like` from `fs.metadata_follow(abs)`. +- A directory-like reparse point is treated like a real directory. Its relative path is pushed to `plan.dirs`, and its children are enumerated through the link with `fs.read_dir(abs)`. +- Every child file becomes a `PlannedFile { src_abs: \, rel_path, size }`. +- A visited set of *followed* `FileId`s stops only cycles that lead back into the tree already being walked. A link pointing anywhere else is traversed. + +**How the move deletes its source.** For each file whose copy returns `CopyOneResult::Done`, `is_move` runs `let _ = fs.set_readonly(&planned.src_abs, false); let _ = fs.remove_file(&planned.src_abs);`. For a file planned through a junction, `src_abs` is `C:\work\proj\shared\lib.dll`, and Windows resolves it to `C:\shared-libs\lib.dll` and deletes that file. The `set_readonly` call also follows the link: `RealFs::set_readonly` uses `std::fs::metadata` plus `set_permissions`, so it clears the target's read-only attribute first. + +After the loop, when the job was not cancelled, the cleanup runs `let _ = fs.remove_dir(&job.source_dir.join(rel_dir))` for every planned directory, deepest first. Planned directories include the target's own subdirectories, reached through the link, so the target's emptied subdirectories are removed too. Finally the junction itself is removed. + +**Contrast with Delete.** `walk_delete` checks `md.reparse.is_reparse_point()` before recursing and emits `DeleteItem::ReparseLeaf { path, is_dir: md.is_dir }`. It never enumerates through a link, and it removes the leaf with `remove_dir` or `remove_file` depending on `is_dir`, without read-only clearing. + +**What `RealFs` reports for a junction (Windows).** A probe compiled with the audited toolchain (`rustc 1.98.1`) against a `mklink /J` junction printed the following: + +| Call | Result | +|---|---| +| `symlink_metadata`, `read_dir` entry metadata | `is_dir=false is_symlink=true attrs=0x410` (DIRECTORY \| REPARSE_POINT) | +| `metadata` (follow) | `is_dir=true` | +| `std::fs::remove_file(junction)` | `Access is denied. (os error 5)` | +| `std::fs::remove_dir(junction)` | OK, the target's files intact | + +The Windows `to_fs_metadata` sets `is_dir: md.is_dir()`. A junction's no-follow `FsMetadata` therefore has `is_dir = false`, and Delete's `ReparseLeaf` calls `remove_file`, which fails. The audit records Delete as CONFORMING because only `FakeFs` tests exercise it, and `FakeFs` models a junction as `Reparse { is_dir: true }`. + +`reparse_kind_from_metadata` also never returns `Junction`: `file_type().is_symlink()` is true for every name-surrogate tag, including mount points. Only `is_reparse_point()` is ever consulted, so this is cosmetic. + +**Why `FakeFs` cannot reproduce R-01 today.** `FakeFs::metadata`, `copy_file_chunked` (through `metadata`) and `read_dir` resolve ancestor reparse points, so the copy half reads through the link as NTFS does. `remove_file`, `remove_dir`, `set_readonly`, `set_modified`, `rename` and `create_dir` only lexically normalize the path. `remove_file("/v1/src/link/f.txt")` returns `NotFound` (ignored by `let _`) instead of deleting `/shared/f.txt`, so a `FakeFs` version of the R-01 scenario passes against the buggy code. + +## Goals / Non-Goals + +**Goals:** + +- A cross-volume move never deletes, empties or changes the attributes of anything reached through a reparse point in the source. Only the link itself is removed, exactly as Delete removes it. +- Keep the existing copy rule: the destination receives the link target's content (user decision). +- Keep the source link while its content copy is incomplete. +- Make reparse-leaf removal work on real NTFS for directory junctions and directory symlinks, for both Delete and Move. +- Regression tests that fail against today's code: one with `FakeFs`, one with `RealFs` and a real junction (Windows only). + +**Non-Goals:** + +- Recreating junctions or symlinks at the destination (user decision). The destination keeps receiving a plain directory, or a plain file for a file symlink. +- Same-volume moves. `rename` moves a link as a link and never traverses it, so it is already safe. One quirk stays out of scope: the same-volume decision follows a *selected* top-level link to its target's volume. A selected junction whose target lives on the destination volume is therefore attempted as a `rename` across volumes, which fails with a recoverable error dialog, not data loss. See Open Questions. +- Copy behavior, the cycle guard, and the plan's byte or file totals. All are unchanged. +- Durability of the copy half (flush, length check, temp-file landing, surfacing of regular-file source-removal failures). These belong to `durable-file-copy`. +- Distinguishing `ReparseKind::Junction` from `Symlink` in `RealFs`. Nothing consumes the distinction. +- Panel listing of links (`link-aware-listing`). + +## Decisions + +### D1: The plan records how each source item is removed + +`walk_copy` threads an `under_link: bool` through its recursion and records a removal disposition on every planned item: + +- `PlannedFile` gains `removal: SourceRemoval`, one of `RemoveFile`, `RemoveLink { is_dir }` or `Keep`: + - A regular file not beneath a traversed link is `RemoveFile`. This is today's behavior: clear read-only, then `remove_file`. + - A file-type reparse point (a file symlink) not beneath a link is `RemoveLink { is_dir: false }`. Its content is copied by following it, as today, and the source half removes only the link. + - Everything planned beneath a traversed directory link is `Keep`. That includes the link target's own files, nested links and nested directories. +- `plan.dirs` becomes `Vec`, where `DirRemoval` is one of `RemoveIfEmpty`, `RemoveLink { is_dir }` or `Keep`: + - A real directory not beneath a link is `RemoveIfEmpty`. This keeps today's best-effort cleanup: a directory left non-empty by a skipped file survives. + - A directory-type reparse point is `RemoveLink`. + - Directories beneath a link are `Keep`. They are still created at the destination, because `rel` is unchanged. +- `src_abs` is recorded during the walk instead of being rebuilt as `job.source_dir.join(rel)`. The link's own path is needed anyway, and this removes the assumption that a source's `original_name` sits directly in `source_dir`. + +*Alternative considered:* skip directory links entirely in move mode, with a SkippedItem "links are not moved across volumes" (the audit's option b). Rejected because the user decided the copy rule stays: a move must still deliver the content, and refusing is a functional regression. + +*Alternative considered:* recreate the link at the destination (the audit's option a). This is a non-goal by user decision. It would also need privilege for symlinks and a `DeviceIoControl` reparse-buffer writer for junctions. + +### D2: A link is removed only after its content copied completely + +During the file loop, every planned file carries the index of the nearest enclosing directory link, if any. If its outcome is anything other than `Done` (conflict skip, error skip), that link is marked incomplete. The cleanup pass, which already runs only when `!cancelled`, then works deepest first: + +- `RemoveIfEmpty` directories get best-effort `remove_dir`, as today. +- `Keep` directories are never touched. +- `RemoveLink` directories are removed only when not marked incomplete. +- A file-type `RemoveLink` is removed right after its own copy succeeds, the same place a `RemoveFile` is removed today. + +This mirrors how a real directory survives a skipped file. The user is never left with the link gone while the destination holds a partial copy. A cancelled move removes no links, consistent with today's "no cleanup on cancel." + +*Alternative considered:* always remove the link once the walk finishes. That is safe for data but loses the link even when the user skipped half its content. Rejected. + +### D3: Link removal reuses Delete's reparse-leaf rule, and a failure is reported + +A small helper `remove_reparse_leaf(fs, path, is_dir)` is factored out of `run_delete`. It calls `remove_dir` for a directory-object link and `remove_file` otherwise, and never clears read-only, because `set_readonly` follows the link and would modify the target. `run_delete` and the move's `RemoveLink` items both use it, so both halves share one rule. + +A link removal that fails (permission denied, sharing violation) is pushed as a `SkippedItem { path: , reason: "copied, but the link could not be removed: …" }`. It then shows in the existing end-of-job summary, unlike the silently ignored `let _` used for directory cleanup. Directory cleanup stays silent because a non-empty directory is an expected outcome there, while a failed link removal is not. This matches `durable-file-copy`'s rule that source-removal failures surface. + +### D4: RealFs classifies a directory link as a directory object (Windows) + +In `windows_impl::to_fs_metadata`, `is_dir` comes from the attribute bits: `attrs & FILE_ATTRIBUTE_DIRECTORY (0x10) != 0`. + +- For non-reparse entries this is identical to `md.is_dir()`. +- For followed metadata (`std::fs::metadata`) it is still the target's directory-ness. +- For the no-follow metadata of a junction or `mklink /D` symlink it becomes `true`. `FsMetadata::is_dir` on a reparse point then means "the link itself is a directory object, removed with `remove_dir`." + +The probe confirmed that `remove_dir` on a junction removes only the link. The portable (non-Windows) implementation keeps `md.is_dir()`, which is false for a Unix symlink, and that is correct there: a Unix symlink to a directory is removed with `unlink` (`remove_file`), and `rmdir` would fail with `ENOTDIR`. + +Other consumers of no-follow `is_dir` are `walk_copy` and the overwrite-conflict removals in `run_move_same_volume`/`run_rename`. `walk_copy` reads it only for non-reparse entries. The overwrite-conflict removals choose `remove_dir` when the conflicting target is a directory junction. Today they would call `remove_file` and ignore the "Access is denied." Both changes are correct. + +*Alternative considered:* query the reparse tag (`FSCTL_GET_REPARSE_POINT` or `FindFirstFileW`'s `dwReserved0`). Rejected because it is more FFI for no benefit: the directory attribute is exactly the bit that decides `RemoveDirectoryW` versus `DeleteFileW`. + +### D5: FakeFs resolves ancestor links in every path-taking operation, as NTFS does + +`remove_file`, `remove_dir`, `set_readonly`, `set_modified`, `rename` (both ends), `create_dir` and the copy's destination all resolve their path with `resolve_ancestors` before touching the node map, matching `metadata`. The final component is left as is, so removing a link path removes the link node. `remove_file` on a `Dir` node or a `Reparse { is_dir: true }` node fails with `PermissionDenied`, matching `DeleteFileW` on a directory object. + +With these two changes the `FakeFs` regression test deletes `/shared/f.txt` under today's code, so the test fails before the fix and passes after it. Existing `FakeFs` tests place no real nodes beneath links (other than the reparse tests, whose expectations hold), so they are unaffected. + +### D6: RealFs coverage uses a real junction and a "cross-volume" wrapper + +A single development machine or CI runner usually has one volume, which would route a `RealFs` move through `rename`. The Windows test therefore wraps `RealFs` in a test-local `CrossVolume` `FileSystem` implementation that delegates every call and answers `same_volume = false`. This drives the real copy-then-delete path on one volume. + +The fixture builds `src\proj\file.txt`, `outside\keep.txt` and `src\proj\shared` = `mklink /J` → `outside`. Junctions need no privilege, unlike symlinks. The test then runs a Move of `proj` to `dst` through `run_job` and asserts: + +- `outside\keep.txt` still exists with its content. +- `dst\proj\shared\keep.txt` exists. +- `src\proj` is gone. + +A second test deletes a junction through `run_job` and asserts that the link is gone and the target is intact. This covers D4. + +The harness lives in `crates/filecommand-core/tests/realfs.rs`, the shared `RealFs` suite that `durable-file-copy` fills out (`copy-onto-itself-guard` may create it first). If this change is the first to land, it creates that file with only the helpers it needs: a temp-dir guard, a `mklink /J` helper invoked through `cmd /C mklink /J`, and `CrossVolume`. + +## Risks / Trade-offs + +- [Behavior change for links inside a moved tree] The link's target content is still *copied*, but its originals now stay in place. A user who expected "move" to relocate the target's files will find them still at the target. → This is the intended, documented semantics (spec scenario), the same as Delete's leaf rule, and the only safe one when the target lies outside the selection. +- [Link kept on partial copy] A move with skipped files under a link leaves the link and the moved remainder. → The skipped items are listed in the summary, and the user can retry. This matches how real directories behave today. +- [D4 widens `is_dir = true` to directory links in no-follow metadata] Any future caller that treats no-follow `is_dir` as "recurse into" would traverse links. → `walk_delete` and `walk_copy` check `reparse` first. The field's meaning on reparse points is documented on `FsMetadata`. +- [FakeFs fidelity changes could break tests that relied on lexical-only mutation] → Run the full `fs_ops` suite. Only the reparse-point tests touch nodes beneath links. + +## Open Questions + +- Should the same-volume test for a *selected* top-level link use the link's own volume (no-follow) instead of its target's? Today a selected junction whose target is on the destination volume is attempted as a cross-volume `rename` and fails with a recoverable error. This is outside R-01, and a candidate for a follow-up. diff --git a/openspec/changes/archive/2026-10-14-move-reparse-point-safety/proposal.md b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/proposal.md new file mode 100644 index 0000000..da7235e --- /dev/null +++ b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/proposal.md @@ -0,0 +1,49 @@ +# Change: move-reparse-point-safety + +## Why + +Audit finding **R-01 (Critical)**: a cross-volume Move deletes files the user never selected. When the moved tree contains a junction or directory symlink, `walk_copy` follows the link and plans every file beneath it with a source path *through* the link (`src\link\file`). After each file is copied, the move half calls `remove_file` on that path, and the OS resolves it through the junction and deletes the real file in the link's target. The empty-directory cleanup then removes the target's subdirectories the same way. Moving `C:\work\proj` to `D:\backup` when `proj\shared` is a junction to `C:\shared-libs` empties `C:\shared-libs`. Delete already treats reparse points as leaves (`walk_delete`); the move's delete half does not. No test covers this, and the in-memory `FakeFs` cannot reproduce it, because its `remove_file`/`remove_dir` do not resolve a path through an ancestor link the way NTFS does. + +A related defect sits on the same path: on Windows, `RealFs` reports a junction's own (non-followed) metadata with `is_dir = false`, because `std::fs::Metadata::is_dir()` is false for every name-surrogate reparse point. The reparse-leaf removal therefore calls `remove_file` on a directory junction, which Windows refuses with "Access is denied" (verified with a `mklink /J` probe). Today that makes Delete of a junction fail with an error dialog. It would make the fixed move fail the same way. + +## What Changes + +- A cross-volume Move treats every reparse point in the source as a leaf for its source-removal half. The copy half is unchanged: the destination still receives a plain copy of the link target's content, per the existing copy rule. The source half removes only the link itself, exactly as Delete does. It never deletes, empties, or changes the attributes of anything reached through the link. +- A link is removed only after everything copied through it succeeded. If any file beneath it was skipped or failed, the link stays in place, the same way a real directory survives when it is left non-empty. A link that cannot be removed is reported in the end-of-job skipped-files summary instead of being ignored. +- On Windows, the `RealFs` no-follow metadata classifies a directory junction or directory symlink as a directory object, using the link's own `FILE_ATTRIBUTE_DIRECTORY` bit. Delete and the move's link removal then use `remove_dir`, which removes the link and leaves the target intact. This also fixes Delete of a junction, which currently fails with "Access is denied." +- `FakeFs` resolves ancestor reparse points in its mutating operations the way NTFS does, and refuses `remove_file` on directory objects. The regression test therefore fails against today's code. +- New regression tests: a `FakeFs` cross-volume move of a tree containing a junction to an outside directory, and a `#[cfg(windows)]` `RealFs` test with a `mklink /J` fixture. +- Non-breaking. Recreating links at the destination is out of scope: the destination still receives the target's content. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `file-operations`: "Reparse-point (symlink/junction) semantics" adds the rule that a cross-volume move's source-removal half treats every reparse point as a leaf. It removes the link, never anything reached through it, and only after the content copied through the link succeeded. New scenarios cover a tree containing a junction to an outside directory, a selected junction, and a link whose content copy was incomplete. + +## Impact + +- **Specs:** `file-operations` (one modified requirement). +- **Code:** + - `crates/filecommand-core/src/fs_ops/worker.rs`: + - `PlannedFile`/`CopyPlan`: each planned file and directory records how its source is removed. + - `walk_copy` (lines 208-257): marks content reached through a link as "keep" and the link itself as "remove link." + - `run_copy_or_move` (lines 324-439): the `is_move` `set_readonly` and `remove_file` calls at 412-415 apply only to removable files. The empty-directory cleanup at 430-436 skips kept directories and removes links with the reparse-leaf rule. + - A shared reparse-leaf removal helper factored out of `run_delete` (`DeleteItem::ReparseLeaf`, lines 93-110 and 146-162). + - `crates/filecommand-core/src/fs_ops/fs.rs`: + - Windows `to_fs_metadata` (lines 149-158) takes `is_dir` from `FILE_ATTRIBUTE_DIRECTORY`. + - `FakeFs` `remove_file`, `remove_dir`, `set_readonly`, `set_modified`, `rename` and `create_dir` resolve ancestor links via `resolve_ancestors`. `FakeFs::remove_file` rejects directory nodes. + - Tests in `fs_ops/worker.rs` and `fs_ops/fs.rs`, plus a Windows-only `RealFs` junction test in `crates/filecommand-core/tests/realfs.rs`. + - No `filecommand-tui` changes. +- **Compatibility:** the only observable changes are that a cross-volume move no longer deletes through links, and that Delete of a junction on Windows now succeeds instead of raising "Access is denied." + +**Audit findings:** R-01 + +**Coordination:** +- `durable-file-copy` edits the same `run_copy_or_move` copy/move loop and `fs.rs` `RealFs`/`FakeFs`, and creates the shared `RealFs` integration-test harness (`crates/filecommand-core/tests/realfs.rs`). Its source-removal error surfacing applies to removable regular files; this change's link removal follows the same "report, don't ignore" rule. Whichever change lands second rebases onto the other. If this change lands first, it adds its own minimal harness (temp-dir helper, `mklink /J` helper, cross-volume `RealFs` wrapper) in that file, and `durable-file-copy` extends it. +- `copy-onto-itself-guard` adds a same-file skip to the same `run_copy_or_move` file loop and may create `realfs.rs` first. A same-file skip beneath a link counts as "not copied", so the link is kept (D2). Whichever change lands second rebases. +- `link-aware-listing` adds an internal link flag to panel `Entry` values. It is independent: `fs_ops` classifies reparse points from its own metadata and does not read the panel flag. diff --git a/openspec/changes/archive/2026-10-14-move-reparse-point-safety/specs/file-operations/spec.md b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/specs/file-operations/spec.md new file mode 100644 index 0000000..bd75ecd --- /dev/null +++ b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/specs/file-operations/spec.md @@ -0,0 +1,34 @@ +## MODIFIED Requirements + +### Requirement: Reparse-point (symlink/junction) semantics + +Reparse points SHALL be handled distinctly from ordinary directories: deleting a reparse point SHALL remove the link itself and never the target's contents; copying a reparse point SHALL copy the link target's content by default. Recursive operations SHALL carry recursion-cycle protection via a visited file-identity set and SHALL NOT traverse into a junction that points inside the source tree being processed. + +A move performed as copy-then-delete (a cross-volume move) SHALL treat every reparse point in the source — a selected link or one anywhere inside a selected directory tree — as a leaf for its source-removal half. The copy half copies the link target's content to the destination per the copy rule above. The source-removal half SHALL remove only the link itself, exactly as delete does, and SHALL NOT delete, empty, or change the attributes of any file or directory reached through the link. A link SHALL be removed only after every item copied through it was copied successfully. If any item beneath it was skipped or failed, the link SHALL be left in place. A link that cannot be removed SHALL be reported in the end-of-job skipped-files summary rather than ignored. + +#### Scenario: Delete removes the link, not the target +- **WHEN** a job deletes a junction or symlink +- **THEN** the link is removed and the files under the link's target directory remain untouched + +#### Scenario: Copy duplicates the link target's content +- **WHEN** a job copies a reparse point +- **THEN** the destination receives a copy of the target's content (not merely a re-created link) by default + +#### Scenario: Recursion cycle is not re-entered +- **WHEN** a recursive copy encounters a junction pointing to a directory already inside the source tree (present in the visited-identity set) +- **THEN** the job does not traverse into it, avoiding an infinite recursion cycle + +#### Scenario: Cross-volume move of a tree containing a junction to an outside directory leaves that directory's files intact +- **WHEN** `C:\work\proj` contains `file.txt` and a junction `shared` pointing to `C:\shared-libs` (which holds `lib.dll` and a subdirectory `x86`), and the user moves `proj` to `D:\backup` +- **THEN** `D:\backup\proj\file.txt`, `D:\backup\proj\shared\lib.dll`, and `D:\backup\proj\shared\x86` exist at the destination as copies of the content +- **AND** `C:\shared-libs`, `C:\shared-libs\lib.dll`, and `C:\shared-libs\x86` still exist with their original content and attributes +- **AND** the source `C:\work\proj`, including the junction `shared` itself, is removed + +#### Scenario: Cross-volume move of a selected junction removes only the link +- **WHEN** the user moves a selected junction `C:\work\link` (pointing to `C:\data`) to `D:\backup` +- **THEN** `D:\backup\link` receives a copy of the content of `C:\data` +- **AND** the junction `C:\work\link` is removed while `C:\data` and every file in it remain untouched + +#### Scenario: A link whose content was not fully copied is kept +- **WHEN** a cross-volume move copies through a junction and one file beneath it is skipped (by an overwrite-conflict Skip or an error-recovery Skip) +- **THEN** the junction is left in place in the source, the link target's files are untouched, and the skipped file is listed in the end-of-job skipped-files summary diff --git a/openspec/changes/archive/2026-10-14-move-reparse-point-safety/tasks.md b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/tasks.md new file mode 100644 index 0000000..a6f75d0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-move-reparse-point-safety/tasks.md @@ -0,0 +1,41 @@ +# Tasks: move-reparse-point-safety + +## 1. FakeFs fidelity (`crates/filecommand-core/src/fs_ops/fs.rs`) + +- [x] 1.1 Route the `FakeFs` path-taking mutations — `remove_file`, `remove_dir`, `set_readonly`, `set_modified`, `rename` (both ends), `create_dir`, and the copy's destination node — through `resolve_ancestors`, as `metadata` already does, so an ancestor link is traversed like NTFS (design D5) +- [x] 1.2 Make `FakeFs::remove_file` fail with `PermissionDenied` on a `Dir` node or a `Reparse { is_dir: true }` node, matching `DeleteFileW` on a directory object (design D5) +- [x] 1.3 Unit tests in `fs.rs`: `remove_file` through an ancestor junction removes the target-side node; `remove_dir` on a junction path removes only the link node; `remove_file` on a directory junction is refused +- [x] 1.4 Write the R-01 regression test first, in `worker.rs` tests, and confirm it FAILS against the unmodified worker. The setup: volumes `/v1` and `/v2`; `/v1/work/proj/file.txt`; `/v1/shared/lib.dll` and `/v1/shared/x86/a.dll`; junction `/v1/work/proj/shared` → `/v1/shared`. Move `proj` to `/v2/backup`. Assert that `/v1/shared/lib.dll` and `/v1/shared/x86/a.dll` survive, that `/v2/backup/proj/shared/lib.dll` exists, and that `/v1/work/proj` is gone (file-operations: "Cross-volume move of a tree containing a junction to an outside directory leaves that directory's files intact") + +## 2. RealFs directory-link classification (`fs_ops/fs.rs`, Windows) + +- [x] 2.1 In `windows_impl::to_fs_metadata`, derive `is_dir` from `FILE_ATTRIBUTE_DIRECTORY` (0x10) in `file_attributes()` instead of `md.is_dir()`. Document on `FsMetadata::is_dir` that for a non-followed reparse point it means "the link itself is a directory object" (design D4) +- [x] 2.2 Leave `portable_impl::to_fs_metadata` unchanged: a Unix symlink is removed with `remove_file`. Note this in a comment (design D4) + +## 3. Move plan and source removal (`crates/filecommand-core/src/fs_ops/worker.rs`) + +- [x] 3.1 Add `SourceRemoval { RemoveFile, RemoveLink { is_dir }, Keep }` to `PlannedFile`, and replace `CopyPlan::dirs: Vec` with `Vec` (design D1) +- [x] 3.2 Thread `under_link: bool` (plus the index of the enclosing link) through `walk_copy`. A traversed directory-type reparse point becomes `DirRemoval::RemoveLink` and everything beneath it becomes `Keep`. A file-type reparse point outside a link becomes `SourceRemoval::RemoveLink { is_dir: false }`. Copy totals and the cycle guard are unchanged (design D1) +- [x] 3.3 Factor `remove_reparse_leaf(fs, path, is_dir)` out of `run_delete`: `remove_dir` when `is_dir`, otherwise `remove_file`, never clearing read-only. Use it from `run_delete` (design D3) +- [x] 3.4 In `run_copy_or_move`'s `is_move` branch, act on `removal`. `RemoveFile` keeps today's read-only clear plus `remove_file`; the surfacing of its failures is `durable-file-copy`'s concern. `RemoveLink` calls `remove_reparse_leaf`. `Keep` does nothing (design D1, D3) +- [x] 3.5 Track, per directory link, whether any file beneath it ended in anything other than `Done`, and mark that link incomplete (design D2) +- [x] 3.6 Rewrite the post-loop cleanup over `PlannedDir`, deepest first, using each entry's recorded `src_abs` rather than `job.source_dir.join(rel)`. `RemoveIfEmpty` gets best-effort `remove_dir` as today. `Keep` is skipped. `RemoveLink` is removed with `remove_reparse_leaf` only when complete. A failed link removal is pushed as a `SkippedItem` reading "copied, but the link could not be removed: …" (design D2, D3; file-operations: "A link whose content was not fully copied is kept") + +## 4. Tests + +- [x] 4.1 Confirm 1.4 now passes +- [x] 4.2 `worker.rs`: cross-volume move of a selected junction removes only the link and copies the target's content (file-operations: "Cross-volume move of a selected junction removes only the link") +- [x] 4.3 `worker.rs`: cross-volume move through a junction with an injected copy failure on one file beneath it and an error-recovery Skip. The junction survives, the target is intact, and the summary lists the skipped file (file-operations: "A link whose content was not fully copied is kept") +- [x] 4.4 `worker.rs`: cross-volume move of a file symlink copies the target's content, removes only the link, and leaves the target file and its read-only attribute untouched (design D3) +- [x] 4.5 `worker.rs`: an injected `RemoveDir` failure on the link during cleanup is reported as a skipped item (design D3) +- [x] 4.6 `worker.rs`: the existing `reparse_point_delete_removes_link_not_target`, `reparse_point_copy_duplicates_target_content`, `reparse_point_cycle_is_skipped_not_infinite` and `move_cross_volume_*` tests still pass +- [x] 4.7 `crates/filecommand-core/tests/realfs.rs` (`#[cfg(windows)]`): use the shared harness, or create the minimal one if `durable-file-copy` has not landed: a unique temp-dir guard under `std::env::temp_dir()`, a `cmd /C mklink /J` helper, and a `CrossVolume` wrapper that delegates everything and answers `same_volume = false` (design D6) +- [x] 4.8 `realfs.rs` (`#[cfg(windows)]`): moving `proj`, which contains a junction to an outside directory, through `run_job` with `CrossVolume` leaves the outside directory's files intact, copies them under `dst\proj\shared`, and removes `src\proj` including the junction (design D6) +- [x] 4.9 `realfs.rs` (`#[cfg(windows)]`): Delete of a junction through `run_job` with `RealFs` completes with no error dialog, removes the link and leaves the target intact (design D4; file-operations: "Delete removes the link, not the target") + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green, on Windows so the `#[cfg(windows)]` `RealFs` tests run +- [x] 5.2 `cargo clippy --workspace --all-targets` introduces no new warnings +- [x] 5.3 `openspec validate move-reparse-point-safety --strict` clean +- [ ] 5.4 Manual (Windows, two volumes, e.g. a USB stick): create `C:\tmp\proj\shared` with `mklink /J` pointing to a scratch directory holding a few files. F6 `proj` to the other volume. Confirm the scratch directory's files are intact and the destination holds copies. Then F8 a junction and confirm it is removed without an error dialog and its target is intact diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/.openspec.yaml b/openspec/changes/archive/2026-10-14-offload-blocking-io/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/design.md b/openspec/changes/archive/2026-10-14-offload-blocking-io/design.md new file mode 100644 index 0000000..c21a9e7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/design.md @@ -0,0 +1,160 @@ +# Design: offload-blocking-io + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`). + +- **The reducer queries the filesystem.** `probe_command_line_target(state, side, path)` (`update.rs:1929-1941`) first looks for `path` among the active panel's listed entries when `path` is a direct child of the panel's `cwd`. If it is not there, it calls `std::fs::metadata(path).ok()?` and returns `Some((is_dir, name))`, or `None` for any error. + - It is the only filesystem call reachable from `core::update`. A search of `update.rs` finds no other `std::fs`, `metadata`, `read_dir`, or `canonicalize`. `config.rs` does do I/O, but only the TUI calls it, from `run_effects`. + - `dispatch_cd` (`:1848-1872`) navigates on `Some((true, _))`, after pushing the line to history and emitting `PersistHistory`. It reports "`` is not a directory" on `Some((false, _))` and "`` not found" on `None`. + - `dispatch_delete_builtin` (`:1884-1919`) rejects `.` and `..` before any lookup, then opens `enter_delete_confirm_for_sources` on a type match, or reports "is a directory", "is not a directory", or "not found". + - In both, `run_command_line` has already cleared the command line (`:1816`). + - The `metadata` call does not go through `fs_ops::path::to_extended_path`, so a target longer than `MAX_PATH` fails and is reported as not found. +- **`run_effects` opens, saves, and launches on the event-loop thread** (`app.rs:843`): + - `Effect::OpenViewer` (`:926-939`) runs `ByteSource::open`, which is `File::open`, then `metadata`, then `Mmap::map`. On success it stores the source in `rt.viewer_source` and sends `ViewerOpened { path, file_len }`. On failure it sends `ViewerOpenFailed { message }`. + - `Effect::OpenEditor` (`:967-982`) runs `EditorState::open`, a whole-file read capped at 10 MB, and replies `EditorOpened`, `EditorTooLarge`, or `EditorOpenFailed`. + - `Effect::SaveEditor { editor, then_quit }` (`:985-995`) runs `editor.save()` on the boxed clone and replies `EditorSaved { editor, then_quit }` or `EditorSaveFailed`. `handle_editor` replaces its whole `EditorState` with the saved clone on `EditorSaved` (`update.rs:2422-2430`). Any edit made between dispatch and reply would therefore be lost if the save were simply moved to a worker. + - `Effect::OpenWithAssociation { path, cwd }` (`:878-886`) calls `open_with_association`, which runs `ShellExecuteW` (`:1087`) with no `CoInitializeEx` anywhere in the workspace. On failure it sends `OpenWithAssociationFailed { message }`, which core applies to `state.active`'s `last_error` (`update.rs:1659`). +- **Configuration writes run inline too.** `Effect::PersistTheme(name)` (`app.rs:900-907`, emitted when the theme picker applies a theme, `update.rs:3739`) and `Effect::PersistPanelSplit(percent)` (`app.rs:908-915`, emitted by every split adjust or reset, `update.rs:1666`, `:2047`) call `config::save_theme_atomic` and `config::save_panel_split_atomic` directly in `run_effects`, guarded by `rt.persist_enabled` and with the result discarded. Each is a read-modify-write of the whole `config.toml`: read the file, replace or append one key's line, write the fixed-name `config.toml.tmp`, and rename it over the target (`config.rs:399-415`, `:458-474`). Holding Ctrl+Left issues one such rewrite per step. These are the only `Persist*` effects besides `PersistHistory`. +- **Existing worker pattern.** `worker.rs` spawns one `std::thread` per request and replies with a `Command` over `rt.tx`. Stale replies are filtered in core by a request id minted from `State::next_request_id` (`update.rs:411-414`), for example `ViewerSearchResult { request }` and `InfoResolved { request }`. +- **`ByteSource` is `Send`.** It holds either a `memmap2::Mmap` or a `File` (`viewer/byte_source.rs:17-26`), so it can be created on a worker and moved to the UI thread. `spawn_viewer_search` already reopens files off-thread (`worker.rs:133-145`). +- **Not cited in the audit.** Quick View mode opens and maps the opposite panel's cursor file inside rendering, on every frame (`views/panel.rs:882-883`, `render_quick_view_body`). That is the same class of blocking I/O. It is recorded as a non-goal below. + +## Goals / Non-Goals + +**Goals:** + +- `core::update` performs no I/O, and a unit test can demonstrate it. +- `cd`, `del`, `rmdir`, F3, F4, F2-in-editor, Open, and the theme and panel-split saves never block input or redraw, whatever the storage. +- Configuration writes stay ordered, keep the last value per setting, and are on disk before a normal exit (within a bound). +- Results that a newer action has superseded never land. +- Command-line targets longer than 260 characters work. +- COM is initialized for the shell launch. + +**Non-Goals:** + +- Moving `PersistHistory` (`history.json`) off the UI thread. That is `history-persistence-hardening`, which adds its own background history writer with debounce and a bounded final flush. Every other `Persist*` effect (today `PersistTheme` and `PersistPanelSplit`) is in scope here (D9). +- How a configuration write is made durable (temporary file, `sync_all`, replace) and how its rewrite keeps keys in the top-level table. Those are `config-parsing-robustness`'s; D9 only changes which thread calls the save functions. +- Per-frame byte reads of an open viewer (`read_range` during render, and the backward and forward scans in `resolve_viewer_navigation`). These are bounded per frame and inherent to the viewer's design. Its backend is `viewer-mapping-safety`'s. +- Quick View's per-frame `ByteSource::open` (`views/panel.rs:883`). This is outside the audit's R-09/P-09 scope and is flagged as a follow-up proposal (cache the preview source per cursor entry and open it on a worker). +- `reload_user_menu` (a local profile-directory read), `EnumerateDrives` (a `GetLogicalDrives` bitmask read), and clipboard effects. +- `RunShellCommand`/`RunExternalEditor`, which are deliberately synchronous suspended runs. +- Cancelling an outstanding probe, open, or save. A worker stuck on an unreachable server finishes when the redirector times out, and its reply is then discarded or applied by the rules below. +- Changing editor encoding or atomic-save semantics. That is `editor-encoding-safety`, which lands first. + +## Decisions + +### D1: Command-line probe becomes an effect and reply pair + +- `probe_command_line_target` keeps only its listed-entry lookup and becomes the fast path. Its `std::fs::metadata` fallback is replaced by a request: + - `Effect::ProbePath { request: u64, path: PathBuf }`. + - `Command::PathProbed { request: u64, kind: ProbeKind }`, where `ProbeKind` is `Dir`, `File`, or `Missing`. +- Core keeps `State::command_probe: Option`. The intent is either `Cd { text }` (for the history push on success) or `Delete { want_dir }`. +- On `PathProbed`, core applies the reply only when all of these hold: + - `request` matches; + - `state.panel(side).cwd` still equals the recorded `cwd`; + - for `Delete`, the phase is `Panels` and no modal field is set. These are the fields `drag_allowed` checks today, plus `quit_confirm`. + + The reply then goes through the same code paths and messages as today's synchronous outcome. In every case the pending entry and the panel's pending note (D3) are cleared. +- A new `cd`, `del`, or `rmdir` replaces `command_probe`, which supersedes the old request. Any navigation of that panel invalidates it through the `cwd` check. + +*Alternative considered:* start the listing optimistically for `cd` and let `ListingFailed` produce the "not found" message, as the audit suggests. This was rejected. The command-line spec requires that a nonexistent target leaves `cwd` unchanged and starts no listing read. `del` and `rmdir` need the entry type before any dialog opens, so the probe is needed anyway. + +*Alternative considered:* a timeout on the probe. This was rejected. Nothing blocks while it is outstanding, and a newer command or navigation supersedes it. + +### D2: Probe semantics on the worker + +`worker::spawn_path_probe` runs `std::fs::metadata(fs_ops::path::to_extended_path(&path))` and maps the result: + +- `Ok(md)` with `md.is_dir()` gives `Dir`. +- `Ok(_)` gives `File`. +- Any `Err` gives `Missing`, which matches today's `.ok()?`. + +Like today's call, it follows links, so a junction to a directory probes as `Dir`. `to_extended_path` adds `\\?\` or `\\?\UNC\` and normalizes `.` and `..` lexically, as every `fs_ops` call does. The on-disk name for the delete dialog stays `path.file_name()`, as today. + +### D3: Pending note on the mini-status line + +`PanelState` gains `pending_note: Option`. + +- It holds `Checking …` while a probe is outstanding and `Opening …` while an open is outstanding (D4). +- Setting it clears that panel's `last_error`, since a new command supersedes an old error. +- It is cleared when the reply is applied or discarded, and when the panel starts a new listing. +- The mini-status shows it in the normal ministatus style, ranked below the existing `status_override` (clipboard feedback, Tree path, Quick View) and above `last_error`. Showing it this way changes no other state. + +### D4: Editor and viewer opens on workers, tagged with a request id + +- `Effect::OpenViewer` and `Effect::OpenEditor` gain `request: u64`. Core records `State::pending_open: Option` and sets the panel's pending note. +- `ViewerOpened`, `ViewerOpenFailed`, `EditorOpened`, `EditorTooLarge`, and `EditorOpenFailed` echo `request`, as does `EditorNotEditable { path, reason }`, the redirect reply `editor-encoding-safety` adds for non-UTF-8 files. `EditorNotEditable` follows the `EditorTooLarge` path below. +- Core applies a reply only when all of these hold: + - `request == pending_open.request`; + - the phase is `Panels`; + - no modal field is set. + + Otherwise it clears the pending state and discards the reply. Failure messages land on the recorded `side`, not on whichever panel is active when the reply arrives. The `EditorTooLarge` path then opens the viewer by minting a fresh request for the same `side`. +- **Viewer hand-off.** `Command` lives in core and derives `Clone`/`PartialEq`/`Eq`, so it cannot carry the source. `Runtime` therefore owns a dedicated `mpsc` pair, `viewer_handoff: (Sender<(u64, PathBuf, ByteSource)>, Receiver<…>)`. + - `worker::spawn_viewer_open` sends the source on that channel first, and only then sends `Command::ViewerOpened { request, path, file_len }`. That ordering guarantees the source is waiting when the command is applied. + - In `apply`, after `update` processes `ViewerOpened`, the runtime drains the hand-off channel. It stores the entry whose `request` matches into `rt.viewer_source` if the phase became `Viewer`, and drops every other entry, closing its handle. + +*Alternative considered:* `Arc>>` shared with the worker. This was rejected because a channel keeps the ordering explicit and needs no lock on the render path. + +### D5: Viewer open moves off the UI thread too + +`ByteSource::open` is O(1) in work but not in latency. `File::open` and `Mmap::map` are separate round trips to an SMB server. If the session dropped after the panel was listed, `CreateFileW` blocks for the redirector timeout, the same 20–60 s failure R-09 describes. + +*Alternative considered:* keep the open synchronous because the panel was just listed. This was rejected: a listing a few seconds old does not prove the server still answers, and removable or optical media can stall on the open as well. The hand-off (D4) is the only cost, and `spawn_viewer_search` already shows the open is thread-agnostic. "Instant open" (no content scan, first frame reads only the visible window) is unaffected. + +### D6: Editor save in flight blocks edits + +- `EditorState` gains `save_pending: bool`, set when `EditorSave` or `EditorConfirmQuitSave` emits `Effect::SaveEditor`. +- While it is set, `handle_editor` ignores every command except `EditorSaved` and `EditorSaveFailed`, and the editor shows a one-row `Saving…` notice on the last body row, directly above the F-key bar. That is the row `editor-encoding-safety` uses for its `Save failed: …` overlay (its D6), so the two share one row: while a retry is in flight, `Saving…` covers an earlier failure message (`save_error` itself is left set), and the reply then either clears it (`EditorSaved`) or replaces it with the new failure (`EditorSaveFailed`). The header and F-key bar stay exactly as builtin-editor "Full-screen editor chrome" specifies; putting the notice in the header would contradict that requirement's fixed header layout. +- `EditorSaved` restores the saved state, as today, and clears the flag. With `then_quit`, the editor closes. +- `EditorSaveFailed` clears the flag and sets `save_error`, as today. Today that message is never rendered; `editor-encoding-safety`, which lands first, renders it on the row above the F-key bar. +- The save itself runs in `worker::spawn_editor_save` on the boxed clone that `Effect::SaveEditor` already carries. + +*Alternative considered:* let editing continue during the save, using a buffer revision counter, and mark the buffer clean only if it is unchanged since dispatch. This was rejected for now. It interacts with `editor-encoding-safety`'s rewritten save, and a local save completes within a frame, so the gate is only noticeable on slow media, where the user is waiting for the save anyway. It can be revisited later without changing this requirement's observable rule. + +### D7: Shell launch worker with COM initialized + +- `Effect::OpenWithAssociation` gains `side: PanelSide`, and `OpenWithAssociationFailed` gains `side`, so a late failure lands on the panel the Open action was invoked on. +- `worker::spawn_open_with_association` runs on its own thread. On Windows it: + - calls `CoInitializeEx(null, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)`, the combination the `ShellExecute` documentation recommends; + - calls the existing `open_with_association`; + - calls `CoUninitialize` if initialization returned `S_OK` or `S_FALSE`. +- On other platforms the detached `xdg-open`/`open` spawn runs on the same kind of worker, for uniformity. +- No pending note is shown. The `file-action-menu` contract keeps Open fire-and-forget, and this change does not modify that capability. +- `windows-sys` gains the `Win32_System_Com` feature. + +### D8: Request ids and worker robustness + +All new requests mint ids through `State::next_request_id`, the same counter that `listing-request-guard` uses for listings. A worker that panics would leave a pending note forever. `process-lifecycle-hardening` wraps worker bodies in `catch_unwind` and sends a failure reply. For the new workers that reply is `PathProbed { kind: Missing }`, `ViewerOpenFailed`, `EditorOpenFailed`, or `EditorSaveFailed`, each with the request id. Whichever change lands second applies the wrapper to these workers. + +### D9: One background writer for configuration writes + +- `Runtime` gains a `ConfigWriter`, spawned in `run` only when `persist_enabled` is true; otherwise `submit` is a no-op, exactly as today's guard skips the write. It owns one thread and a mailbox of pending writes keyed by setting (`Theme`, `PanelSplit`, and any future non-history `Persist*` effect). +- `run_effects` maps `PersistTheme(name)` and `PersistPanelSplit(percent)` to `rt.config_writer.submit(..)` and returns at once. No filesystem call remains in those arms. +- **Last write wins, in order.** `submit` stores the value under its key, replacing any value for that key the thread has not taken yet, and records the key's position in a FIFO of dirty keys if it is not already queued. The thread takes one key at a time and calls that key's save function with the newest value. So ten quick split steps produce at most a couple of writes, the final value is always the last one written for that key, and the panel-split spec's "rapid repeated adjustments MAY coalesce writes, but the final value SHALL be persisted" holds. +- **One thread, never concurrent.** Both saves are read-modify-write of the same `config.toml`. Running them on separate threads could let one rename overwrite the other's freshly written key. A single writer applies them strictly one after another, and each re-reads the file, so a theme change and a split change made close together both end up on disk. +- **Durability is unchanged.** The thread calls the same `save_theme_atomic`/`save_panel_split_atomic`, which `config-parsing-robustness` routes through the shared durable-replace helper. A failure is logged through `error-diagnostics`' `diag::log` when that change has landed and is otherwise dropped, as today; the in-memory setting stays in effect. +- **Flush before exit.** `ConfigWriter` implements `Drop`: it sends `Flush`, the thread writes every pending key, and the drop joins it with a bounded wait of about 2 seconds. `Runtime` owns the writer, so every exit from `run` flushes, including the early `?` returns and unwinding. A profile directory that stops answering cannot hold the process open, and the durable helper guarantees that an abandoned write leaves `config.toml` either complete-old or complete-new. + +*Alternative considered:* a thread per write, like the open workers. This was rejected because concurrent read-modify-writes of one file lose updates, and nothing would order a theme write against a later split write. + +*Alternative considered:* one shared writer for `history.json` and `config.toml`. This was not chosen here because `history-persistence-hardening` designs its writer around a debounce and a cross-instance merge that configuration writes do not need. The two threads write different files, so they are independent; whichever change lands second may merge them. + +## Risks / Trade-offs + +- [Worker threads stuck on an unreachable server accumulate, one per attempt.] → Each attempt is a deliberate user action, and each thread ends when the SMB redirector times out. The same exposure already exists for the git, Info, and drive-label workers. +- [Reducer tests that relied on the synchronous fallback change.] → `cd_to_a_nonexistent_directory_is_rejected_without_navigating` and similar tests now assert a `ProbePath` effect, then feed `PathProbed { kind: Missing }` and assert the same rejection. The end state is unchanged. +- [The F2 gate makes the editor ignore keys for the length of the save.] → The gate is invisible on local disks. On slow media the `Saving…` row says why, and the save is already what the user is waiting on. +- [The hand-off channel can keep a source whose `ViewerOpened` was discarded.] → `apply` drains the channel on every `ViewerOpened` and drops non-matching entries. Every source is sent immediately before its own command, so nothing can linger past that command. +- [A theme or split change made in the last moments before quit could be lost if the writer is still blocked when the 2 s bound expires.] → Only on a profile directory that has stopped answering; the file is then left complete (old or new), never truncated, and the in-memory value was in effect for the session. On a responsive disk the flush completes well within the bound. +- [Quit can now wait on several bounded flushes: the job wait from `process-lifecycle-hardening`, the history writer, and this writer.] → Each is bounded and they only wait when work is outstanding. Signalling every writer to flush before joining any of them lets the waits overlap; whichever change lands second arranges the drop order so. +- [A new `side` field on `OpenWithAssociationFailed` touches code from the in-flight `open-with-associated-app` change.] → The observable rule, "the panel the menu acted on", is the one that change already states, so no `file-action-menu` delta is needed. + +## Migration Plan + +None. The protocol is in-memory only, and no persisted data changes. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/proposal.md b/openspec/changes/archive/2026-10-14-offload-blocking-io/proposal.md new file mode 100644 index 0000000..62dbb84 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/proposal.md @@ -0,0 +1,83 @@ +# Change: offload-blocking-io + +## Why + +Audit findings **R-09 (High)** and **P-09 (Low)**: filesystem and shell I/O runs on the event-loop thread. On slow, removable, or unreachable storage, that freezes input and redraw. + +- **R-09.** `core::update` itself performs filesystem I/O. `probe_command_line_target` (`update.rs:1929-1941`), called from `dispatch_cd` (`:1856`) and `dispatch_delete_builtin` (`:1902`), falls back to `std::fs::metadata(path)` inside the reducer whenever the target is not a listed entry of the active panel. This breaks the application-shell "Pure core update function" contract ("performs no I/O"). `cd \\offline-server\share` then blocks the event loop for the SMB timeout, typically 20–60 s, with no redraw and no input. The same call skips the `\\?\` long-path abstraction, so a `cd`, `del`, or `rmdir` target longer than 260 characters is reported as "not found". +- **P-09.** `run_effects` runs several file operations synchronously on the UI thread: + - `ByteSource::open` for F3 (`app.rs:926`); + - `EditorState::open` for F4, a read of up to 10 MB (`app.rs:967`); + - `editor.save()` for F2 in the editor (`app.rs:985-994`); + - `ShellExecuteW` for Open with the associated application (`app.rs:878-886`, `:1087`), which can block for seconds in slow handlers and is called without `CoInitializeEx`, though MSDN asks for it because shell handlers may use COM. + + - the `config.toml` rewrites for `Effect::PersistTheme` and `Effect::PersistPanelSplit` (`app.rs:900-915`): each reads the whole file, rewrites one line, writes a temporary file, and renames it, on every theme apply and every Ctrl+Left/Ctrl+Right split step. + + On a network share or removable media, F3, F4, F2, and Open can each freeze the UI for as long as the I/O takes, and so can a theme or split change when the profile directory sits on a slow or redirected volume. + +## What Changes + +- The reducer no longer touches the filesystem. `cd`, `del`, and `rmdir` targets that are already listed in the active panel are still answered immediately from that listing. Any other target is checked asynchronously: the reducer emits a path-probe effect carrying a fresh request id, and completes the command when the matching probe reply arrives. + - A reply that has been superseded is discarded: a newer check was started, the panel has navigated since, or (for `del`/`rmdir`) the panels no longer own input. + - While a check is outstanding, the panel shows `Checking …`. + - The probe runs through the `\\?\` long-path abstraction. +- Viewer open, editor open, editor save, and launching the associated application all run on worker threads. + - While an open is outstanding, the panel shows `Opening …`. A newer F3 or F4 supersedes it. A late reply is discarded once the user has left the panels or opened a dialog, menu, or overlay. + - While a save is outstanding, the editor shows a `Saving…` notice on its last body row, above the F-key bar, and edits are not accepted. The header and F-key bar that builtin-editor "Full-screen editor chrome" specifies are unchanged. + - On Windows, the launch worker initializes COM (apartment-threaded) around `ShellExecuteW`. A launch failure lands on the panel the Open action targeted. +- The theme and panel-split `config.toml` writes move to one background configuration writer: + - writes are applied one at a time in the order requested, so two rewrites of `config.toml` never interleave; + - a newer value for the same setting replaces an older one that has not been written yet (last write wins); + - on quit the writer finishes its outstanding writes before the process exits, waiting at most about 2 seconds; + - each write keeps the durable temp-file, flush, and replace sequence that `config-parsing-robustness` specifies, and a failure still never interrupts the session. + + `PersistHistory` (`history.json`) is not part of this; `history-persistence-hardening` gives it its own background writer. +- `cd`, `del`, and `rmdir` keep their observable outcomes: they navigate, open the delete-confirmation dialog, or reject with the same messages. Only the timing of the outcome changes, plus the pending status text. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: ADDED "The reducer performs no filesystem I/O", which makes the existing purity rule testable and adds the request-id/stale-reply contract. ADDED "Slow storage never blocks the event loop", which covers the viewer and editor open, editor save, the command-line path check, the associated-application launch with COM initialized, and the `config.toml` writes for the applied theme and panel split (ordered, last write wins, completed before exit within a bound). "Pure core update function" is left unchanged. +- `command-line`: MODIFIED "cd navigates the active panel or rejects a nonexistent target" and MODIFIED "del and rmdir route into the existing delete-confirmation flow". Both gain the asynchronous long-path probe with the listed-entry fast path, the pending status, and stale-reply discarding. Their resolution clause now defers to `relative-destination-resolution`'s ADDED "cd, del and rmdir targets resolve to a normalized absolute path" instead of "exactly as today". All existing scenarios are kept verbatim. + +## Impact + +- `crates/filecommand-core/src/update.rs`: + - `probe_command_line_target` (`:1929-1941`) loses its `std::fs::metadata` fallback and becomes a listed-entry lookup only. + - `dispatch_cd` (`:1848-1872`) and `dispatch_delete_builtin` (`:1884-1919`) gain pending-probe state and emit `Effect::ProbePath { request, path }`. + - New `Command::PathProbed { request, kind }` handling. + - `Effect::OpenViewer` and `Effect::OpenEditor` gain a request id, and their replies (`ViewerOpened`/`ViewerOpenFailed`, `EditorOpened`/`EditorTooLarge`/`EditorOpenFailed`) echo it (`:646-732`, `:1622-1659`). + - `handle_editor` (`:2332-2453`) gains a save-in-flight state. + - `Effect::OpenWithAssociation` and its failure reply carry the panel side. +- `crates/filecommand-core/src/panel.rs`: a non-error pending note on the mini-status line. +- `crates/filecommand-tui/src/app.rs`, `run_effects` (`:843-1035`): + - `ProbePath`, `OpenViewer`, `OpenEditor`, `SaveEditor`, and `OpenWithAssociation` dispatch to workers. + - `PersistTheme` and `PersistPanelSplit` (`:900-915`) are submitted to a new `ConfigWriter` owned by `Runtime`, which is flushed and joined (bounded) on every exit path from `run`. + - `Runtime` gains a hand-off slot for the `ByteSource` a viewer-open worker produces. + - `open_with_association` (`:1064-1144`) runs inside a COM-initialized worker on Windows. +- `crates/filecommand-tui/src/worker.rs`: new `spawn_path_probe`, `spawn_viewer_open`, `spawn_editor_open`, `spawn_editor_save`, and `spawn_open_with_association`. +- `crates/filecommand-tui/Cargo.toml`: the `windows-sys` features gain `Win32_System_Com`. +- `crates/filecommand-tui/src/views/`: the panel mini-status shows the pending note, and the editor shows the `Saving…` notice row. +- Tests: + - core reducer tests for probe emission, reply application, stale discarding, the listed-entry fast path, and save-in-flight gating; + - TUI worker tests for the probe (including a >260-character path on Windows) and for viewer and editor open hand-off; + - snapshots of the pending note and of the `Saving…` notice row. + +**Audit findings:** R-09, P-09 + +**Coordination:** +- `editor-encoding-safety` (R-02) must land first. It rewrites `EditorState::open`/`save`: encoding detection, redirecting non-UTF-8 files to the viewer, and atomic save. This change only moves those calls onto a worker and tags their replies with the request id, including its new `EditorNotEditable` redirect reply. It also renders `save_error` on the row above the editor's F-key bar, which is where this change shows `Saving…`. +- `relative-destination-resolution` (R-06) **lands first**. It makes `resolve_cd_target` return an absolute, lexically normalized path and ADDS command-line "cd, del and rmdir targets resolve to a normalized absolute path", which also says the existence check uses the resolved path. This change rebases onto it: the listed-entry fast path and the asynchronous probe both take that resolved path, and the MODIFIED cd and del/rmdir descriptions defer to that requirement for resolution instead of saying "exactly as today". +- `history-persistence-hardening` (P-08/R-13/R-15) owns `PersistHistory` only: its background `history.json` writer, debounce, and bounded final flush. This change owns every other `Persist*` effect (today `PersistTheme` and `PersistPanelSplit`) and follows the same shape: one writer thread spawned when persistence is enabled, a bounded flush at exit. The two writers handle different files and are independent; whichever change lands second may fold them into one persistence thread. +- `config-parsing-robustness` (R-15/R-16/B-05) owns how a `config.toml` write is performed: its ADDED configuration-files "Persisted configuration writes are durable" (uniquely named temporary file, flushed with `sync_all`, then replaced; target untouched and temporary file removed on failure), implemented by routing `save_theme_atomic`/`save_panel_split_atomic` through the shared durable-replace helper (introduced by `editor-encoding-safety`, or by `config-parsing-robustness` if it lands first). This change only moves those calls onto the writer thread and must not bypass the helper. Its table-aware rewrite ("Configuration rewrites keep settings in the top-level table") is likewise unchanged by being called from the writer. +- `error-diagnostics` (B-06) logs persistence failures. The `persist` log lines move with the writes onto the writer thread; `diag::log` is already safe to call from any thread. +- `open-with-associated-app` (in-flight) introduced `ShellExecuteW`. This change adds only application-shell requirements and does not modify `file-action-menu`. The failure still lands on the status line of the panel the Open action was invoked on. +- `listing-request-guard` (R-04) uses the same request-id pattern (`State::next_request_id`) for listings. +- `viewer-mapping-safety` (R-20) changes the backend `ByteSource::open` uses. The worker calls whichever backend exists. +- `process-lifecycle-hardening` (R-12) adds `catch_unwind` around worker bodies. The new workers must follow the same pattern; whichever change lands second wraps them. +- `resize-preserves-session-state` (R-03): replies that arrive while the terminal is undersized are applied, because being undersized is not "leaving the panels". diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/application-shell/spec.md new file mode 100644 index 0000000..f5aabd6 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/application-shell/spec.md @@ -0,0 +1,110 @@ +## ADDED Requirements + +### Requirement: The reducer performs no filesystem I/O + +`core::update` SHALL NOT perform filesystem I/O of any kind, directly or through any helper it calls. That includes metadata or existence queries, directory reads, and file opens. When handling a command depends on the current state of the filesystem, `core::update` SHALL: + +- answer from the state it already holds where that state suffices, such as an entry already listed in a panel; +- otherwise return an effect requesting the check, tagged with a fresh request id; +- complete the command only when the reply command carrying that id is applied. + +A reply whose id is no longer the outstanding request SHALL be discarded without changing state. This makes the "Pure core update function" rule checkable by a unit test that runs with no filesystem at all. + +#### Scenario: A command-line target outside the listing produces a probe request, not a disk read + +- **WHEN** a unit test applies `cd sub` to `core::update` with the active panel at `C:\NORTON` and `sub` not among its listed entries +- **THEN** the returned effects contain a path-probe request for `C:\NORTON\sub` tagged with a request id, and the panel's `cwd` is still `C:\NORTON` +- **AND** `core::update` made no filesystem call, so the result is the same whether or not `C:\NORTON\sub` exists on the machine running the test + +#### Scenario: The probe reply completes the command + +- **WHEN** the path-probe reply for that request id arrives reporting a directory +- **THEN** applying it through `core::update` navigates the panel to `C:\NORTON\sub` exactly as a synchronous check would have + +#### Scenario: A superseded probe reply is discarded + +- **WHEN** a second `cd` is entered before the first probe's reply arrives, and the first reply then arrives +- **THEN** the first reply changes nothing: no navigation, no error message, and no dialog + +### Requirement: Slow storage never blocks the event loop + +These operations SHALL run on worker threads, never on the event-loop thread, so that a slow, removable, or unreachable volume cannot freeze input or redraw: + +- the command-line target check; +- opening a file in the F3 viewer; +- opening a file in the built-in editor; +- saving from the built-in editor; +- launching a file's associated application; +- writing the applied theme and the panel split to `config.toml`. + +While an editor or viewer open is outstanding, the status line of the panel it was requested from SHALL show `Opening …`, and the panels SHALL keep accepting input. A newer F3 or F4 request SHALL supersede an outstanding open. An open reply that arrives after the user has left the panels, or after a menu, dialog, or overlay has opened, SHALL be discarded, and a viewer reply's file handle SHALL be released. + +While a save is outstanding, the editor SHALL show a `Saving…` notice on the row directly above its F-key bar, leaving the header and F-key bar unchanged, and SHALL NOT accept edits, caret moves, or further save or quit commands until the save completes or fails. The screen SHALL keep redrawing meanwhile. + +Configuration writes SHALL be performed one at a time, in the order they were requested, by a single background writer, so that two rewrites of `config.toml` never interleave. A newer value for a setting SHALL replace an older value for the same setting that has not been written yet, so the value on disk after the writes complete is always the last one requested. Moving a write to the background writer SHALL NOT change how it is written: it SHALL remain the atomic write that theme-selection "Applied theme persists to configuration" and panel-split "Split persistence to configuration" require, made durable as configuration-files "Persisted configuration writes are durable" specifies where that requirement exists, and a failed write SHALL NOT interrupt the session. When the application exits, by quit or by any other exit path, the system SHALL first complete every configuration write still outstanding, waiting at most about 2 seconds. History persistence (`history.json`) is outside this rule. + +On Windows, the worker that launches the associated application SHALL initialize COM in a single-threaded apartment (`COINIT_APARTMENTTHREADED`) before calling the shell launch API and SHALL uninitialize it afterwards. A launch failure SHALL be reported on the status line of the panel the Open action was invoked on. + +#### Scenario: F4 on a slow share keeps the panels live + +- **WHEN** the user presses F4 on `notes.txt` in a panel showing `\\nas\share` and reading the file takes several seconds +- **THEN** the panels keep redrawing and moving the cursor in response to keys while the panel's status line shows `Opening notes.txt…` +- **AND** the built-in editor opens on `notes.txt` once the read completes + +#### Scenario: F3 on a file whose server stopped responding does not freeze the UI + +- **WHEN** the user presses F3 on a file on a share whose server has stopped responding +- **THEN** the UI keeps redrawing and accepting input while the open is outstanding +- **AND** when the open finally fails, the panel's status line shows the failure and the viewer does not open + +#### Scenario: Saving to a slow share shows progress and accepts no edits + +- **WHEN** the user presses F2 in the built-in editor on a file whose write takes several seconds +- **THEN** the editor shows `Saving…` above its F-key bar, the screen keeps redrawing, and typed characters are not inserted into the buffer +- **AND** when the write completes, `Saving…` disappears, the modified indicator clears, and typing edits the buffer again + +#### Scenario: A newer F3 supersedes an outstanding open + +- **WHEN** the user presses F3 on `slow.log`, moves to `fast.txt`, and presses F3 again before `slow.log` has opened +- **THEN** the viewer opens `fast.txt`, and the late answer for `slow.log` is discarded without replacing it + +#### Scenario: A late open does not pull the user out of a menu + +- **WHEN** an F4 open is outstanding and the user opens the F9 menu before it completes +- **THEN** the menu stays open, the editor does not open when the read completes, and the late result is discarded + +#### Scenario: Launching a slow handler leaves the panels interactive + +- **WHEN** the user activates Open on a document whose associated application takes several seconds to start +- **THEN** the panels keep redrawing and responding to keys during the launch +- **AND** if the launch fails, the failure is shown on the status line of the panel the Open action was invoked on, even if the user has switched panels since + +#### Scenario: COM is initialized on the launch worker + +- **WHEN** Open launches a file on Windows +- **THEN** the shell launch API is called on a worker thread that initialized COM in a single-threaded apartment beforehand, and that thread uninitializes COM after the call returns + +#### Scenario: Applying a theme on a slow profile directory keeps the UI live + +- **WHEN** the profile directory is on a network share that takes several seconds to answer and the user applies a theme from the theme picker +- **THEN** the new theme is shown and the UI keeps redrawing and accepting keys while `config.toml` is being rewritten in the background + +#### Scenario: Rapid split adjustments persist the final value + +- **WHEN** the split is at 50/50 on a 100-column terminal and the user presses Ctrl+Right eight times in quick succession, ending at a 66% split +- **THEN** the input is never held up by the writes, and once the writer has caught up `config.toml` contains `panel_split = 66` + +#### Scenario: A theme and a split change made together are both saved + +- **WHEN** the user applies a theme and, before that write finishes, adjusts the split +- **THEN** after both writes complete `config.toml` contains both the new `theme` and the new `panel_split` + +#### Scenario: A setting changed just before quit is saved + +- **WHEN** the user adjusts the split and immediately quits and confirms, on a responsive disk +- **THEN** the process exits only after `config.toml` holds the new `panel_split` value + +#### Scenario: An unresponsive profile directory cannot hold quit open + +- **WHEN** a configuration write is blocked on a profile directory that has stopped answering and the user confirms quit +- **THEN** the application exits after about 2 seconds, and `config.toml` is left with either its complete previous content or its complete new content diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/command-line/spec.md b/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/command-line/spec.md new file mode 100644 index 0000000..616d626 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/specs/command-line/spec.md @@ -0,0 +1,100 @@ +## MODIFIED Requirements + +### Requirement: cd navigates the active panel or rejects a nonexistent target + +`cd ` SHALL resolve `` against the active panel's current directory (supporting `.`, `..`, bare drive letters, UNC paths, and relative paths), producing the normalized absolute path that "cd, del and rmdir targets resolve to a normalized absolute path" defines. Before navigating, the system SHALL verify the resolved target exists and is a directory. If it does, the active panel SHALL navigate to it exactly as today. If it does not — the target doesn't exist, or exists but is not a directory — the system SHALL reject the command: the active panel's `cwd` SHALL be left completely unchanged, no listing read SHALL be attempted, and the panel's error state SHALL be set to indicate the target could not be found. + +The check is made in one of two ways: + +- When the resolved target is an entry already listed in the active panel, that listing SHALL answer the check immediately. +- Otherwise the check SHALL run asynchronously on that resolved path, off the event-loop thread, through the `\\?\` long-path abstraction, so that targets longer than 260 characters and UNC targets are checked correctly. While it is outstanding, input and redraw SHALL continue and the panel's status line SHALL show `Checking …`. + +An asynchronous answer SHALL be applied only if both of these hold: + +- it is still the most recent command-line check; +- the panel the command was entered for still shows the directory it showed when the command was entered. + +Otherwise it SHALL be discarded with no navigation and no error message. + +#### Scenario: cd navigates to a valid relative subdirectory +- **WHEN** the active panel is at `C:\NORTON` containing subdirectory `sub`, and the command buffer is `cd sub` +- **THEN** the active panel navigates to `C:\NORTON\sub` + +#### Scenario: cd to a nonexistent directory is rejected without navigating +- **WHEN** the active panel is at `C:\NORTON` and the command buffer is `cd \NOSUCHDIR` +- **THEN** the active panel's current directory remains `C:\NORTON`, no listing read is started, and the panel shows an error indicating the target was not found + +#### Scenario: cd to a file (not a directory) is rejected +- **WHEN** the active panel is at `C:\NORTON`, `readme.txt` is a file in it, and the command buffer is `cd readme.txt` +- **THEN** the active panel's current directory remains `C:\NORTON` and the panel shows an error indicating the target is not a directory + +#### Scenario: A listed subdirectory needs no filesystem check +- **WHEN** the active panel at `C:\NORTON` lists the subdirectory `sub` and the command buffer is `cd sub` +- **THEN** navigation to `C:\NORTON\sub` begins in response to the Enter itself, without waiting for any filesystem check + +#### Scenario: cd to an unresponsive UNC path does not freeze the UI +- **WHEN** the active panel is at `C:\NORTON` and the command buffer is `cd \\offline-server\share`, whose server does not respond +- **THEN** while the check is outstanding the UI keeps redrawing and accepting keys, the panel's status line shows `Checking \\offline-server\share…`, and the panel's current directory remains `C:\NORTON` +- **AND** when the check finally fails, the panel shows an error indicating the target was not found and no listing read was started + +#### Scenario: cd to a path longer than 260 characters +- **WHEN** the command buffer is `cd` followed by an existing directory whose absolute path exceeds 260 characters and which is not listed in the active panel +- **THEN** the check finds the directory and the active panel navigates to it, rather than reporting it as not found + +#### Scenario: A late answer after navigating elsewhere is discarded +- **WHEN** `cd \\slow-server\share` is outstanding and the user presses Enter on the listed directory `docs` before its check completes +- **THEN** the panel shows `docs`, and the late answer for `\\slow-server\share` neither navigates the panel nor sets an error + +### Requirement: del and rmdir route into the existing delete-confirmation flow + +`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory, producing the normalized absolute path that "cd, del and rmdir targets resolve to a normalized absolute path" defines. `.` 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. + +The existence and type check is made in one of two ways: + +- When the resolved target is an entry already listed in the active panel, that listing SHALL answer the check immediately. +- Otherwise the check SHALL run asynchronously on that resolved path, off the event-loop thread, through the `\\?\` long-path abstraction. While it is outstanding, input and redraw SHALL continue and the panel's status line SHALL show `Checking …`. + +An asynchronous answer SHALL be applied, opening the dialog or setting the error, only if all of these hold: + +- it is still the most recent command-line check; +- the panel the command was entered for still shows the directory it showed when the command was entered; +- the panels still own input, with no menu, dialog, or overlay open and no file operation in progress. + +Otherwise it SHALL be discarded and no dialog SHALL open. + +#### 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` +- **THEN** the delete-confirmation dialog opens naming `notes.txt`, and nothing is deleted until it is accepted + +#### Scenario: rmdir on a directory opens the delete-confirmation dialog +- **WHEN** the active panel is at `C:\NORTON`, `docs` is a subdirectory in it, and the command buffer is `rmdir docs` +- **THEN** the delete-confirmation dialog opens naming `docs`, requiring the existing second confirmation if it is non-empty, and nothing is deleted until accepted + +#### Scenario: del rejects a directory target +- **WHEN** the active panel is at `C:\NORTON`, `docs` is a subdirectory in it, and the command buffer is `del docs` +- **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target is a directory + +#### Scenario: rmdir rejects a file target +- **WHEN** the active panel is at `C:\NORTON`, `notes.txt` is a file in it, and the command buffer is `rmdir notes.txt` +- **THEN** the command is rejected, no dialog opens, and the panel shows an error indicating the target is not a directory + +#### Scenario: del/rmdir on a nonexistent target is rejected +- **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 + +#### Scenario: del on an unlisted target on a slow share does not freeze the UI +- **WHEN** the command buffer is `del \\nas\share\old.log`, which is not listed in the active panel, and the server answers slowly +- **THEN** the UI keeps redrawing and accepting keys while the panel's status line shows `Checking \\nas\share\old.log…` +- **AND** once the check reports a file, the delete-confirmation dialog opens naming `old.log`, and nothing is deleted until it is accepted + +#### Scenario: A late answer does not open the dialog over a menu +- **WHEN** `rmdir \\nas\share\tmp` is outstanding and the user opens the F9 menu before its check completes +- **THEN** the menu stays open and the delete-confirmation dialog does not open when the check completes + +#### Scenario: del on a path longer than 260 characters +- **WHEN** the command buffer is `del` followed by an existing file whose absolute path exceeds 260 characters and which is not listed in the active panel +- **THEN** the delete-confirmation dialog opens naming that file, rather than the target being reported as not found diff --git a/openspec/changes/archive/2026-10-14-offload-blocking-io/tasks.md b/openspec/changes/archive/2026-10-14-offload-blocking-io/tasks.md new file mode 100644 index 0000000..2d7da0a --- /dev/null +++ b/openspec/changes/archive/2026-10-14-offload-blocking-io/tasks.md @@ -0,0 +1,87 @@ +# Tasks: offload-blocking-io + +## 1. Core: command-line probe (`crates/filecommand-core/src/update.rs`) + +- [x] 1.1 Add `Effect::ProbePath { request: u64, path: PathBuf }`, `Command::PathProbed { request: u64, kind: ProbeKind }`, and `ProbeKind { Dir, File, Missing }`. Add `State::command_probe: Option`, holding `{ request, side, cwd, path, intent: Cd { text } | Delete { want_dir } }` (design D1). +- [x] 1.2 Strip the `std::fs::metadata` fallback from `probe_command_line_target`, so it becomes a listed-entry lookup only that returns `None` when the target is not listed (application-shell "The reducer performs no filesystem I/O"). +- [x] 1.3 `dispatch_cd` and `dispatch_delete_builtin`: + - on a listed-entry hit, behave exactly as today; + - otherwise mint a request id, record the pending probe, set the panel's `Checking …` note, and return `ProbePath`. + + `.` and `..` rejection in `del`/`rmdir` stays before any lookup (command-line "cd navigates…", "del and rmdir route…"). +- [x] 1.4 Handle `PathProbed` per design D1: + - apply only when the request matches, the panel `cwd` is unchanged, and (for `Delete`) the phase is `Panels` with no modal field set; + - reuse today's navigation, history push and `PersistHistory`, the delete-dialog entry, and the error messages; + - always clear the pending entry and the note. + +## 2. Core: opens, save, launch + +- [x] 2.1 Add `PanelState::pending_note: Option`. Setting it clears that panel's `last_error`. Clear it when a reply is applied or discarded and in `begin_new_listing` (design D3). +- [x] 2.2 Add `request: u64` to `Effect::OpenViewer`/`OpenEditor` and to the replies `ViewerOpened`, `ViewerOpenFailed`, `EditorOpened`, `EditorTooLarge`, and `EditorOpenFailed`, plus `EditorNotEditable`, the redirect reply added by `editor-encoding-safety`. Add `State::pending_open`. `handle_request_viewer`, `handle_request_editor`, and the user-menu-file editor path (`update.rs:3692`) set the `Opening …` note. Apply a reply only when the request matches, the phase is `Panels`, and no modal field is set; otherwise discard it. Failure messages go to the recorded side (design D4). +- [x] 2.3 `EditorState::save_pending`: + - set it when `EditorSave` or `EditorConfirmQuitSave` emits `SaveEditor`; + - while it is set, `handle_editor` ignores everything except `EditorSaved` and `EditorSaveFailed`; + - both replies clear it. + + (design D6; application-shell "Slow storage never blocks the event loop") +- [x] 2.4 Add `side: PanelSide` to `Effect::OpenWithAssociation` and `Command::OpenWithAssociationFailed`. The failure lands on that side's `last_error` (design D7). + +## 3. TUI workers and runtime (`crates/filecommand-tui/src/worker.rs`, `app.rs`) + +- [x] 3.1 `worker::spawn_path_probe(request, path, tx)`: `std::fs::metadata(to_extended_path(&path))` mapped to `ProbeKind` (design D2). +- [x] 3.2 `worker::spawn_viewer_open(request, path, handoff_tx, tx)`: `ByteSource::open`. On success, send the source on the hand-off channel, then `ViewerOpened`. On failure, send `ViewerOpenFailed` (design D4, D5). +- [x] 3.3 `Runtime::viewer_handoff`. In `apply`, after a `ViewerOpened` is processed, drain the hand-off channel: keep the matching source in `rt.viewer_source` only if the phase is `Viewer`, and drop the rest (design D4). +- [x] 3.4 `worker::spawn_editor_open` and `worker::spawn_editor_save`, wrapping `EditorState::open` and `EditorState::save` with the same replies as today plus the request id (design D4, D6). +- [x] 3.5 `worker::spawn_open_with_association(side, path, cwd, tx)`. On Windows it brackets `open_with_association` with `CoInitializeEx(null, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE)` and a conditional `CoUninitialize`. Add the `Win32_System_Com` feature to `windows-sys` in `Cargo.toml` (design D7). +- [x] 3.6 `run_effects`: dispatch `ProbePath`, `OpenViewer`, `OpenEditor`, `SaveEditor`, and `OpenWithAssociation` to those workers. No filesystem or shell call remains in these arms. +- [x] 3.7 Add `ConfigWriter` (design D9): spawned in `run` only when `persist_enabled`; `submit(ConfigWrite::Theme(name) | ConfigWrite::PanelSplit(percent))` stores the newest value per key and queues the key once (FIFO); one thread takes keys in order and calls the existing `config::save_theme_atomic`/`save_panel_split_atomic` (which `config-parsing-robustness` routes through the durable-replace helper; do not bypass it); failures go to `diag::log` when `error-diagnostics` has landed. `Drop` sends `Flush`, the thread writes every pending key, and the drop joins with a bounded wait of about 2 s (application-shell "Slow storage never blocks the event loop"). +- [x] 3.8 `run_effects`: `PersistTheme` and `PersistPanelSplit` call `rt.config_writer.submit(..)` and return at once. `PersistHistory` is left to `history-persistence-hardening`. + +## 4. TUI rendering + +- [x] 4.1 `views/panel.rs` `render_bottom_border`: show `pending_note` in the normal ministatus style, below `status_override` and above `last_error` (design D3). +- [x] 4.2 `views/editor.rs`: while `save_pending` is set, show a one-row `Saving…` notice on the last body row, above the F-key bar, in the row `editor-encoding-safety` uses for its save-failure overlay. Leave the header and F-key bar unchanged (builtin-editor "Full-screen editor chrome"). +- [x] 4.3 Snapshots: a panel with `Checking \\nas\share…`, a panel with `Opening notes.txt…`, and the editor with the `Saving…` notice row (header and F-key bar unchanged). + +## 5. Tests + +- [x] 5.1 Core: + - `cd` to an unlisted target emits `ProbePath` with the panel unchanged, so the result does not depend on the test machine's disk; + - `PathProbed { Dir }` navigates and records history; + - `{ File }` gives "is not a directory"; + - `{ Missing }` gives "not found" with no `StartListing`. + + Migrate the existing tests (`update/tests.rs` around `:1976-2050`) to drive the reply. +- [x] 5.2 Core: + - `del`/`rmdir` unlisted target, then `PathProbed`, opens the delete dialog with a type match, or gives the matching error; + - a reply after the F9 menu opened opens no dialog; + - a reply after a newer probe is ignored; + - a reply after the panel navigated is ignored. +- [x] 5.3 Core: the listed-entry fast path emits no `ProbePath` (`cd sub` with `sub` listed starts the listing at once). +- [x] 5.4 Core: + - open replies with a stale request id are discarded; + - a reply arriving with `menu` open is discarded; + - `EditorTooLarge` re-requests the viewer with a fresh id; + - a failure message lands on the recorded side after a Tab. +- [x] 5.5 Core: while `save_pending` is set, `EditorChar`, `EditorMove`, `EditorSave`, and `EditorRequestQuit` leave the editor unchanged and emit nothing. `EditorSaved` clears the flag and the modified state. `EditorSaveFailed` clears the flag and sets `save_error`. +- [x] 5.6 TUI: + - `spawn_path_probe` reports `Dir`, `File`, and `Missing` for a temp directory, a temp file, and a missing path; + - on Windows it reports `Dir` for a directory whose absolute path exceeds 260 characters (created with `\\?\`). +- [x] 5.7 TUI: `spawn_viewer_open` delivers the source on the hand-off channel before `ViewerOpened`. `apply` keeps only the matching source and drops a non-matching one. Add a compile-time `fn assert_send()` check for `ByteSource`. +- [x] 5.7a TUI `ConfigWriter` tests with a temp profile directory and an injectable save function: + - eight split submissions in a row produce a final `panel_split` equal to the last one, with no more writes than submissions; + - a theme submission followed by a split submission leaves both keys in `config.toml`, and the two saves never run concurrently (a save hook that asserts no overlap); + - dropping the writer with a pending write completes it before `drop` returns; + - a save hook that blocks forever makes `drop` return after about 2 s; + - with `persist_enabled` false, `submit` writes nothing. +- [x] 5.8 TUI (Windows): `spawn_open_with_association` on a missing file replies `OpenWithAssociationFailed` with the original side. A unit test of the COM bracket helper checks that it uninitializes only after a successful initialization. + +## 6. Verification + +- [x] 6.1 `cargo test --workspace` passes. +- [x] 6.2 `cargo clippy --workspace --all-targets` reports no new warnings. +- [x] 6.3 `openspec validate offload-blocking-io --strict` is clean. +- [ ] 6.4 Manual (Windows): + - `cd \\10.255.255.1\x`: the UI stays live, `Checking …` shows, and "not found" appears after the timeout. + - F3, F4, and F2 on a file on an SMB share with the server paused: the UI stays live. + - Open on a `.pdf` still launches the reader. diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/.openspec.yaml b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/design.md b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/design.md new file mode 100644 index 0000000..c620f5b --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/design.md @@ -0,0 +1,145 @@ +# Design: process-lifecycle-hardening + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`). + +- **Suspended child runs.** + - `Effect::RunShellCommand` is emitted by the file-action menu's Run entry (`update.rs:2875`) and by F2 user-menu entries (`update.rs:3557`). It reaches `run_shell_command` (`app.rs:1041-1056`), which calls `guard.suspend()`, then `std::process::Command::new(..).status()` with inherited stdio, then `wait_for_key()`, then `guard.resume()`. + - `Effect::RunExternalEditor` reaches `run_external_editor` and `spawn_editor_and_wait` (`app.rs:1147-1176`) with the same shape. + - `suspend` disables raw mode, which on Windows restores `ENABLE_PROCESSED_INPUT`, so Ctrl+C becomes a console control event again. + - No code in the workspace calls `SetConsoleCtrlHandler`, installs a signal handler, or depends on a signal crate. The default handler terminates FileCommand along with the child. + - `wait_for_key` re-enables raw mode for its read, so a Ctrl+C at the press-any-key prompt is an ordinary key and is not affected. +- **Quit.** + - `ConfirmQuit` pushes `Effect::CancelJob` only when the phase is `FileOpRunning`, then `Effect::Quit` (`update.rs:1299-1309`). + - `run_effects` sets `handle.cancel` (`app.rs:850-854`) and returns `quit = true`, and `run` returns `Ok(())` at once (`app.rs:391-394`, and the same `if quit { return Ok(()) }` after every `apply`). + - `JobHandle { cancel, reply_tx }` (`worker.rs:216-219`) keeps no `JoinHandle`, and `spawn_job` (`worker.rs:408-421`) drops it. When `main` returns, the process exits and kills the job thread wherever it is. + - The cancel cleanup in `copy_chunked_impl` (`fs_ops/fs.rs:263-268`: drop the destination, remove the file, return a cancelled error) runs only if the thread reaches the next chunk check first. +- **Waiting on a prompt.** + - `ChannelSink::conflict` and `ChannelSink::error` (`worker.rs:234-254`) send `JobConflict`/`JobError` and then block in `self.reply_rx.recv()`, so the cancel flag is not observed. + - On `Err` (the sender was dropped) they fall back to `ConflictChoice::Skip` and `ErrorChoice::Abort` respectively, so a lost conflict answer silently becomes Skip. +- **Input on the dialogs.** `map_file_op_running_key` (`input/mod.rs:800-835`): + - On the conflict dialog with no rename field, it maps only O, W, S, A, and R, so Esc does nothing. + - With the rename field open, Esc closes the field (`FileOpCancel`). + - On the error dialog, it already maps Esc (and B) to `ErrorChoice::Abort`. The operation-dialogs spec does not say so. +- **The panic hook.** `install_panic_hook_with` (`terminal.rs:219-225`) installs one process-wide hook that runs `restore()` and then the previous hook for every thread. + - None of the `spawn_*` bodies in `worker.rs` use `catch_unwind`. + - `spawn_listing` sends `ListingComplete` or `ListingFailed` only on a normal return, and `spawn_job` relies on `run_job` calling `sink.done`. + - A panic inside `git_info::query` is already absorbed: it runs on `race_with_timeout`'s inner thread, whose dropped sender makes `recv_timeout` return at once, giving `GitInfo::none()`. The hook still restores the terminal in that case. + - No code path that actually panics was found in a worker. The audit rates the trigger as SUSPECTED. + +## Goals / Non-Goals + +**Goals:** + +- Ctrl+C typed during a suspended child run reaches only the child. +- Quit lets a running job finish its cancellation cleanup, within a bound. +- The conflict dialog can cancel the job, and a cancel request is honored while a job waits on any prompt. +- A worker panic never touches the terminal and never leaves a dialog or `Reading…` indicator stuck. + +**Non-Goals:** + +- Changing how the job engine writes files: temporary file plus rename, `sync_all`, and verified moves are `durable-file-copy`'s. This change only gives the existing cancel cleanup time to run. +- A Ctrl+C or Ctrl+Break policy for the TUI itself. In raw mode, Ctrl+C is a key; its meaning is defined by application-shell "Quit request keys and confirmation" and clipboard-export. +- Cancelling find-file walks or viewer searches. That is P-10, in `listing-request-guard`. +- Restoring the R-03 orphaned-job case. `resize-preserves-session-state` removes it; this change only makes the quit path robust whatever the core phase. + +## Decisions + +### D1: Windows uses a console control handler gated by a "child running" flag + +- At startup, `run` calls `SetConsoleCtrlHandler(Some(ctrl_handler), TRUE)` once. +- `ctrl_handler` returns `TRUE` (handled, so FileCommand keeps running) for `CTRL_C_EVENT` and `CTRL_BREAK_EVENT` while a process-wide `static CHILD_RUNNING: AtomicBool` is set. In every other case it returns `FALSE`, so default processing still ends the process on close, logoff, or shutdown, and on Ctrl+C or Ctrl+Break outside child runs. +- `run_shell_command` and `run_external_editor` set the flag through an RAII `ChildRunGuard`. The flag is set before `guard.suspend()` and held until `guard.resume()` has re-entered raw mode, so the press-any-key prompt and the moments between steps are covered too. +- The child is unaffected. Handler routines are per-process and are not inherited, so the child receives `CTRL_C_EVENT` and applies its own or the default handling. +- This needs the `windows-sys` feature `Win32_System_Console`. + +*Alternative considered:* `SetConsoleCtrlHandler(None, TRUE)` around the spawn. This was rejected because the "ignore Ctrl+C" attribute it sets is inherited by child processes, so `ping -t` itself could no longer be stopped. The audit notes the same. + +*Alternative considered:* spawn the child with `CREATE_NEW_PROCESS_GROUP`. This was rejected because a new group does not receive Ctrl+C at all. + +### D2: Unix ignores SIGINT and SIGQUIT in the parent after the child has started + +`std::process::Command::spawn` returns only after the child has `exec`ed, because std reports exec failure over a close-on-exec pipe. The child therefore starts with default dispositions. Only after that does the parent set `SIGINT` and `SIGQUIT` to `SIG_IGN` with `libc::sigaction`, saving the previous actions. It then calls `child.wait()`. The saved actions are restored when the same `ChildRunGuard` drops after the TUI resumes. + +`libc` becomes a direct `cfg(unix)` dependency. It is already in the lock graph through crossterm, `mio`, and `signal-hook`, so no new crate is built. + +*Alternative considered:* ignore the signals before spawning and reset them in the child with `pre_exec`. This was rejected because it is unsafe code in the fork path for no gain over ordering the calls. + +### D3: Quit-time job shutdown in the TUI, whatever the core phase + +- `JobHandle` gains `thread: JoinHandle<()>`. +- Every `if quit { return Ok(()) }` in `run`, plus the initial `run_effects`, goes through `shutdown(rt)`. If `rt.active_job` holds a thread that is not yet finished, `shutdown`: + 1. sets the cancel flag; + 2. polls `thread.is_finished()` every 10 ms for up to `QUIT_JOB_WAIT = 2 s`; + 3. joins the thread if it finished, or abandons it if not. +- No dialog answer is sent. The wait loop in D5 turns the cancel flag into an abort for any pending prompt, so a conflict and an error reply can never be mismatched. Today a stray `JobReply::Error` sent to a waiting conflict would become `Skip`. +- Because this works on `rt.active_job` rather than on core's `CancelJob` effect, it also covers a quit confirmed while core no longer shows `FileOpRunning`. +- The screen is not redrawn during the wait. At most 2 s pass with the last frame still showing. + +*Alternative considered:* `join()` with no bound. This was rejected because a write blocked on an unresponsive SMB server could hang quit indefinitely. Exiting after 2 s leaves the same partial-file risk as today, and only in that degenerate case. + +### D4: The panic hook acts only on the UI thread + +- `install_panic_hook_with` captures `std::thread::current().id()` into the hook closure it installs. `run` already calls it on the UI thread (`app.rs:245`), before entering raw mode. Capturing in the closure, rather than in a process-wide `OnceLock`, keeps each installation self-contained, so the existing hook tests, which install from different harness threads, keep working. +- The hook runs `restore()` and then the previous hook only when the panicking thread is that thread. +- On any other thread it does neither. The default hook would print `thread '' panicked at …` onto the alternate screen while ratatui keeps diff-drawing over it. +- The payload message is recovered by the worker's own `catch_unwind` (D6). When `error-diagnostics`' log is enabled, it is written there. +- **Spec wording.** The MODIFIED "Panic hook restores the terminal before reporting" keeps both existing scenarios. The WHEN of "Panic in raw mode restores the terminal first" changes from "a panic occurs" to "a panic occurs on the UI thread". Unqualified, that scenario would require a worker panic to restore the terminal too, which is exactly the R-12 behavior this decision removes. The new scenario "A worker panic leaves the terminal alone" states the other half. "Original hook still runs" is unchanged: it is conditioned on the hook having restored the terminal, which now happens only on the UI thread. + +*Alternative considered:* compare `thread::current().name() == Some("main")`, as the audit suggests. This was rejected because a `ThreadId` captured at install time is exact, and it does not depend on `run` being called from the thread std names `main`. Tests call `run`'s helpers from harness threads. + +### D5: Prompts wait with a timeout loop that observes the cancel flag + +`ChannelSink::conflict` and `ChannelSink::error` loop on `reply_rx.recv_timeout(100 ms)`: + +- an answer that arrives returns as today; +- on `Timeout`, if `self.cancel.is_cancelled()`, return `ConflictChoice::Cancel` or `ErrorChoice::Abort`; +- on `Disconnected` (the main loop dropped the `JobHandle`), return `Cancel` or `Abort`. Today this falls back to `Skip`, so a job whose UI is gone stops instead of continuing unattended. + +The 100 ms bound fits easily inside the 2 s quit budget. + +*Alternative considered:* have `CancelJob` also push a synthetic reply. This was rejected because it needs to know which prompt is pending to avoid the mismatch described in D3. + +### D6: Worker bodies run under `catch_unwind`, and each worker has a defined panic reply + +A helper `fn guarded(body: impl FnOnce() -> T) -> Result` wraps `std::panic::catch_unwind(AssertUnwindSafe(body))` and turns the payload into a message (`&str`, `String`, or `"unknown panic"`). Each `spawn_*` uses it and maps a panic to its normal failure reply: + +| Worker | Reply on panic | +|---|---| +| `spawn_listing` | `ListingFailed { panel, message: "Internal error while reading : " }`, plus the request id once `listing-request-guard` lands | +| `spawn_job` | `JobDone { outcome: Cancelled { skipped: [SkippedItem { path: source_dir, reason: "internal error: " }] }, source_dir, dest_dir }`. The skipped list built up inside `run_job` is lost, but the summary dialog appears and the panels re-read. The sink's `tx` is cloned before `run_job` borrows the sink. | +| `spawn_git_info_query` | `GitInfoResolved { info: GitInfo::none() }` | +| `spawn_info_query` | `InfoResolved` with every value resolved to empty or zero, as for an unreadable directory | +| `spawn_drive_label` | `DriveLabelResolved { label: None }` | +| `spawn_tree_expand` | `TreeNodeExpanded { children: vec![] }` | +| `spawn_find_subtree` | `FindFileSearchDone { request }` | +| `spawn_viewer_search` | `ViewerSearchResult { offset: None, match_range: None, request }` | + +`race_with_timeout`'s inner thread needs no wrapper (see Context), but the hook change in D4 is what keeps it from touching the terminal. + +*Alternative considered:* a new `JobOutcome::Failed { message }`. This was rejected for now because it would touch core state and every outcome match. Reusing `Cancelled` with an explanatory skipped item gives the observable result the requirement asks for: the dialog closes, the panels re-read, and the internal error is named. + +### D7: `ConflictChoice::Cancel` and the dialog's Cancel control + +- `fs_ops::ConflictChoice` gains `Cancel`. +- `ConflictPolicy::apply` ignores it, so it never latches. +- Each conflict match in the engine (`run_copy_or_move`, `run_move_same_volume`, `run_rename`) maps `Cancel` to "set cancelled, stop", without pushing a `SkippedItem`, and the job ends with `JobOutcome::Cancelled { skipped }`. +- Input: with no rename field open, Esc and a plain `c`/`C` map to `FileOpConflictChoice(ConflictChoice::Cancel)`. This mirrors the progress dialog's own `Esc`/`c` cancel. The existing O/W/S/A/R letters are unaffected. +- Core's existing `FileOpConflictChoice` arm forwards the answer with `Effect::SendConflictReply` and returns the dialog to Progress until `JobDone` arrives. No core logic changes beyond a new `ButtonId::ConflictCancel` in `button_command`. +- The dialog renders a `(C)ancel` control on its own row below the existing choice row, so the existing row and its hit spans stay as they are, and the dialog grows by one row. + +## Risks / Trade-offs + +- [A Ctrl+C typed after `guard.suspend()` but before the child is spawned still takes the default path.] → The flag is set before `suspend` is called, so this window does not exist on Windows. On Unix the window from `suspend` to `spawn` returning is a few system calls long. It matches today's exposure, and nothing is lost: no child has started yet, so only FileCommand would exit, exactly as today. +- [A worker panic now prints nothing to the console.] → The message appears in the failure reply (the panel error or the summary item) and in the diagnostic log when it is enabled. That is strictly more visible than a report drawn under the TUI and then partly painted over. +- [The quit wait shows a frozen frame for up to 2 s.] → This only happens when quitting mid-job. A "Cancelling…" frame was considered unnecessary for a bounded 2 s wait. +- [The conflict dialog grows by one row, which changes snapshots.] → Accepted. The snapshots are updated deliberately. + +## Migration Plan + +None. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/proposal.md b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/proposal.md new file mode 100644 index 0000000..87d89c5 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/proposal.md @@ -0,0 +1,71 @@ +# Change: process-lifecycle-hardening + +## Why + +Four audit findings share one theme: FileCommand does not control how it, its child processes, and its worker threads stop. + +- **R-10 (High): no console Ctrl+C handler.** While a command from the Run entry or the F2 user menu, or the external editor, runs in the suspended terminal (`app.rs:1041-1056`, `:1147-1157`), raw mode is off. Ctrl+C therefore becomes a console control event that Windows delivers to every process attached to the console, and Unix delivers SIGINT to the foreground process group. FileCommand installs no handler anywhere in the workspace, so pressing Ctrl+C to stop `ping -t` also kills FileCommand, along with its tabs and session state. +- **R-11 (Medium): quit does not wait for job cleanup.** `ConfirmQuit` emits `[CancelJob, Quit]` (`update.rs:1299-1309`). `run_effects` sets the cancel flag (`app.rs:850-854`) and `run` returns at once (`app.rs:391-394`). `JobHandle` keeps no `JoinHandle` (`worker.rs:216-219`), so the process can exit while the job thread is in the middle of `write_all`. The cancel path that removes the partial file (`fs_ops/fs.rs:263-268`) never runs, and a truncated destination file remains. This drifts from application-shell "Quit request keys and confirmation", which says the job is aborted through its cancel semantics *before* exit. +- **R-12 (Medium): the panic hook restores the terminal whatever thread panics.** `std::panic::set_hook` is process-wide (`terminal.rs:219-225`). A panic in a listing, git, find, or job worker leaves raw mode and the alternate screen while the UI keeps drawing, and the chained default hook prints the report across the TUI. Separately, a panicked listing worker never sends `ListingComplete`, so the panel shows `Reading… N` forever. A panicked job never sends `JobDone`, so the progress dialog never closes. +- **R-18 (Low): no cancel from the conflict dialog, and cancellation is not seen while a job waits.** The overwrite-conflict dialog maps only O/W/S/A/R (`input/mod.rs:824-831`), so Esc does nothing. The job thread is parked in `reply_rx.recv()` (`worker.rs:234-254`), so a cancel request is not seen until some answer arrives. To abort a long copy from a conflict prompt, the user must choose Skip All and then Cancel. + +## What Changes + +- **Ctrl+C reaches only the child.** While a child started by the Run entry, the F2 user menu, or the F4 external editor is running, FileCommand ignores Ctrl+C and Ctrl+Break: a console control handler on Windows, SIGINT and SIGQUIT ignored in the parent on Unix, set up only after the child has started. The child still receives the signal and stops normally, then FileCommand shows its press-any-key prompt and resumes. +- **Quit waits for job cleanup.** Confirming quit during a job sets the cancel flag. Any conflict or error prompt the job is waiting on resolves as an abort without needing an answer. Quit then waits up to about 2 s for the job thread to finish its cancellation cleanup, including removing the partial file, before exiting. This applies to any live job thread, whatever screen core thinks is active. +- **Worker panics stay in their worker.** The panic hook restores the terminal and chains to the previous hook only on the UI thread. A worker panic leaves the terminal alone and prints nothing over the TUI. Each worker body runs under `catch_unwind`, and a panic becomes that worker's normal failure result: + - a listing reports a failed listing; + - a job ends as cancelled, with the internal error in the end-of-job summary; + - query workers return their "no result" answer. +- **The conflict dialog gets Cancel.** It adds Cancel, chosen with Esc, `C`, or a click. Cancel aborts the job through the same cancelled outcome as the progress dialog's Cancel. A job waiting on a conflict or error answer now checks the cancel flag while it waits. Esc on the error-recovery dialog, which already chooses Abort in code, is pinned by a scenario. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: + - ADDED "Ctrl+C during a suspended child run stops only the child". + - ADDED "Quit waits for job cancellation cleanup". "Quit request keys and confirmation" already requires the abort before exit and is left unchanged. + - MODIFIED "Panic hook restores the terminal before reporting": restore and report on the UI thread only, worker panics contained and surfaced. Its scenarios are kept; the first is deliberately qualified to say "on the UI thread", and new scenarios are added. +- `operation-dialogs`: + - MODIFIED "Overwrite-conflict dialog": a Cancel choice via Esc, `C`, or the Cancel control. All existing scenarios are kept verbatim. + - ADDED "Cancellation is observed while a job waits for an answer", which covers both the conflict and error-recovery dialogs and pins Esc as Abort on the error-recovery dialog. "Error-recovery dialog" is left unchanged. + +## Impact + +- `crates/filecommand-tui/src/app.rs`: + - `run_shell_command` (`:1041-1056`) and `spawn_editor_and_wait` (`:1165-1176`) switch from `.status()` to `spawn()` plus `wait()` inside a child-run guard. + - `run` installs the console control handler and performs the bounded job shutdown before returning on quit (`:391-394` and every other `quit` return). + - The `Effect::RunJob`/`CancelJob` handling (`:849-854`) is affected. +- `crates/filecommand-tui/src/terminal.rs`: `install_panic_hook_with` (`:219-225`) records the UI `ThreadId` and only restores and chains on that thread. +- `crates/filecommand-tui/src/worker.rs`: + - `JobHandle` gains `thread: JoinHandle<()>` (`:216-219`, `spawn_job` `:408-421`). + - `ChannelSink::conflict` and `ChannelSink::error` wait with `recv_timeout` in a loop that checks the cancel flag (`:234-254`). + - Every `spawn_*` body is wrapped in `catch_unwind`, with a per-worker failure reply. + - A new `console` helper module (or a section of `app.rs`) holds the Windows ctrl handler and the Unix signal guard. +- `crates/filecommand-core/src/fs_ops/conflict.rs`: `ConflictChoice::Cancel`. +- `crates/filecommand-core/src/fs_ops/worker.rs`: every match on a conflict answer (`run_copy_or_move` `:386-403`, `run_move_same_volume` `:472-487`, `run_rename` `:550-561`) handles `Cancel` as "stop, job cancelled". +- `crates/filecommand-core/src/update.rs`: `ButtonId::ConflictCancel` and the `button_command` mapping. +- `crates/filecommand-tui/src/input/mod.rs`: `map_file_op_running_key` (`:800-835`) maps Esc and `C` to `FileOpConflictChoice(Cancel)` when no rename field is open. +- `crates/filecommand-tui/src/views/conflict_dialog.rs`: renders and hit-tests the Cancel control. +- `Cargo.toml` (tui): the `windows-sys` features gain `Win32_System_Console`. A direct `[target.'cfg(unix)'.dependencies] libc` is added; it is already in the lock graph through crossterm, `mio`, and `signal-hook`. +- Tests: + - engine tests with FakeFs for `Cancel`; + - `ChannelSink` tests for cancel-while-waiting and disconnected-reply; + - panic-hook tests for UI versus worker threads; + - `catch_unwind` reply tests per worker; + - input mapping and conflict-dialog snapshots; + - Windows integration tests for the ctrl handler gate and for the quit-time wait removing a partial file. + +**Audit findings:** R-10, R-11, R-12, R-18 + +**Coordination:** +- `listing-request-guard` (R-04) adds a request id to `ListingChunk`, `ListingComplete`, and `ListingFailed`. The panic path's `ListingFailed` must carry it. Whichever change lands second adds the id to the `catch_unwind` reply. +- `durable-file-copy` (R-07/R-08/R-14/B-01) changes the copy to write a temporary file and rename it. Its cancellation cleanup, which removes the temporary or partial file, is what the quit-time wait gives time to run. The "partial file removed" scenario holds for either implementation. +- `resize-preserves-session-state` (R-03): this change's quit-time shutdown acts on `rt.active_job` whatever the core phase, so it also covers the quit-from-placeholder path. Either order works. +- `error-diagnostics` (B-06): when its diagnostic log is enabled, worker panic messages and the quit-wait timeout are logged there. Whichever change lands second wires them in. +- `offload-blocking-io` (R-09/P-09) adds new workers (path probe, viewer open, editor open and save, shell launch). They must use the same `catch_unwind` wrapper. Whichever change lands second applies it. diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/application-shell/spec.md new file mode 100644 index 0000000..a041602 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/application-shell/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Ctrl+C during a suspended child run stops only the child + +While FileCommand is suspended and waiting for a child process it started, a Ctrl+C or Ctrl+Break typed at the console SHALL reach the child as usual and SHALL NOT terminate FileCommand. This covers a command run from the file-action menu's Run entry, a command from the F2 user menu, and the external editor launched by F4. + +After the child exits, FileCommand SHALL show its usual press-any-key prompt and then resume the TUI with its tabs, panels, command history, and session state intact. The child SHALL NOT inherit an instruction to ignore Ctrl+C from FileCommand, so a console program that stops on Ctrl+C still stops. + +Outside these suspended child runs, Ctrl+C handling SHALL be unchanged, as SHALL any console close, logoff, or shutdown event, which still ends FileCommand. + +#### Scenario: Ctrl+C stops a user-menu command and FileCommand survives + +- **WHEN** the user runs a user-menu entry `ping -t 127.0.0.1` and presses Ctrl+C while it runs +- **THEN** `ping` stops and FileCommand shows its press-any-key prompt +- **AND** after a key press the TUI resumes with the same tabs, directories, and cursor positions as before the command ran + +#### Scenario: Ctrl+C during a Run-entry command + +- **WHEN** the user activates Run on `server.exe`, a console program that stops on Ctrl+C, and presses Ctrl+C +- **THEN** `server.exe` stops, and FileCommand is still running and resumes after the prompt + +#### Scenario: Ctrl+C inside the external editor does not end FileCommand + +- **WHEN** the configured external editor is running in the suspended terminal and the user presses Ctrl+C +- **THEN** FileCommand does not exit, whether or not the editor itself treats Ctrl+C as a key or exits on it, and the panel re-read and resume happen as usual once the editor exits + +#### Scenario: The child is not made to ignore Ctrl+C + +- **WHEN** a child started through any of these paths installs no handler of its own and the user presses Ctrl+C +- **THEN** the child terminates as it would when run directly from a shell + +### Requirement: Quit waits for job cancellation cleanup + +When quit is confirmed while a file-operation job is running, the system SHALL do the following before the process exits: + +- request the job's cancellation; +- treat any overwrite-conflict or error-recovery prompt the job is waiting on as an abort, without requiring an answer; +- wait for the job to finish its cancellation cleanup, including removing a partially written destination file, for up to about 2 seconds. + +If the job has not finished within that bound, the application SHALL exit anyway. The wait SHALL apply to any job still running when quit is confirmed, whichever screen or dialog is showing at the time. When no job is running, quit SHALL exit without delay. + +#### Scenario: Quit during a large copy leaves no partial file + +- **WHEN** a copy of a 2 GB file is in progress and the user requests quit and confirms +- **THEN** the application exits only after the partially written destination file has been removed, and the destination holds no truncated copy of that file + +#### Scenario: Quit while a conflict prompt is waiting + +- **WHEN** a copy job is waiting on the overwrite-conflict dialog and the user requests quit and confirms +- **THEN** the job stops without overwriting the conflicting target and without processing any further items, and the application exits after the job has ended + +#### Scenario: The wait is bounded + +- **WHEN** quit is confirmed while the job is blocked in a write to a share that has stopped responding +- **THEN** the application exits after about 2 seconds rather than waiting for the write to return + +#### Scenario: No job means no wait + +- **WHEN** no file operation is running and the user confirms quit +- **THEN** the application exits immediately + +## MODIFIED Requirements + +### Requirement: Panic hook restores the terminal before reporting + +The system SHALL install a panic hook that disables mouse capture, leaves raw mode, and leaves the alternate screen BEFORE the panic report is printed, and that chains to the previously installed hook so the backtrace still surfaces, so that a panic while in raw mode never leaves the terminal unusable. The restoration and the chained report SHALL apply only to a panic on the UI thread, the thread that owns the terminal and runs the event loop. + +A panic on a worker thread SHALL NOT restore the terminal and SHALL NOT write its report onto the terminal the UI still owns. Instead, the panic SHALL be contained to that worker and surfaced through the worker's normal result: + +- a panicked directory listing SHALL end as a failed listing: the panel leaves its `Reading…` state and shows an error; +- a panicked file-operation job SHALL end as a cancelled job: the progress or prompt dialog closes, the affected panels re-read, and the end-of-job summary names the internal error; +- any other worker SHALL deliver its "no result" answer, so that no indicator stays pending. + +The application SHALL keep running after a worker panic. + +#### Scenario: Panic in raw mode restores the terminal first + +- **WHEN** a panic occurs on the UI thread while the terminal is in raw mode on the alternate screen with mouse capture enabled +- **THEN** mouse capture is disabled, raw mode is disabled, and the alternate screen is left before any panic report is written + +#### Scenario: Original hook still runs + +- **WHEN** the panic hook completes its terminal restoration +- **THEN** it delegates to the previously installed hook so the panic message and backtrace are still reported + +#### Scenario: A worker panic leaves the terminal alone + +- **WHEN** a worker thread panics while the TUI is running in raw mode on the alternate screen +- **THEN** raw mode, the alternate screen, and mouse capture stay as they were, no panic text is written over the TUI, and the UI keeps responding to input + +#### Scenario: A panicked listing ends as a failed listing + +- **WHEN** the worker reading a panel's directory panics partway through +- **THEN** the panel stops showing `Reading… N`, shows an error for that directory, and can be re-read with Ctrl+R + +#### Scenario: A panicked job ends and closes its dialog + +- **WHEN** a file-operation job's worker panics while its progress dialog is shown +- **THEN** the progress dialog closes, the affected panels re-read, and an end-of-job summary names the internal error +- **AND** a new file operation can be started afterwards diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/operation-dialogs/spec.md new file mode 100644 index 0000000..1477473 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/specs/operation-dialogs/spec.md @@ -0,0 +1,84 @@ +## MODIFIED Requirements + +### Requirement: Overwrite-conflict dialog + +The system SHALL present an overwrite-conflict dialog when a copy or move would overwrite an existing target. The dialog SHALL display the source's and the target's size and date and SHALL offer the choices Overwrite, Skip, Rename, Overwrite All, Skip All, and Cancel. Cancel SHALL be chosen with Esc, with the `C` hotkey, or by clicking the dialog's Cancel control. + +Cancel SHALL abort the job: + +- the conflicting target SHALL be left untouched and SHALL NOT be recorded as a skipped item; +- no further items SHALL be processed; +- the job SHALL end as cancelled through the same path as the progress dialog's Cancel, so the affected panels re-read and any items skipped earlier in the job are listed in the end-of-job skipped-files summary. + +While the Rename name field is open, Esc SHALL close the field and return to the dialog's choices without cancelling the job. + +#### Scenario: Source vs target details shown + +- **WHEN** copying `report.txt` (2,048 bytes, 2026-08-01) over an existing `report.txt` (900 bytes, 2026-07-15) at the destination +- **THEN** the overwrite-conflict dialog shows both the source size/date and the target size/date so the user can compare them + +#### Scenario: Overwrite All latches for later conflicts + +- **WHEN** the user selects Overwrite All on the first conflict of a multi-file job +- **THEN** that first target is overwritten +- **AND** every subsequent overwrite conflict in the same job is auto-resolved as Overwrite without re-prompting + +#### Scenario: Skip All latches and records skips + +- **WHEN** the user selects Skip All on a conflict +- **THEN** that conflicting item and all subsequent conflicting items in the job are skipped without re-prompting +- **AND** each skipped item is recorded for the end-of-job skipped-files summary + +#### Scenario: Rendered timestamps are deterministic + +- **WHEN** the overwrite-conflict dialog renders source and target dates in a snapshot test with time pinned through the injected `Clock`/formatting path +- **THEN** the displayed dates are stable and reproducible across runs + +#### Scenario: Esc cancels the job from the conflict dialog + +- **WHEN** a ten-file copy reaches a conflict on its third file, `report.txt`, and the user presses Esc +- **THEN** the existing `report.txt` at the destination is unchanged, files four to ten are not copied, and the job ends as cancelled +- **AND** the panels re-read, and no skipped-files summary lists `report.txt` + +#### Scenario: The Cancel control and hotkey match Esc + +- **WHEN** the overwrite-conflict dialog is shown and the user presses `C` or clicks the dialog's Cancel control +- **THEN** the job is cancelled exactly as if Esc had been pressed + +#### Scenario: Esc in the Rename field returns to the choices + +- **WHEN** the user has chosen Rename and is typing a new name, then presses Esc +- **THEN** the name field closes, the dialog shows its choices again, and the job is still waiting for an answer + +## ADDED Requirements + +### Requirement: Cancellation is observed while a job waits for an answer + +While a job is paused waiting for an answer to an overwrite-conflict or error-recovery dialog, a cancellation request SHALL take effect without the dialog being answered. Quitting the application is one source of such a request. The job SHALL then: + +- stop waiting; +- treat the pending prompt as aborted, neither overwriting nor skipping the item in question; +- perform its normal cancellation cleanup; +- end as cancelled. + +If the dialog's answer can no longer reach the job, the job SHALL likewise end as cancelled rather than proceed with a default answer. Esc on the error-recovery dialog SHALL choose Abort. + +#### Scenario: A cancel request ends a job waiting on an error prompt + +- **WHEN** a job is paused on the error-recovery dialog for `locked.db` and a cancellation is requested without the dialog being answered +- **THEN** the job stops waiting promptly, does not retry or skip `locked.db`, processes no further items, and ends as cancelled + +#### Scenario: A cancel request ends a job waiting on a conflict prompt + +- **WHEN** a job is paused on the overwrite-conflict dialog and a cancellation is requested without the dialog being answered +- **THEN** the conflicting target is left untouched, no further items are processed, and the job ends as cancelled + +#### Scenario: A lost answer does not resume the job with a default + +- **WHEN** a job is paused on a conflict or error prompt and the channel that would deliver the answer is closed +- **THEN** the job ends as cancelled instead of continuing with a default Skip or Abort answer applied to that item and later items + +#### Scenario: Esc on the error-recovery dialog aborts + +- **WHEN** the error-recovery dialog is shown and the user presses Esc +- **THEN** the job stops and no further items are processed, exactly as if Abort had been selected diff --git a/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/tasks.md b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/tasks.md new file mode 100644 index 0000000..4e0cdf9 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-process-lifecycle-hardening/tasks.md @@ -0,0 +1,74 @@ +# Tasks: process-lifecycle-hardening + +## 1. Ctrl+C during suspended child runs (`crates/filecommand-tui`) + +- [x] 1.1 `Cargo.toml`: add `Win32_System_Console` to the `windows-sys` features. Add `[target.'cfg(unix)'.dependencies] libc` and confirm with `cargo tree` that no new crate is built (design D1, D2). +- [x] 1.2 Add a `console` module with: + - `static CHILD_RUNNING: AtomicBool`; + - on Windows, `install_ctrl_handler()` and an `extern "system" fn ctrl_handler(u32) -> BOOL` that returns `TRUE` only for `CTRL_C_EVENT` and `CTRL_BREAK_EVENT` while the flag is set; + - an RAII `ChildRunGuard` that sets and clears the flag, and on Unix also holds and restores the saved `SIGINT`/`SIGQUIT` `sigaction`s. + + (application-shell "Ctrl+C during a suspended child run stops only the child") +- [x] 1.3 `app::run`: call `console::install_ctrl_handler()` once at startup, on Windows only. +- [x] 1.4 `run_shell_command` and `run_external_editor`/`spawn_editor_and_wait`: + - create the guard before `guard.suspend()` and hold it until after `guard.resume()`; + - replace `.status()` with `spawn()` followed by `wait()`; + - on Unix, have the guard ignore `SIGINT`/`SIGQUIT` only after `spawn` returns. + + The existing spawn-failure messages are unchanged (design D1, D2). +- [x] 1.5 Unit tests: `ctrl_handler` returns `TRUE` for Ctrl+C and Ctrl+Break only while the guard is alive, and `FALSE` for `CTRL_CLOSE_EVENT` at all times. The guard clears the flag on drop, including during an unwind. +- [x] 1.6 Unix integration test: inside a `ChildRunGuard` after spawning `sleep 5`, `kill -INT` the process group. The child exits and the test process survives. Mark it `#[cfg(unix)]`. + +## 2. Quit waits for job cleanup + +- [x] 2.1 `worker.rs`: add `thread: JoinHandle<()>` to `JobHandle` and have `spawn_job` store it (design D3). +- [x] 2.2 `app.rs`: add `shutdown(rt)`. It sets the cancel flag on a live job, polls `is_finished()` every 10 ms for up to `QUIT_JOB_WAIT` (2 s), then joins or abandons the thread. Route every `quit` return in `run`, including the initial `run_effects`, through it (application-shell "Quit waits for job cancellation cleanup"). +- [x] 2.3 Tests: + - a fake job thread that finishes 300 ms after the flag is set makes `shutdown` return after it finishes; + - one that never finishes makes `shutdown` return after about 2 s; + - with no job, `shutdown` returns immediately. +- [x] 2.4 Windows integration test: start a real `spawn_job` copying a large temp file to a destination, call `shutdown` mid-copy, then assert that no destination file remains. + +## 3. Cancellation while a job waits on a prompt + +- [x] 3.1 `ChannelSink::conflict` and `ChannelSink::error` loop on `recv_timeout(100 ms)`. On a timeout with the cancel flag set, return `ConflictChoice::Cancel` or `ErrorChoice::Abort`. On `Disconnected`, return `Cancel` or `Abort` (design D5; operation-dialogs "Cancellation is observed while a job waits for an answer"). +- [x] 3.2 Tests on `ChannelSink` with a real channel: + - setting the flag while `conflict()` waits returns `Cancel` within about 200 ms; + - setting it while `error()` waits returns `Abort`; + - dropping the reply sender returns `Cancel` or `Abort`; + - a normal answer is still returned unchanged. + +## 4. Conflict-dialog Cancel + +- [x] 4.1 `fs_ops/conflict.rs`: add `ConflictChoice::Cancel`. `ConflictPolicy::apply` ignores it (design D7). +- [x] 4.2 `fs_ops/worker.rs`: handle `Cancel` in `run_copy_or_move`, `run_move_same_volume`, and `run_rename`. Each stops, pushes no `SkippedItem` for the conflicting item, and ends with `JobOutcome::Cancelled`. +- [x] 4.3 Engine tests with FakeFs and `RecordingSink`: + - a three-file copy whose second file conflicts, answered `Cancel`, leaves the target unchanged, does not copy the third file, gives the outcome `Cancelled { skipped: [] }`, and makes no write to the conflicting target; + - the same for a same-volume move and for a rename. +- [x] 4.4 `input/mod.rs`: with no rename field open, Esc and a plain `c`/`C` map to `FileOpConflictChoice(ConflictChoice::Cancel)`. With the rename field open, Esc keeps mapping to `FileOpCancel`. Add tests for both, and pin the error dialog's existing Esc to Abort mapping with a test. +- [x] 4.5 `update.rs`: add `ButtonId::ConflictCancel` and map it to `FileOpConflictChoice(Cancel)` in `button_command`. Add a reducer test that a `Cancel` answer emits `SendConflictReply(Cancel)` and returns the dialog to Progress. +- [x] 4.6 `views/conflict_dialog.rs`: render a `(C)ancel` row below the choice row and record its hit span. Update the conflict-dialog snapshots and add one with the Cancel row. + +## 5. Panic containment + +- [x] 5.1 `terminal.rs`: `install_panic_hook_with` captures the installing thread's `ThreadId` in the hook closure (not a process-wide `OnceLock`, which the multi-threaded test harness could not reset). The hook runs `restore()` and the previous hook only on that thread (design D4; application-shell "Panic hook restores the terminal before reporting"). +- [x] 5.2 Tests extending `panic_inside_guarded_scope_restores_terminal_before_report_prints`: + - a panic on the installing thread records "restore" before "report"; + - a panic on a spawned thread, caught by joining, records neither; + - restore the original hook afterwards. +- [x] 5.3 `worker.rs`: add the `guarded(body)` helper and wrap every `spawn_*` body. Send the per-worker failure reply from the design D6 table. +- [x] 5.4 Tests with an injected panicking body (a test-only seam that takes a closure): + - listing gives `ListingFailed` naming the path; + - job gives `JobDone { Cancelled { skipped: [internal error] } }`; + - each query worker gives its "no result" reply. +- [x] 5.5 Reducer tests: `ListingFailed` after chunks clears `Streaming`. A `JobDone(Cancelled)` carrying an internal-error item leaves `FileOpRunning` for `FileOpSummary` and emits the re-read listings. + +## 6. Verification + +- [x] 6.1 `cargo test --workspace` passes. +- [x] 6.2 `cargo clippy --workspace --all-targets` reports no new warnings. +- [x] 6.3 `openspec validate process-lifecycle-hardening --strict` is clean. +- [ ] 6.4 Manual (Windows): + - Add the user-menu entry `ping -t 127.0.0.1`, run it, and press Ctrl+C. Ping stops, the prompt appears, and FileCommand resumes with its tabs. + - Copy a multi-GB file, press F10 and confirm. No partial file remains at the destination. + - Trigger a conflict and press Esc. The job ends and the panels re-read. diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/.openspec.yaml b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/design.md b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/design.md new file mode 100644 index 0000000..85263e4 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/design.md @@ -0,0 +1,267 @@ +# Design: reducer-overlay-structure + +## Context + +All references were originally verified against `build/open-proposals-2026-10` (`1bada6f`). The audit quoted non-blank line counts. The physical counts are larger, and both are given here. + +*Line references refreshed against the current branch (`Spec`, `03bdbd7`) on 2026-10-12.* Two features present on the verified branch have not landed on this one: the command-history dialog (`history_dialog`, `handle_history_dialog`, `HistoryDialogOpen`, from pulldown-menu-completion) and the dialog-iris stamp (`stamps.rs`, `State::dialog_opened_at_ms`, from ui-animations). Mentions of those, and the "ten fields" phrasing that counts `history_dialog`, describe the verified branch; every other reference below matches the current tree. + +**Size.** +- `crates/filecommand-core/src/update.rs` is 3,707 lines (3,519 non-blank). +- `Command` spans `:409-877`, `Effect` spans `:908-1010`, and `update_impl` spans `:1063-1600` (538 lines). +- `update/tests.rs` is 5,118 lines (4,605 non-blank). + +**Overlay state.** `State` holds ten independent `Option` overlay fields, each documented as a "one-shot `Option` overlay" (`update.rs:190-252` on this branch, which has nine; see the refresh note): + +| Field | Type | +|---|---| +| `menu` | `MenuState` | +| `drive_select` | `DriveSelect` | +| `fuzzy_jump` | `FuzzyJumpState` | +| `find_file` | `FindFileState` | +| `user_menu` | `UserMenuState` | +| `history_dialog` | `HistoryDialogState` | +| `theme_picker` | `ThemePickerState` | +| `file_action_menu` | `FileActionMenuState` | +| `help` | `HelpState` | +| `startup_warning` | `String` | + +`quit_confirm: bool` is a separate layer (`:253-262`). Its documented contract is that it opens above any context and that cancelling it clears only this flag. The `State::dialog_opened_at_ms` doc (`update.rs:292-297` on the verified branch; the stamp `stamps.rs` maintains for the dialog iris — see the refresh note) also relies on "only one of these dialogs is ever visible at a time", as does the render comment in `views/mod.rs` `render_phase`. + +**The four precedence encodings.** + +| Where | Order, highest first | +|---|---| +| Reducer, `update_impl` `:1165-1277` | quit (`RequestQuit`/`ConfirmQuit`/`CancelQuit`) → file-op phases or job events → viewer → editor → drive select → menu → fuzzy jump → find file → user menu → history → theme picker → file-action menu → help → startup warning → phase `match` | +| Keys, `input::map_key` `:27-102` plus `app.rs:195-215` | quit → *(viewer, editor: dispatched directly by `app.rs` when the phase is `Viewer`/`Editor`)* → startup warning → drive select → menu → fuzzy jump → find file → user menu → history → theme picker → help → file-action menu → phase (file-op dialogs, then panels) | +| Mouse, `input/mouse.rs` `context` `:156-187` | quit → {startup warning, drive select, fuzzy jump, find file, user menu, history, theme picker, help} ignored → file-action menu → file-op phase → menu (inside `Panels`) → panels | +| Render z-order, `views/mod.rs` `render_phase` and `render` | (lowest to highest) panels → menu → drive select → fuzzy jump → find file → user menu → history → theme picker → help → file-action menu → file-op dialog → quit dialog → startup warning | + +`build_hitmap` (`views/mod.rs:117-169`) repeats the "what is open" dispatch a fifth time. + +**Why the disagreement is invisible today.** Every path that opens an overlay starts with the slot empty or empties it first: +- Overlays open only from the reducer's `Panels` arm, which is reached only when no overlay gate claimed the command. +- While an overlay is open, `map_key` routes every key to that overlay's own mapper. No overlay mapper emits another overlay's open command or `RequestQuit`. Unbound keys map to `None` and are ignored. +- The mouse context ignores clicks on every overlay except the file-action menu and the F9 menu. +- An overlay that leads to another closes itself first: + - `handle_menu` sets `state.menu = None` before returning the item's command (`:2388-2395`); + - the user menu's Themes slot takes `user_menu` before setting `theme_picker` (`:3372-3384`); + - `handle_file_action_menu` takes the menu before `activate_file_action`. + +Async replies could in principle open an overlay over another. `DriveListReady` is the only overlay opener among them. `Effect::EnumerateDrives` computes the drive list synchronously inside `run_effects` (`app.rs:689-694`) and posts `DriveListReady` on the channel, and `drain_events` applies it before the next key event is read. `ViewerOpened` and `EditorOpened` are produced the same way today. The startup warning is also raised mid-session, by `UserMenuReloaded` after the user-menu file was edited (`apply_user_menu_reload`; on the verified branch — see the refresh note); that command likewise follows the editor's return before any further input. + +Startup warnings set before the event loop starts are last-writer-wins: `app::run` assigns `state.startup_warning` once (the theme-override warning, `app.rs:144-146`), and `apply_user_menu` once more (`:503`). + +Two key-router tests nevertheless build two-overlay states on purpose, to pin an order: +- `the_drive_dialog_outranks_the_menu` (`input/tests.rs:473-478`): menu and drive select both open, and Esc goes to drive select. +- `startup_warning_outranks_every_other_modal_and_typing_sink` (`:1058-1067`): startup warning and menu both open. + +A snapshot test draws the quit dialog over an open menu (`tests/snapshot_views.rs:275-284`, `quit_confirm_dialog_renders_above_an_open_pull_down_menu`). That combination stays legal, because quit is not part of the slot. + +**The unwraps.** The overlay handlers in `update.rs` contain exactly 25 `.as_mut().unwrap()`, `.as_ref().unwrap()`, or `.take().unwrap()` calls on overlay fields, and they are the file's only `unwrap()` calls. They are in `handle_file_action_menu` (`:2627-2655`), `handle_fuzzy_jump` and `handle_find_file` (`:3263-3329`), `handle_user_menu` (`:3369-3373`), `handle_theme_picker` (`:3412-3416`), and `handle_help` (`:3445-3449`) — plus `handle_history_dialog` on the verified branch (see the refresh note). Each handler re-checks `is_none()` on entry, because nothing ties it to the gate. + +**Dialog geometry.** Each clickable dialog has a `render_*` and a `hit_buttons`/`hit_items` that independently call `filecommand_core::dialogs::overlay_rect` with the same preferred and minimum sizes and recompute their content: +- `delete_confirm.rs:17-48` vs `:58-87` (the question text is rebuilt in both); +- `quit_dialog.rs:14-36` vs `:42-60`; +- `progress_dialog.rs:27-76` vs `:82-89`; +- the same pattern in `conflict_dialog.rs`, `error_dialog.rs`, `skipped_summary.rs`, `destination_input.rs`, `file_action_menu.rs`, and `menubar.rs`. + +`overlay_rect` is the single geometry rule of responsive-layout "Unified overlay geometry". Each dialog's use of it, and the button positions inside the rectangle, is what is duplicated. + +## Goals / Non-Goals + +**Goals:** + +- At most one modal overlay, enforced by the type system rather than by the shape of the open paths. +- One written-down precedence order that the reducer, key routing, mouse routing, rendering, and the hit map all follow. +- Overlay handlers that cannot run without their state, and no unwrap-after-gate. +- Reducer and test files split along functional lines, small enough to navigate. +- Click targets derived from the same layout that draws them. +- Zero change in observable behavior for every reachable state, with insta snapshots unchanged. + +**Non-Goals:** + +- Nesting overlay commands as `Command::Overlay(OverlayCmd)`, which the audit suggests (D4). +- Moving file-operation dialogs, the viewer, or the editor into the slot. They are `UiPhase` variants and already exclusive. +- Any precedence change that a user could observe. Where the current encodings disagree, it is only in unreachable states (Context). +- Deciding what an asynchronous viewer or editor open does when an overlay has opened in the meantime. That is `offload-blocking-io`, which makes those replies asynchronous and discards a late one (D5). +- Shortening `map_panel_key` (`input/mod.rs:238-431`), `app.rs::run`, or `app.rs::run_effects`. B-02 cites them as size evidence. Only the dispatch lines that route through the new precedence function change. +- Panel-row, tab-strip, and key-bar hit-testing. These already derive from `layout::compute`, the same function render uses. +- Removing the per-frame `OsString` clone of each on-screen row name in `views/panel.rs` `hit_test`, which B-08 cites as evidence. `streaming-listing-performance` lists it as out of its scope, so this change owns the decision, and keeps it. The clone is bounded by the panel height (a few dozen short names per frame, not the directory size), and `idle-aware-event-loop` (P-05) removes most idle frames. Panel hits are keyed by name on purpose, so a click stays correct when the listing changes under the pointer (the reasoning `hit_test`'s own comments give for rejecting row-index keys); keying by index to avoid the clone would give that up. +- The "too small" placeholder layer, which is `resize-preserves-session-state`. + +## Decisions + +### D1: `State::overlay: Option`, a single slot + +```rust +pub enum Overlay { + Menu(MenuState), + DriveSelect(DriveSelect), + FuzzyJump(FuzzyJumpState), + FindFile(FindFileState), + UserMenu(UserMenuState), + HistoryDialog(HistoryDialogState), + ThemePicker(ThemePickerState), + FileActionMenu(FileActionMenuState), + Help(HelpState), + StartupWarning(String), +} +``` + +`quit_confirm` is **not** a variant. It is a layer above everything, and the existing application-shell contract ("Cancelling SHALL restore the prior context exactly — anything open stays open") requires it to coexist with an open overlay. The snapshot of the quit dialog over the menu stays valid. + +The startup warning **is** a variant. It is raised only at session start or by `UserMenuReloaded` after the editor closes, both times with the slot empty, so it never coexists with another overlay. Its render placement (over whatever phase is showing, including the splash) is kept per variant (D8). + +`State` gets read accessors (`overlay()`, plus typed `menu()`, `fuzzy_jump()`, and so on, returning `Option<&T>`). It also gets `open_overlay(Overlay)` and `close_overlay()`. The typed accessors keep the TUI and test migration mechanical. `drag_allowed` becomes `matches!(phase, Panels) && overlay.is_none() && !quit_confirm`. + +*Alternative considered: keep the fields and add a runtime invariant check.* That catches violations only in tests, keeps the unwraps, and keeps four precedence encodings. **Rejected.** + +### D2: One precedence function + +Core exposes: + +```rust +pub enum InputLayer { QuitConfirm, FileOp, Viewer, Editor, Overlay, Panels, Splash } +pub fn input_layer(state: &State) -> InputLayer +``` + +It encodes the reducer's current order, layer by layer: +1. quit confirmation; +2. file-operation phase; +3. viewer; +4. editor; +5. overlay slot; +6. the base phase (panels or splash). + +Consumers: +- `update_impl` routes on it after its phase-independent preamble (`DialogButtonClick`, `Resize`, `Tick` bookkeeping, listing events, and stale-reply checks, all unchanged). Routing keeps today's fall-through: each layer handles only the commands it claims, and any other command continues to the next layer down. The quit layer claims only `ConfirmQuit` and `CancelQuit` (and `RequestQuit` opens it from anywhere). The file-op layer also claims job events in every phase. So `JobProgress`, `JobConflict`, and the other worker replies still reach a running job while the quit dialog is open, exactly as today. +- `input::map_key`, the direct viewer and editor dispatch in `app.rs::run`, `input::mouse::context`, and the merged render and hit-map dispatch (D8) all `match` on it, then on the slot's variant. + +The reducer's order is chosen because the reducer is the authority on state, and the TUI's viewer and editor dispatch already agree with it. In every reachable state all five encodings agree, so the choice is not observable. + +`resize-preserves-session-state` adds an undersized state. In its key routing the quit confirmation and then the startup warning are checked first, and only then the undersized mapper, which turns F10/Esc into a quit request and ignores every other key (its design D3). Here that is an `Undersized` layer directly below `QuitConfirm`, with one exception: an open `StartupWarning` keeps its keys above it. Rendering draws the placeholder in place of the phase and overlays, with the quit and startup-warning dialogs above it. Whichever change lands second adds this layer here. + +*Alternative considered: the key router's order (overlays before the file-op phase).* It differs from the reducer's only in unreachable states, and adopting it would mean reordering the reducer, the larger and riskier side. **Rejected.** + +### D3: Take, handle, restore + +The reducer takes the overlay out of the slot (`state.overlay.take()`), matches on the variant, and calls the variant's handler with the overlay state and `&mut State`, for example `fn fuzzy_jump(fj: &mut FuzzyJumpState, state: &mut State, cmd) -> OverlayStep`. The slot is empty while the handler runs. + +```rust +enum OverlayStep { + Keep(Vec), + Close(Vec), + Replace(Overlay, Vec), + CloseThen(Command), +} +``` + +- `Keep` puts the state back. +- `Close` leaves the slot empty, as Esc, confirm, and cancel do today. +- `Replace` installs a different overlay: the user menu's Themes slot opening the theme picker. +- `CloseThen` closes and re-enters `update_impl` with a follow-up command: menu activation, exactly as `handle_menu` returns `Option` today. + +This removes all 25 unwraps and the per-handler `is_none()` re-checks, and it sidesteps the double borrow of `state` and `state.`. + +*Alternative considered: `if let Some(Overlay::FuzzyJump(fj)) = &mut state.overlay` with split borrows.* The handlers need `&mut State` as well (`begin_listing`, `dir_history`, `clock_ms`), which a borrow of a field of `state` forbids. **Rejected.** + +### D4: `Overlay::claims(&Command)` replaces the nine `is_*_command` predicates; `Command` stays flat + +One `impl Overlay { fn claims(&self, cmd: &Command) -> bool }` holds every variant's command set in one `match`, moved verbatim from today's predicates. That includes `TextEdit` and `RequestPaste` for fuzzy jump and find file, and `FindFileMatch` and `FindFileSearchDone` for find file. The startup warning claims `DismissStartupWarning`. A command the open overlay does not claim falls through to the base layer, exactly as today: listing events, `Tick`, async replies, and so on. + +*Alternative considered: `Command::Overlay(OverlayCmd)` nesting, as the audit suggests.* It would rewrite every overlay mapper in the TUI and roughly 300 test constructors, for no behavior or safety gain once `claims` sits in one place. **Rejected** for this change. It can be a later, purely mechanical change. + +### D5: How overlays open, and replies that arrive for or during an overlay + +- Synchronous openers stay where they are, in the `Panels` arm and the file-action and menu paths, and call `state.open_overlay(..)`. In a debug build `open_overlay` asserts that the slot is empty. Every current opener runs with an empty slot (Context). +- **Startup warnings keep last-writer-wins.** The assignments in `app::run` and `apply_user_menu` (Context) use a separate `State::set_startup_warning(String)` that installs `Overlay::StartupWarning` when the slot is empty and replaces the message when it already holds a startup warning. It debug-asserts that the slot holds nothing else. `error-diagnostics` introduces a logging helper of that name for the same assignments, and `config-parsing-robustness` adds its warning summary through it; whichever lands second merges the two into this setter. +- **`DriveListReady` replaces whatever overlay is in the slot.** This is unreachable today: the reply is synchronous and drained before the next input. The rule makes drive select the winner, the same winner today's key router gives in `the_drive_dialog_outranks_the_menu`. +- **Stale replies never touch another overlay.** + - `FindFileMatch` and `FindFileSearchDone` are claimed only while the slot holds `FindFile`. Otherwise they fall to the base layer and are dropped, as today. + - `DriveLabelResolved` updates the dialog only if the slot holds `DriveSelect` with the matching target and generation. + - `UserMenuReloaded` with a malformed file opens the startup warning through `set_startup_warning`. The slot is empty there: the reload follows the return of the built-in or external editor, which was opened from the F9 menu after the menu closed, and no input is read in between. +- **`ViewerOpened` and `EditorOpened` keep the rule current when this change lands.** Today they change the phase from the base layer and leave the slot alone; an open overlay there is unreachable. `offload-blocking-io`, which lands first (proposal: Coordination), makes them asynchronous and discards a reply that arrives after a menu, dialog, or overlay has opened. With the slot, its overlay test becomes `state.overlay.is_some()`; this change does not alter the rule. + +### D6: Tests + +- **Core tests** (`update/tests/*.rs`) set overlays through `Overlay` constructors or `open_overlay`, and read them through the typed accessors. Assertions are unchanged. +- **New reducer tests:** + - opening each overlay while another is in the slot replaces it only where a current path does (menu activation, user-menu Themes, `DriveListReady`), and is otherwise unreachable, which the debug assertion checks; + - a bound command for a closed overlay is a no-op that does not panic; + - late find-file results leave a different open overlay untouched. +- **Rewritten tests.** The two key-router tests that build two-overlay states are rewritten: + - `the_drive_dialog_outranks_the_menu` becomes a reducer test: `DriveListReady` with the menu open leaves `Overlay::DriveSelect` in the slot. + - `startup_warning_outranks_every_other_modal_and_typing_sink` keeps its typing-sink half (a quick search and a command line underneath) and drops the menu. +- **New precedence test.** `input_layer` is checked for each layer combination that can occur, and the key router, mouse context, and render dispatch agree with it. +- **Snapshots.** Every insta snapshot must pass unchanged. A changed snapshot is a regression in this change, not an update to accept. + +### D7: Reducer and test split + +The change starts with **pure moves**, kept as their own commits: functions move between files with no body changes. Then come the logic commits, one overlay at a time (D9). + +| File | Contents | +|---|---| +| `update.rs` | module root: `State`, `UiPhase`, `PanelSide`, `update`, `update_impl` (routing only), `input_layer`, `drag_allowed`, re-exports | +| `update/command.rs` | `Command`, `Effect`, `ButtonId`, `ClickMods`, `DropTarget`, `DragState`, and the other reducer vocabulary types | +| `update/listing.rs` | `begin_listing`, `begin_listing_inner`, `apply_listing_event`, `git_info_query_effect`, `reconcile_panel_viewport`, and the viewport helpers | +| `update/file_op.rs` | `handle_file_op`, setup and running, `confirm_destination_input`, the clipboard export handlers, the source and selection helpers, the drag-and-drop helpers | +| `update/viewer.rs` | `handle_request_viewer`, `handle_viewer` | +| `update/editor.rs` | `handle_request_editor`, `handle_request_external_editor`, `handle_editor`, `editor_viewport`, `handle_edit_user_menu_file` | +| `update/overlays.rs` | `Overlay`, `OverlayStep`, `claims`, and each overlay's handler, plus `menu_action_command`, `button_command`, and `keybar_command` | +| `update/panels.rs` | the `Panels` arm as `handle_panels`, the command line (`run_command_line`, `dispatch_cd`, the parsers), tree mode, tabs, split, quick search, quick filter, swap | + +`parse_cd`, `parse_del`, `parse_rmdir`, `resolve_cd_target`, `menu_action_command`, `button_command`, and `panel_viewport_rows` stay reachable at their current `filecommand_core::update::…` paths through `pub use`. `lib.rs`'s re-exports are unchanged. + +`update/tests.rs` becomes `update/tests/mod.rs` (shared helpers) plus one file per module above, moved verbatim except for the overlay accessor changes in D6. + +### D8: `DialogLayout`, and render plus hit map in one dispatch + +Each clickable dialog and menu module exposes `pub fn layout(area: Rect, model: &…) -> DialogLayout`: + +```rust +pub struct DialogLayout { + pub rect: Rect, + pub buttons: Vec<(Rect, ButtonId)>, + pub rows: Vec<(Rect, usize)>, + // plus the precomputed content strings the renderer needs (e.g. the + // delete question), so they are built exactly once +} +``` + +Its renderer draws from that layout. Its old `hit_buttons` or `hit_items` becomes `layout(area, model).buttons` (or `.rows`), or is deleted. + +The modules are `delete_confirm`, `destination_input`, `quit_dialog`, `progress_dialog`, `conflict_dialog`, `error_dialog`, `skipped_summary`, `file_action_menu`, and `menubar`. `menubar` has two layouts: the title bar, and the open pull-down, which uses responsive-layout's shift-left rule. + +`views::render` fills the `HitMap` in the same per-layer dispatch that draws: each arm computes the layout once, draws, and records its hits. This replaces `build_hitmap`'s separate dispatch. The panel, tab-strip, key-bar, and command-line regions keep coming from `layout::compute`. + +During a dialog's opening iris animation, hits keep using the final layout, as they do today. The geometry rule itself (`overlay_rect`) is unchanged. + +*Alternative considered: renderers record hits while drawing (`&mut HitMap` threaded through every `render_*`).* It changes every renderer signature, including the ones snapshot tests call directly, and ties drawing to input bookkeeping. **Rejected** in favor of a layout value that both sides consume. + +### D9: Incremental migration + +1. Pure-move commits for the module split (D7). +2. Introduce `Overlay`, the slot, `input_layer`, and the accessors, with the slot initially unused. +3. Migrate one overlay per commit, in roughly increasing order of consumers: startup warning, help, theme picker, history dialog, user menu, drive select, fuzzy jump, find file, file-action menu, and finally menu (stamps, render, hit map, mouse). Each commit moves the field into the slot, its handler to `OverlayStep`, its predicate into `claims`, and its TUI readers onto accessors. +4. While both old fields and the slot exist, `update` debug-asserts after each call that at most one of them is set. +5. Switch the TUI routing (`map_key`, mouse `context`, `app.rs` dispatch) to `input_layer`. +6. Introduce `DialogLayout` one dialog at a time, then merge `build_hitmap` into `render`. + +The full test suite and the snapshots must pass after every commit. + +## Risks / Trade-offs + +- [This is a large diff that touches the most-edited files in the repository.] → Land it after the Critical and High audit fixes (proposal: Coordination). Pure moves are separate commits. One overlay per commit keeps every step reviewable and bisectable. +- [A hidden reachable two-overlay state exists that the analysis missed.] → The debug assertion in `open_overlay` and in the transition-period `update` check (D9) turns it into a test failure. The full suite runs at every step. +- [Rendering order changes for the startup warning relative to the quit dialog.] → The two cannot both be open: the warning's mapper turns any key into a dismissal, and the mouse is ignored. No snapshot has both. +- [`Replace` semantics for `DriveListReady` is new code for an unreachable case.] → It is pinned by a reducer test and matches today's key-router winner. +- [The accessor layer adds indirection.] → It is thin `Option<&T>` getters. Handlers themselves use the taken state directly (D3). + +## Migration Plan + +None for users. Developers: code that set `state. = Some(..)` uses `state.open_overlay(Overlay::…(..))`, and readers use the typed accessors. The `filecommand_core::update::…` public paths are preserved through re-exports. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/proposal.md b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/proposal.md new file mode 100644 index 0000000..595be56 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/proposal.md @@ -0,0 +1,95 @@ +# Change: reducer-overlay-structure + +## Why + +Two audit findings describe the same structural weakness. Which modal surface owns the screen and the input is encoded several times over, and nothing keeps the copies in step. + +- **B-02 (Medium): one large reducer whose input-routing policy lives only in its control flow.** + - `crates/filecommand-core/src/update.rs` is 3,707 lines (3,519 non-blank). It is built around `update_impl` (`update.rs:1063-1600`, 538 lines): a chain of early returns, one per surface. + - Ten modal overlays are separate `Option` fields on `State`: `menu`, `drive_select`, `fuzzy_jump`, `find_file`, `user_menu`, `history_dialog`, `theme_picker`, `file_action_menu`, `help`, `startup_warning`. Each is gated in `update_impl` by `state.X.is_some() && is_X_command(&cmd)`, and its handler then re-`unwrap()`s the `Option` it was gated on. That makes 25 such unwraps in `update.rs`, safe only because of a gate hundreds of lines away. (Line references refreshed 2026-10-12 against branch `Spec`; `history_dialog` exists only on the verified branch `build/open-proposals-2026-10` — see design.md's refresh note.) + - The overlay precedence is written down four separate times, in four different orders: + - the reducer's gate chain (`update.rs:1230-1277`); + - the key router (`crates/filecommand-tui/src/input/mod.rs:35-76`); + - the mouse context (`input/mouse.rs:156-187`); + - the render z-order (`views/mod.rs` `render_phase` and `render`). + + The orders differ. For example, the reducer checks the startup warning last of all the overlays, while the key router checks it first. The disagreement goes unnoticed only because the open paths happen never to leave two overlays open at once. Two key-router tests build such impossible states on purpose, to pin an order. + - Adding a modal means touching at least four places. The audit attributes two of its own bugs, R-03 (Critical) and R-04 (High), to precedence and ordering omissions that are hard to see in a 538-line function. + - The tests mirror the layout: `update/tests.rs` is 5,118 lines. +- **B-08 (Low): click targets are computed separately from what is drawn.** + - Each dialog's renderer and its `hit_buttons` recompute the same geometry independently: + - `views/delete_confirm.rs`: render at `:32`, hit at `:70`, including rebuilding the question text; + - `views/quit_dialog.rs`: `:21` and `:44`; + - `views/progress_dialog.rs`: `:33` and `:84`. + - `views/mod.rs` `build_hitmap` (`:117-169`) re-derives "what is open" separately from `render_phase`. + - The only guard is comments such as "mirrors the renderer's own geometry exactly". When one side changes, clicks silently land on the wrong cells. + +Neither finding is a bug today. Both are the reason the next bug in this area is likely, and hard to see. + +## What Changes + +- **One overlay slot.** The ten `Option` fields become one `State::overlay: Option`, an enum with one variant per overlay. At most one overlay can be open by construction. + - The quit-confirmation dialog stays a separate layer above everything. The existing spec lets it open over any context, and cancelling it restores that context exactly. + - The file-operation dialogs, viewer, and editor stay phases of `UiPhase`, which are already exclusive. +- **One precedence order, in one place.** A core function names which layer owns input. The reducer's routing, the TUI's key router and mouse context, and the render and hit-map dispatch all consult it, instead of each encoding its own order. The single order is the reducer's current one: + 1. quit confirmation; + 2. file-operation dialog; + 3. viewer; + 4. editor; + 5. the overlay; + 6. panels or splash. + + `resize-preserves-session-state` will add an undersized layer directly below the quit confirmation, through which only the startup warning's keys also pass. + + A layer handles only the commands it claims. Everything else falls through to the next layer down, as today, so worker replies such as job progress still reach a running job while the quit dialog is open. +- **Handlers receive their state directly.** Each overlay's handler is given its own state (`&mut FuzzyJumpState`, and so on) taken from the slot, and it returns whether the overlay stays open, closes, or is replaced. The nine `is_*_command` predicates collapse into one `Overlay::claims(&Command)`, and the 25 unwraps disappear. +- **Reducer split.** `update.rs` is split into `update/{listing,file_op,viewer,editor,overlays,panels}.rs`, with `Command` and `Effect` in `update/command.rs`. Public paths are unchanged through re-exports. `update/tests.rs` is split along the same lines. +- **One layout per dialog.** Each dialog and menu with clickable parts exposes one layout function, returning its rectangle and button or row rectangles. Its renderer and its hit test both use it, and the hit map is filled in the same dispatch that draws. +- **No user-visible behavior change.** Rendering, key handling, and click handling stay the same for every reachable state. The insta snapshots are the safety net and are expected not to change. +- **Incremental.** One overlay per commit, with a debug assertion during the transition that at most one overlay is set. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: adds the requirement "Modal overlays are mutually exclusive by construction". It covers the single slot, overlay-to-overlay replacement exactly as today, unbound keys being ignored while an overlay is open, stale replies never touching another overlay, and one precedence order. +- `mouse-input`: adds the requirement "Click targets match the rendered geometry". Every dialog's and menu's clickable cells are exactly the cells where its buttons or rows are drawn, at every size and after a resize. + +## Impact + +- **`crates/filecommand-core/src/update.rs`:** + - `State`: ten overlay fields are replaced by `overlay: Option`, plus accessors. + - `update_impl`: the gate chain becomes one routing step. + - `drag_allowed` becomes `state.overlay.is_none()`, plus the existing phase and quit checks. + - Every `is_*_command` and `handle_*` overlay function (`handle_drive_select`, `handle_menu`, `handle_fuzzy_jump`, `handle_find_file`, `handle_user_menu`, `handle_history_dialog`, `handle_theme_picker`, `handle_file_action_menu`, `handle_help`) and every opener in the `Panels` arm (`FuzzyJumpOpen`, `FindFileOpen`, `UserMenuOpen`, `HistoryDialogOpen`, `ThemePickerOpen`, `HelpOpen`, `MenuOpen`, `MenuTitleClick`, `DriveListReady`, `handle_enter`, `handle_open_action_menu_at`, `apply_user_menu_reload`). + - The module is split into `update/*.rs`. `update/tests.rs` is split into `update/tests/*.rs`. +- **`crates/filecommand-core/src/stamps.rs`:** `DialogMark` and the menu and popup marks read the slot instead of the separate fields. +- **`crates/filecommand-core/src/lib.rs`:** re-exports `Overlay`. Existing re-exports are unchanged. +- **`crates/filecommand-tui/src/input/mod.rs`:** `map_key` dispatches on the precedence function and the slot. +- **`crates/filecommand-tui/src/input/mouse.rs`:** `context` dispatches the same way. +- **`crates/filecommand-tui/src/app.rs`:** the key dispatch that calls `map_viewer_key` and `map_editor_key` directly consults the same precedence function. Its `state.X` reads switch to accessors. The startup-warning assignments in `run` and `apply_user_menu` (last writer wins) go through a replacing setter rather than the asserting `open_overlay` (design D5). +- **`crates/filecommand-tui/src/views/mod.rs`:** `render`, `render_phase`, and `build_hitmap` are merged into one dispatch that draws and records hits. +- **`crates/filecommand-tui/src/views/`:** a `layout` function plus `DialogLayout` in each of: + - `delete_confirm.rs`, `destination_input.rs`, `quit_dialog.rs`, `progress_dialog.rs`, `conflict_dialog.rs`, `error_dialog.rs`, `skipped_summary.rs`; + - `file_action_menu.rs`, `menubar.rs` (titles and items). +- **Tests:** + - about 190 references in `update/tests.rs` and about 100 in the TUI tests move to accessors or `Overlay` constructors; + - two key-router tests that build impossible two-overlay states are rewritten (design D6); + - new reducer tests for the slot invariants; + - a new geometry property test for every dialog layout. + - Insta snapshots are unchanged. + +**Audit findings:** B-02, B-08 + +**Coordination:** +- **Land this change after the Critical and High audit fixes that touch `update_impl` and its precedence.** Those are `resize-preserves-session-state` (R-03), `listing-request-guard` (R-04, P-10), `process-lifecycle-hardening` (R-10, R-11, R-12, R-18), and `offload-blocking-io` (R-09, P-09). They are small, local fixes to the current structure. Rebasing them onto a split, restructured reducer would cost more than rebasing this refactor onto them. +- `resize-preserves-session-state` adds `State::undersized` as a screen-level flag above every phase and overlay. This change carries it into the precedence function as the top layer. The startup warning and the quit confirmation keep their keys while undersized, as that change specifies. +- `offload-blocking-io` makes viewer and editor opens reply asynchronously, which makes "a reply arrives while an overlay is open" reachable. That change already decides the rule: an open reply that arrives after a menu, dialog, or overlay has opened is discarded. With the slot its overlay check becomes `state.overlay.is_some()`. This change does not alter that rule. It only fixes the drive-list case, which is not asynchronous today (design D5). +- `config-parsing-robustness` (R-16) adds startup-warning assignments (the parser-warning summary at startup and after a user-menu edit), and `error-diagnostics` (B-06) routes every startup-warning assignment through a logging `set_startup_warning` helper. The `StartupWarning` variant stays a single `String`, and that helper becomes the replacing setter of design D5. +- `ci-and-build-tooling` (B-03) reformats the code and fixes rustdoc links to private items in `update.rs` (`update_impl`, `drag_allowed`). If it lands first, this split starts from formatted code. If not, the formatting commit is regenerated after this change. +- `streaming-listing-performance` (P-03) changes `views/panel.rs` `hit_test` and `visible_indices`. Panel row hit-testing is not part of this change (design: Non-Goals, which also records why B-08's per-row name clone in `hit_test` is kept), so the two only meet in `build_hitmap`'s dispatch. +- `filter-selection-safety` (R-17) adds a disclosure row to the delete confirmation. If it lands first, `delete_confirm`'s `layout` includes that row. diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/application-shell/spec.md new file mode 100644 index 0000000..2841973 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/application-shell/spec.md @@ -0,0 +1,67 @@ +## ADDED Requirements + +### Requirement: Modal overlays are mutually exclusive by construction + +The system SHALL hold the state of the modal overlays in a single overlay slot in `filecommand-core`'s `State`, so that at most one of them is open at any time. The modal overlays are the F9 menu, the drive-select dialog, fuzzy jump, find file, the F2 user menu, the command-history dialog, the theme picker, the file-action menu, the Help window (with its nested About dialog), and the startup warning. Each overlay's handler SHALL receive that overlay's state directly from the slot, so no handler depends on a separately checked gate and none can run while its overlay is closed. + +The quit-confirmation dialog is not part of the slot. It SHALL remain a separate layer above every other context, as "Quit request keys and confirmation" requires. The file-operation dialogs, the viewer, and the editor SHALL remain phases. + +Which layer owns input SHALL be defined once, by a single precedence order in `filecommand-core`, and the reducer's command routing, the TUI's key routing, its mouse routing, and its rendering and hit-map construction SHALL all follow it. The order, highest first: +1. the quit confirmation; +2. a file-operation dialog; +3. the viewer; +4. the editor; +5. the open overlay; +6. the panels or the startup splash. + +A layer SHALL handle only the commands it claims; any other command, including worker replies, SHALL pass to the layers below it as it does today. While the terminal is below the 60x16 floor, input routing SHALL additionally follow "Resize handling with 60x16 hard floor and placeholder". + +This structure SHALL NOT change which overlay is shown, which keys or clicks it accepts, or how it renders, in any state the application can reach. + +#### Scenario: A menu item opens its dialog in place of the menu + +- **WHEN** the F9 menu is open and the user activates Commands → Find file +- **THEN** the menu closes and the find-file dialog opens +- **AND** only the find-file dialog is shown, and it receives the next key + +#### Scenario: The user menu's Themes entry replaces the user menu + +- **WHEN** the F2 user menu is open with its built-in Themes entry highlighted and the user presses Enter +- **THEN** the user menu closes and the theme picker opens in its place, with the active theme highlighted + +#### Scenario: Another overlay's shortcut is ignored while an overlay is open + +- **WHEN** the fuzzy-jump dialog is open and the user presses F9 or Alt+F1 +- **THEN** neither the menu nor the drive-select dialog opens +- **AND** the fuzzy-jump dialog stays open with its pattern and highlight unchanged + +#### Scenario: Unbound keys never reach the panels or the command line + +- **WHEN** the F2 user menu is open and the user types a printable character that the user menu does not bind +- **THEN** the key is ignored, so the command line and the panel cursor are unchanged + +#### Scenario: Late results of a dismissed search touch no other overlay + +- **WHEN** the user dismisses the find-file dialog with Esc while its search is still running, then opens the Help window +- **AND** further matches from the dismissed search arrive +- **THEN** the results are discarded, the Help window is unchanged, and no find-file dialog reappears + +#### Scenario: An overlay command with its overlay closed does nothing + +- **WHEN** a command that only an overlay binds (for example confirming fuzzy jump) reaches the reducer while that overlay is not open +- **THEN** the state is unchanged, no effect is returned, and nothing panics + +#### Scenario: The quit dialog stacks above an open overlay + +- **WHEN** the quit-confirmation dialog is open above an open F9 pull-down and the user cancels it +- **THEN** the pull-down is still open exactly as before, and it receives the next key + +#### Scenario: Worker replies pass beneath the quit dialog + +- **WHEN** a copy job is running, the quit-confirmation dialog is open above its progress dialog, and the job reports progress +- **THEN** the progress dialog underneath shows the new progress and the quit-confirmation dialog stays open + +#### Scenario: One precedence order everywhere + +- **WHEN** the routing of a key, the routing of a mouse event, and the drawing and hit map of a frame are each computed for the same state +- **THEN** all of them treat the same layer as the one that owns input, the layer defined by the single precedence order diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/mouse-input/spec.md b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/mouse-input/spec.md new file mode 100644 index 0000000..0221ed6 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/specs/mouse-input/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Click targets match the rendered geometry + +Every dialog and menu that accepts clicks SHALL compute its rectangle and the rectangles of its buttons or rows through one layout computation per frame, and its renderer and its hit test SHALL both use that computation. This covers: +- the delete confirmation; +- the drop-initiated destination dialog's button row; +- the quit confirmation; +- the progress dialog; +- the overwrite-conflict dialog; +- the error dialog; +- the skipped-items summary; +- the F9 menu bar and its pull-downs; +- the file-action menu. + +The hit map SHALL be recorded by the same dispatch that draws the frame. At every terminal size from the 60x16 floor upward, and after every resize, the cells a click maps to each button or row SHALL be exactly the cells where that button's label or that row is drawn. In a modal dialog a click on any other cell SHALL do nothing, as "Mouse is honoured only where the key would be" already requires; the F9 pull-downs and the file-action menu keep their existing handling of a click that hits none of their titles or rows. The overlay rectangle itself SHALL still come from the unified overlay geometry rule (responsive-layout "Unified overlay geometry"). + +#### Scenario: Delete confirmation buttons at the size floor + +- **WHEN** the delete confirmation for a file with a 200-character name is open at 60x16, with its question truncated with `…` +- **THEN** a click on any cell of the drawn `(Y)es` label confirms exactly as pressing Y, and a click on any cell of the drawn `(N)o` label cancels exactly as pressing N +- **AND** a click on the frame, on the question, or on the spaces between the two labels does nothing + +#### Scenario: Quit dialog hotkey cells + +- **WHEN** the quit-confirmation dialog is open, at 80x24 or at 60x16 +- **THEN** a click on the drawn `Y` of `(Y/N)` confirms quit and a click on the drawn `N` cancels it +- **AND** a click on any other cell of the dialog does nothing + +#### Scenario: Progress dialog Cancel follows a resize + +- **WHEN** a copy job's progress dialog is shown and the terminal is resized from 120x40 to 80x24 +- **THEN** a click on the cells where `[ Cancel ]` is now drawn cancels the job +- **AND** a click on a cell where the button was drawn before the resize, but no longer is, does nothing + +#### Scenario: Geometry agrees at every size + +- **WHEN** any listed dialog or menu is rendered at any terminal size between 60x16 and 200x60 +- **THEN** for each of its buttons or rows, the set of cells the hit map assigns to it equals the set of cells where its label or row is drawn diff --git a/openspec/changes/archive/2026-10-14-reducer-overlay-structure/tasks.md b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/tasks.md new file mode 100644 index 0000000..d85a636 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-reducer-overlay-structure/tasks.md @@ -0,0 +1,73 @@ +# Tasks: reducer-overlay-structure + +Land after `resize-preserves-session-state`, `listing-request-guard`, `process-lifecycle-hardening`, and `offload-blocking-io` (proposal: Coordination). Every step below must leave `cargo test --workspace` green with **no insta snapshot changes**. A snapshot diff is a regression to fix, not one to accept. + +## 1. Pure-move module split (design D7) + +- [x] 1.1 Move `Command`, `Effect`, `ButtonId`, `ClickMods`, `DropTarget`, `DragState`, and the other reducer vocabulary types from `update.rs` to `update/command.rs`, re-exported from `update.rs` so every `filecommand_core::update::…` and `filecommand_core::…` path still resolves. Commit with no body changes +- [x] 1.2 Move handlers verbatim into `update/listing.rs`, `update/file_op.rs`, `update/viewer.rs`, `update/editor.rs`, `update/overlays.rs`, and `update/panels.rs` per the D7 table. Extract the `Panels` arm of `update_impl` into `panels::handle_panels`. Keep the `pub` helpers (`parse_cd`, `parse_del`, `parse_rmdir`, `resolve_cd_target`, `menu_action_command`, `button_command`, `panel_viewport_rows`) re-exported at their current paths. Commit with no body changes +- [x] 1.3 Split `update/tests.rs` into `update/tests/mod.rs` (shared helpers) and one file per module above, moving test functions verbatim. Commit with no body changes +- [x] 1.4 Confirm that `cargo test --workspace` passes and that the test count is unchanged from before 1.1 + +## 2. Overlay slot and precedence (design D1, D2) + +- [x] 2.1 Add `enum Overlay` (ten variants), `State::overlay: Option`, the typed read accessors, `open_overlay` (debug-asserting an empty slot), and `close_overlay`. Re-export `Overlay` from `lib.rs` (application-shell: "Modal overlays are mutually exclusive by construction") +- [x] 2.2 Add `enum InputLayer` and `pub fn input_layer(&State) -> InputLayer` encoding: quit, then file-op phase, then viewer, then editor, then overlay, then panels or splash. Add a unit test per layer (application-shell: "One precedence order everywhere") +- [x] 2.3 Add `enum OverlayStep { Keep, Close, Replace, CloseThen }` and the take, handle, restore routing step in `update_impl`, unused until 3.x (design D3) +- [x] 2.4 During the transition, debug-assert after every `update` call that at most one of the not-yet-migrated `Option` fields and the slot is set (design D9) + +## 3. Migrate one overlay per commit (design D3, D4, D9) + +For each overlay: +- move the field into the slot; +- convert its handler to receive its own state and return an `OverlayStep`, which removes its `unwrap()`s and the `is_none()` re-check; +- move its `is_*_command` predicate into `Overlay::claims`; +- switch its openers to `open_overlay`; +- switch `stamps.rs`, `drag_allowed`, the TUI views, input, and mouse readers, and the tests to the accessors. + +- [x] 3.1 Startup warning (`DismissStartupWarning`, `apply_user_menu_reload`), keeping its render placement over any phase, splash included (design D8). Route the last-writer-wins assignments in `app::run` and `apply_user_menu` through `State::set_startup_warning` (replaces an existing warning, debug-asserts no other overlay), merged with `error-diagnostics`' logging helper of the same name if that change has landed, and add a test that two successive startup warnings leave the second in the slot (design D5) +- [x] 3.2 Help window (`handle_help`, `HelpOpen`) +- [x] 3.3 Theme picker (`handle_theme_picker`, `ThemePickerOpen`), keeping `State::render_theme`'s live preview working +- [x] 3.4 History dialog (`handle_history_dialog`, `HistoryDialogOpen`) +- [x] 3.5 User menu (`handle_user_menu`, `UserMenuOpen`). The Themes slot returns `OverlayStep::Replace(Overlay::ThemePicker(..))` (application-shell: "The user menu's Themes entry replaces the user menu") +- [x] 3.6 Drive select (`handle_drive_select`, `DriveListReady`, `DriveLabelResolved`). `DriveListReady` replaces whatever overlay is in the slot, and `DriveLabelResolved` applies only to a `DriveSelect` in the slot with the matching target and generation (design D5) +- [x] 3.7 Fuzzy jump (`handle_fuzzy_jump`, `FuzzyJumpOpen`) +- [x] 3.8 Find file (`handle_find_file`, `FindFileOpen`). `FindFileMatch` and `FindFileSearchDone` are claimed only while the slot holds `FindFile` (application-shell: "Late results of a dismissed search touch no other overlay") +- [x] 3.9 File-action menu (`handle_file_action_menu`, `handle_enter`, `handle_open_action_menu_at`) +- [x] 3.10 F9 menu (`handle_menu`, `MenuOpen`, `MenuTitleClick`, `MenuItemClick`). Activation returns `OverlayStep::CloseThen(command)` (application-shell: "A menu item opens its dialog in place of the menu") +- [x] 3.11 Remove the transition assertion (2.4) and the last per-overlay `Option` field. Confirm that `grep -n "unwrap()" crates/filecommand-core/src/update` finds none of the 25 overlay unwraps + +## 4. TUI routing on one precedence order (design D2) + +- [x] 4.1 `input::map_key`: dispatch on `input_layer`, then on the slot's variant, replacing the field-by-field `if` chain (application-shell: "One precedence order everywhere") +- [x] 4.2 `app.rs::run` key dispatch: choose `map_viewer_key`, `map_editor_key`, or `map_key` from `input_layer` instead of the `quit_confirm` check plus the phase `match` +- [x] 4.3 `input::mouse::context`: derive the context from `input_layer` and the slot's variant, keeping today's per-overlay mouse policy (ignored, file-action rows, pull-down, dialog buttons) (mouse-input: "Mouse is honoured only where the key would be") +- [x] 4.4 `views::render_phase` and `render`: draw the slot's overlay in one `match` at its current z-position (D8 placement per variant) + +## 5. Shared dialog layout (design D8) + +- [x] 5.1 Add `DialogLayout { rect, buttons, rows, … }`. For each of `delete_confirm`, `destination_input`, `quit_dialog`, `progress_dialog`, `conflict_dialog`, `error_dialog`, `skipped_summary`, and `file_action_menu`, add `pub fn layout(area, model) -> DialogLayout` and make the renderer draw from it. The old `hit_buttons`/`hit_items` become thin wrappers over `layout(..)`, or are removed (mouse-input: "Click targets match the rendered geometry") +- [x] 5.2 `menubar`: title-bar and pull-down layouts shared by `render_*`, `hit_titles`, and `hit_items`, keeping the shift-left rule of responsive-layout "Unified overlay geometry" +- [x] 5.3 Merge `build_hitmap` into `views::render`: each layer's arm computes its layout once, draws, and records its hits. Panel, tab-strip, key-bar, and command-line hits keep coming from `layout::compute` +- [x] 5.4 Property test (proptest) over terminal sizes from 60x16 to 200x60 and representative models, including long, truncated names and each conflict and error variant. For every listed dialog and menu, render into a `Buffer` and assert that the cells of each hit rectangle are exactly the cells holding that button's label or that row (mouse-input: "Geometry agrees at every size") +- [x] 5.5 Mouse tests: delete-confirm `(Y)es`/`(N)o` at 60x16 with a 200-character name; quit `Y`/`N` at 80x24 and 60x16; progress `[ Cancel ]` after a resize from 120x40 to 80x24; and clicks on non-button cells do nothing (mouse-input: "Delete confirmation buttons at the size floor", "Quit dialog hotkey cells", "Progress dialog Cancel follows a resize") + +## 6. Reducer and router tests (design D6) + +- [x] 6.1 Menu activation of Commands → Find file leaves only `Overlay::FindFile` in the slot (application-shell: "A menu item opens its dialog in place of the menu") +- [x] 6.2 Fuzzy jump open: F9 and Alt+F1 map to no command, and the slot and pattern are unchanged (application-shell: "Another overlay's shortcut is ignored while an overlay is open") +- [x] 6.3 User menu open: an unbound printable key leaves the command line and cursor unchanged (application-shell: "Unbound keys never reach the panels or the command line") +- [x] 6.4 Find file dismissed, then Help opened, then late `FindFileMatch` and `FindFileSearchDone` arrive: the Help state is unchanged and no find-file state appears (application-shell: "Late results of a dismissed search touch no other overlay") +- [x] 6.5 Each overlay's confirm or move command applied with the slot empty is a no-op returning no effects (application-shell: "An overlay command with its overlay closed does nothing") +- [x] 6.6 Quit confirmation over an open menu, then `CancelQuit`: the menu is still in the slot and the next key maps to a menu command (application-shell: "The quit dialog stacks above an open overlay") +- [x] 6.6a With a running job's progress dialog and `quit_confirm` set, a `JobProgress` updates the progress and leaves `quit_confirm` set (application-shell: "Worker replies pass beneath the quit dialog"; design D2) +- [x] 6.7 Rewrite `input/tests.rs` `the_drive_dialog_outranks_the_menu` as a reducer test (`DriveListReady` with the menu open leaves `Overlay::DriveSelect`), and drop the menu half of `startup_warning_outranks_every_other_modal_and_typing_sink`, keeping its typing-sink assertions (design D6) +- [x] 6.8 A cross-check test that, for a set of states covering every `InputLayer` and every overlay variant, `map_key`, mouse `context`, and the render dispatch agree with `input_layer` (application-shell: "One precedence order everywhere") + +## 7. Verify + +- [x] 7.1 `cargo test --workspace` green, with `cargo insta test` reporting no changed snapshots +- [x] 7.2 `cargo clippy --workspace --all-targets -- -D warnings` clean +- [x] 7.3 `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` clean (the split moves items that rustdoc links point at) +- [ ] 7.4 Manual smoke test on Windows: open and close each overlay by keyboard and mouse, activate menu items that open dialogs, use the user menu → Themes, and click every dialog button at 60x16 and at a large size +- [x] 7.5 `openspec validate reducer-overlay-structure --strict` clean diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/.openspec.yaml b/openspec/changes/archive/2026-10-14-relative-destination-resolution/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/design.md b/openspec/changes/archive/2026-10-14-relative-destination-resolution/design.md new file mode 100644 index 0000000..8dbddb7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/design.md @@ -0,0 +1,117 @@ +# Design: relative-destination-resolution + +## Context + +All of the following was verified on `build/open-proposals-2026-10` @ `1bada6f`. + +**Every destination prompt funnels through one function.** F5/F6 (`enter_file_op_setup`), the file-action menu's Copy/Move, and a mouse drop (`Command::DragDrop` → `enter_file_op_setup_for_sources` with `buttons: Some(..)`) all open the same `FileOpSetup::DestinationInput { kind, sources, source_dir, input, buttons }`: + +- `source_dir` is the source panel's `cwd`. For a drop it is the panel the drag started on, which may be the inactive one. +- Enter (`FileOpConfirm`) and the drop dialog's `[ Copy ]`/`[ Move ]` (`FileOpConfirmAs`) both call `confirm_destination_input`. +- For Copy/Move that function builds `Job { dest_dir: PathBuf::from(trimmed), .. }`, with no join against `source_dir`. +- F7 opens the same setup with `kind: Mkdir` and builds `Job { dest_dir: source_dir, new_dir_name: Some(trimmed) }`, which `run_mkdir` joins as `dest_dir.join(name)`. +- The file-action menu's in-place Rename uses a separate `FileOpSetup::RenameInput` arm that builds `Job { dest_dir: source_dir, new_dir_name: Some(trimmed) }`, joined the same way by `run_rename`. + +**What a relative path does today.** + +- **The destination path.** `RealFs` passes every path through `fs_ops::path::to_extended_path`. That function lexically normalizes the path, prefixes `\\?\` only when the result `is_absolute()`, and otherwise returns it relative. Windows then resolves a relative path against the process's current directory. FileCommand never calls `set_current_dir`, so that directory is the launch directory for the whole session, and `backup` lands in `\backup`. +- **F7 and Rename.** These are mostly right, because `Path::join` handles relative names, `\x` (rooted: keeps the source's drive), absolute paths and UNC paths, and `to_extended_path` normalizes a joined `..`. Two forms still escape: a drive-relative `D:x` (`join` yields the relative `D:x`, resolved against the process's per-drive directory) and a bare `D:`. +- **The refresh after a job.** `FileOpRunning` keeps `dest_dir` verbatim, and `JobDone` refreshes panels by exact `PathBuf` equality (`panels_matching`). An unnormalized `C:\work\..\archive` therefore never matches a panel showing `C:\archive`. + +**The `cd` resolver.** `resolve_cd_target(cwd, target)` handles these cases: + +| Input | Result | +|---|---| +| `..` | `parent_path(cwd)`, or `None` at a root | +| `.` | `cwd` | +| UNC or absolute | as-is | +| Bare `X:` | `X:\` | +| Anything else | `cwd.join(target)` | + +It never normalizes, so `cd ..\x` sets the panel's `cwd` to `C:\work\..\x`. The listing's `to_long_path` then prefixes that as `\\?\C:\work\..\x`, and a probe confirmed that a verbatim path with a `..` component fails with OS error 123 ("The filename, directory name, or volume label syntax is incorrect"). The existence probe passes first, because it uses the non-verbatim path. A drive-relative `cd D:x` likewise yields the relative `D:x`. + +**The absolute-path invariant.** filesystem-error-handling "Canonicalization precedes prefixing" requires the abstraction to produce an absolute, normalized path before prefixing. Nothing enforces that the input is absolute. `to_extended_path` has callers outside `fs_ops`, though: `shell::spawn_safe_path` passes configured program names, which may legitimately be relative (`cmd.exe`), once they reach 240 characters, and `editor.rs` passes the panel path joined with the file name. + +## Goals / Non-Goals + +**Goals:** + +- A destination typed or edited in the F5/F6 dialog, reached from the keyboard, the file-action menu or a drop, is interpreted relative to the source panel's directory, with the same forms `cd` accepts. +- The resolved destination is absolute and normalized, so the job acts on the intended directory and the completion re-read finds the panel that shows it. +- F7 and in-place Rename names cannot escape to the process's working directory either. +- Relative paths reaching `RealFs` fail loudly in debug builds. + +**Non-Goals:** + +- Stripping surrounding quotes from a pasted destination (`"C:\x y"`, as Explorer's "Copy as path" produces). Today's literal handling is kept. A quoted path still fails at the filesystem with an error dialog, as it does now. +- Interpreting a destination naming a *file* as "copy under a new name" (NC's single-file rename-on-copy). The destination keeps meaning a directory. +- Case-insensitive panel matching for the completion re-read (`d:\Backup` vs `D:\backup`). +- Changing the `cd` existence probe or moving it off the UI thread (`offload-blocking-io`). +- Guarding against a destination that resolves to the source directory (`.`). `copy-onto-itself-guard` makes that a skipped "same file" item. + +## Decisions + +### D1: Resolve in the reducer, with `resolve_cd_target`, against the source panel's directory + +`confirm_destination_input` replaces `PathBuf::from(trimmed)` with `resolve_cd_target(&source_dir, trimmed)`: + +- `None` (only `..` at a drive or UNC root) returns the same `DestinationInput` phase unchanged, exactly like the existing empty-field branch. +- `Some(path)` becomes the job's `dest_dir` and the `FileOpRunning.dest_dir` used for the completion refresh. + +The resolution belongs in the reducer for two reasons. `source_dir` is already in hand there, and the pure function can be unit-tested without I/O. The audit's suggested `to_extended_path` join, by contrast, has no notion of the panel. Reusing `cd`'s resolver gives one set of rules users already know from the command line, and keeps `D:` meaning "that drive's root." + +*Alternative considered:* resolve in the worker, against `job.source_dir`. Rejected because the refresh matching in the reducer would still see the unresolved string. +*Alternative considered:* reject relative input with an error. Rejected: NC accepts relative destinations, and the field is a free-form path. + +### D2: `resolve_cd_target` always returns an absolute, normalized path + +Two additions make the helper's contract "absolute and normalized whenever `cwd` is": + +- **Drive-relative input (Windows only).** A target matching `X:` followed by a non-separator, such as `D:x`, resolves to `cwd.join(rest)` when `cwd` is on drive `X` (compared case-insensitively). Otherwise it resolves to `drive_root(X).join(rest)`. FileCommand has no per-drive current directory, and the source panel is the closest equivalent: when it is on that drive, it acts as the drive's current directory. On other platforms `D:x` is an ordinary file name and stays relative to `cwd`. +- **Normalization.** Every `Some` result is passed through `fs_ops::path::lexically_normalize`, whether it was joined or typed absolute (`C:\a\..\b` → `C:\b`). An already-clean absolute or UNC path comes out unchanged, and `..\archive` from `C:\work\proj` yields `C:\work\archive`. On Windows this matches what Win32 path normalization would do to the non-verbatim path. Lexical `..` on Unix can differ from the kernel's physical resolution through a symlink, an accepted difference on a best-effort platform. + +These also change `cd`/`del`/`rmdir`, which share the helper: + +- `cd ..\x` now lands on `C:\x` and lists successfully, instead of failing with error 123. +- `cd D:x`, `del D:x` and `rmdir D:x` act on a well-defined absolute path, instead of whatever the process's per-drive directory happens to be. + +The command-line spec's "resolve ... exactly as today" lists `.`, `..`, bare drive letters, UNC and relative paths, and those keep their meaning. `offload-blocking-io` keeps that wording in its MODIFIED `cd` and `del`/`rmdir` requirements. Neither requirement can be modified here as well, so this change ADDs a command-line requirement, "cd, del and rmdir targets resolve to a normalized absolute path". It states the two refinements, `..` removal and drive-relative resolution, without adding or removing a supported form, so it reads consistently with both the current and the `offload-blocking-io` wording. `resolve_cd_target`'s doc comment is updated to state the new contract. + +*Alternative considered:* a separate `resolve_destination` wrapper that leaves `cd` untouched. Rejected because `cd ..\x` is broken by the same missing normalization, and D4's debug assertion needs every path source, `del`/`rmdir` included, to produce absolute paths. Two resolvers with subtly different rules would also be worse than one. + +### D3: F7 and in-place Rename use the same resolver + +For Mkdir and Rename, the confirm handler computes `target = resolve_cd_target(&source_dir, trimmed)` and builds `Job { dest_dir: target.parent(), new_dir_name: target.file_name(), .. }`: + +- A plain name resolves to `source_dir\name`, giving exactly today's job. +- A path names its real parent directory. That makes `FileOpRunning.dest_dir` accurate, so a panel showing that parent re-reads on completion, which it does not today. +- A result with no file name (a drive or UNC root, or `None`) keeps the dialog open, like an empty field. + +The `Job` doc comment changes from "Rename sets `dest_dir` equal to `source_dir`" to "the resolved parent of the new name, which is `source_dir` for a plain name." + +The spec delta covers F7, because it is the same destination dialog. Rename's behavior for every name the file-action-menu spec describes is unchanged. Rename is routed through the resolver only so D4's invariant holds, and no file-action-menu delta is needed. All of file-action-menu's requirements are in flight elsewhere. + +### D4: `RealFs` debug-asserts absolute paths at its own boundary, not inside `to_extended_path` + +Every `RealFs` method, in both the Windows and the portable implementation, obtains its OS path through a private helper: + +```rust +fn ext(p: &Path) -> PathBuf { + debug_assert!(p.is_absolute(), "fs_ops received a relative path: {p:?}"); + to_extended_path(p) +} +``` + +On Windows, `is_absolute()` is true for drive-absolute, UNC and `\\?\` paths, and false for `\x` and `D:x`, both of which depend on process state. + +The assertion is not placed in `to_extended_path` itself, because `shell::spawn_safe_path` legitimately routes relative program names such as `cmd.exe` through it once they reach the 240-character threshold. The existing `extended_path_leaves_relative_alone` test keeps documenting that pass-through. The assertion costs nothing in release builds and turns any future relative-path leak into a test failure. + +## Risks / Trade-offs + +- [Behavior change for `cd`/`del`/`rmdir` with drive-relative or `..` input] → Both were unreliable before: they depended on process state, or produced an unlistable `\\?\…\..\…` path. The new results are what a user would expect. Unit tests pin them. +- [The debug assertion could fire in debug builds if a panel `cwd` were ever relative] One case: `app.rs` falls back to `PathBuf::from(".")` when `std::env::current_dir()` fails at startup. → This only matters in debug builds. A relative panel path already misbehaves in release builds, and the assertion makes it visible rather than hiding it. +- [Lexical normalization through a link] `C:\link\..\x` normalizes to `C:\x`, not to the link target's parent. → This is identical to Win32's own normalization of non-verbatim paths, which is what users already get from Explorer and `cmd`. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/proposal.md b/openspec/changes/archive/2026-10-14-relative-destination-resolution/proposal.md new file mode 100644 index 0000000..3eb3dab --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/proposal.md @@ -0,0 +1,59 @@ +# Change: relative-destination-resolution + +## Why + +Audit finding **R-06 (High)**: a relative destination typed into the F5/F6 dialog resolves against the directory FileCommand was *launched* from, not the panel the user is working in. `confirm_destination_input` builds the job with `dest_dir: PathBuf::from(trimmed)` and never joins it to the source panel's directory. `fs_ops::path::to_extended_path` leaves a relative path relative, so Windows resolves it against the process working directory. FileCommand never changes that directory, so it stays at the launch directory for the whole session. + +Typing `backup` or `..\archive` in the F6 dialog therefore moves the selection somewhere the user never intended, typically into the directory the app was started from. This also drifts from filesystem-error-handling "Canonicalization precedes prefixing," which requires the path abstraction to work on absolute paths. + +The same dialog is reached from the file-action menu's Copy/Move and from a mouse drop, whose field the user can edit, so all of these share the bug. The F7 make-directory name and the in-place Rename name are joined onto the panel directory, so plain and `..\` names already land correctly. Drive-relative forms (`D:x`) and a bare `D:` still escape as relative paths there. + +## What Changes + +- The F5/F6 destination resolves against the **source panel's directory**, using the same rules `cd` uses (`resolve_cd_target`). This covers the keyboard dialog, the file-action menu's Copy/Move and the drop-initiated dialog: + - A relative path is joined to the source directory: `backup` becomes `\backup`. + - `..` and `.` components are resolved: `..\archive` becomes `\archive`. + - A bare drive letter means that drive's root: `D:` becomes `D:\`. + - A drive-rooted path takes the source's drive: `\x` becomes `C:\x`. + - UNC and absolute paths are used unchanged. +- `resolve_cd_target` now always returns an absolute, lexically normalized path. Two inputs change: + - A drive-relative form `X:rest` resolves against the source directory when that directory is on drive `X`, and against `X:\` otherwise. It is no longer handed to the OS as a relative path. + - Every result is lexically normalized, so it contains no `.`/`..` components. + + The second point also fixes `cd ..\x`, which today sets the panel to an unnormalized path whose `\\?\`-prefixed listing fails with OS error 123 (verified). +- The F7 make-directory name and the in-place Rename name go through the same resolver. Plain names behave exactly as today. Drive-relative names stop escaping, and a path naming a different directory refreshes that directory's panel when the job completes. +- A destination that resolves to nothing (`..` at a drive root) keeps the dialog open unchanged, as an empty field does today. +- `RealFs` debug-asserts that every path it receives is absolute, so a future relative-path leak fails loudly in debug builds and tests instead of acting on the launch directory. +- Non-breaking. An absolute or UNC destination, the common pre-filled case, is unchanged. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `operation-dialogs`: new requirement "Relative destinations resolve against the source panel directory" for the F5/F6 destination field (keyboard, menu and drop-initiated) and the F7 make-directory name. It is ADDED rather than modifying "Destination input dialog," which `line-editing-in-text-fields` already modifies. +- `command-line`: new requirement "cd, del and rmdir targets resolve to a normalized absolute path", which pins the shared resolver's new contract for the command-line builtins. It is ADDED rather than modifying "cd navigates the active panel or rejects a nonexistent target" and "del and rmdir route into the existing delete-confirmation flow", which `offload-blocking-io` already modifies. + +## Impact + +- **Specs:** `operation-dialogs` (one added requirement), `command-line` (one added requirement). +- **Code:** + - `crates/filecommand-core/src/update.rs`: + - `confirm_destination_input` (lines 2918-2943) resolves `trimmed` against `source_dir` for Copy/Move and Mkdir. + - The `FileOpSetup::RenameInput` confirm arm (lines 2986-3018) does the same for Rename. + - `resolve_cd_target` (lines 1832-1851) adds drive-relative handling and lexical normalization through `fs_ops::path::lexically_normalize`. + - `crates/filecommand-core/src/fs_ops/fs.rs`: a `debug_assert!` on path absoluteness at the `RealFs` entry points, in both the Windows and portable implementations. + - `crates/filecommand-core/src/fs_ops/job.rs`: doc comment on `Job::dest_dir` (Mkdir/Rename now carry the resolved parent). + - Tests in `crates/filecommand-core/src/update/tests.rs`. + - No `filecommand-tui` changes. +- **Compatibility:** relative, drive-relative and `..`-containing destinations now go where the dialog's panel context says. `cd` with a `..`-containing or drive-relative path now lands on a normalized absolute directory. + +**Audit findings:** R-06 + +**Coordination:** +- `offload-blocking-io` (R-09) moves `dispatch_cd`'s and `dispatch_delete_builtin`'s existence probe off the UI thread and MODIFIES the command-line `cd` and `del`/`rmdir` requirements, keeping their "supported forms, exactly as today" wording. This change lands **first**: it is a "do now" data-safety fix, and it only changes the path `resolve_cd_target` hands to the probe, not the probe. Its ADDED command-line requirement defines how those same supported forms resolve, so the two deltas do not contradict each other. `offload-blocking-io` then rebases a small textual overlap in `update.rs` and probes the already-normalized path. +- `copy-onto-itself-guard` independently guarantees that a destination resolving to the source directory (for example `.`) never overwrites a file with itself. This change makes `.` mean the source directory but does not add that guard. That change's path fallback relies on the absolute destinations this change produces. +- `reducer-overlay-structure` (B-02) may move `confirm_destination_input` and the setup handlers into a sub-module. That is a mechanical rebase. diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/command-line/spec.md b/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/command-line/spec.md new file mode 100644 index 0000000..5317f43 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/command-line/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: cd, del and rmdir targets resolve to a normalized absolute path + +The path a `cd`, `del` or `rmdir` target resolves to SHALL be an absolute path with no `.` or `..` components, whenever the active panel's directory is absolute. The supported forms keep their meaning: `.`, `..`, a bare drive letter, a UNC path, an absolute path, and a path relative to the active panel's directory. Two forms are defined precisely: + +- A path containing `..` components, such as `..\x`, SHALL resolve to the directory it names after those components are removed, and SHALL NOT be kept as typed. +- A drive-relative path (`D:x`) SHALL resolve against the active panel's directory when that directory is on drive `D:`, and against `D:\` otherwise. It SHALL NOT depend on any process-wide current directory. + +These are the same rules the destination input dialog uses (operation-dialogs "Relative destinations resolve against the source panel directory"). The existence check, the navigation, and the delete-confirmation flow SHALL all use the resolved path. + +#### Scenario: cd with a parent-relative path lands on the normalized directory +- **WHEN** the active panel is at `C:\work\proj`, `C:\work\x` exists, and the command buffer is `cd ..\x` +- **THEN** the active panel navigates to `C:\work\x` and lists it successfully +- **AND** the panel's current directory contains no `..` component + +#### Scenario: cd with a drive-relative path resolves against the panel or the drive root +- **WHEN** the active panel is at `D:\work`, `D:\work\old` exists, and the command buffer is `cd D:old` +- **THEN** the active panel navigates to `D:\work\old` +- **AND** when the active panel is instead at `C:\work` and `D:\old` exists, the same command navigates to `D:\old` + +#### Scenario: del with a parent-relative path names the resolved file +- **WHEN** the active panel is at `C:\work\proj`, `C:\work\old.txt` is a file, and the command buffer is `del ..\old.txt` +- **THEN** the delete-confirmation dialog opens for `C:\work\old.txt`, and nothing is deleted until it is accepted diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/operation-dialogs/spec.md b/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/operation-dialogs/spec.md new file mode 100644 index 0000000..8a60bb3 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/specs/operation-dialogs/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Relative destinations resolve against the source panel directory + +A path the user confirms in the destination input dialog SHALL be interpreted relative to the **source panel's** current directory. This applies to the Copy (F5) and Rename/Move (F6) destination, however the dialog was opened (keyboard, file-action menu, or a drop), and to the make-directory (F7) name. The source panel is the panel whose entries the operation acts on; for a drop, it is the panel the drag started from. The path SHALL NOT be interpreted against the application's launch directory or any other process-wide current directory. + +The entered text SHALL resolve as follows: + +- A relative path is joined to the source panel's directory. +- `.` and `..` components are resolved. +- A bare drive letter (`D:`) means that drive's root. +- A drive-rooted path (`\x`) takes the source panel's drive. +- A drive-relative path (`D:x`) is taken relative to the source panel's directory when that directory is on drive `D:`, and relative to `D:\` otherwise. +- A UNC path or an absolute path is used unchanged. + +The resolved destination SHALL be an absolute path with no `.` or `..` components. The automatic panel re-read on completion SHALL match panels against this resolved path. If the entered text resolves to no directory (`..` at a drive root), the dialog SHALL stay open with its text unchanged and no operation SHALL start. + +#### Scenario: A relative name lands inside the source panel's directory +- **WHEN** the left panel shows `C:\work` with `report.txt` selected, FileCommand was launched from `C:\Users\me`, and the user presses F6, replaces the field with `backup`, and presses Enter +- **THEN** the move's destination is `C:\work\backup` +- **AND** nothing is moved into `C:\Users\me\backup` + +#### Scenario: A parent-relative path is resolved and normalized +- **WHEN** the source panel shows `C:\work\proj` and the user confirms the destination `..\archive` +- **THEN** the operation's destination is `C:\work\archive` +- **AND** if the other panel is showing `C:\work\archive`, it re-reads automatically when the operation completes + +#### Scenario: A bare drive letter means that drive's root +- **WHEN** the source panel shows `C:\work` and the user confirms the destination `D:` +- **THEN** the operation's destination is `D:\` + +#### Scenario: A drive-rooted path keeps the source panel's drive +- **WHEN** the source panel shows `C:\work` and the user confirms the destination `\archive` +- **THEN** the operation's destination is `C:\archive` + +#### Scenario: A drive-relative path resolves against the source panel or the drive root +- **WHEN** the source panel shows `D:\work` and the user confirms the destination `D:old` +- **THEN** the operation's destination is `D:\work\old` +- **AND** when the source panel instead shows `C:\work`, the same entry resolves to `D:\old` + +#### Scenario: UNC and absolute destinations are used unchanged +- **WHEN** the user confirms the destination `\\server\share\dest`, or `E:\backup` +- **THEN** the operation's destination is exactly `\\server\share\dest`, or `E:\backup`, regardless of the source panel's directory + +#### Scenario: An edited drop destination resolves against the drag's source panel +- **WHEN** entries dragged from the inactive right panel showing `D:\src` are dropped on the left panel, and the user replaces the drop dialog's field with `sub` and activates `[ Copy ]` +- **THEN** the copy's destination is `D:\src\sub` + +#### Scenario: A make-directory name with a parent path creates it where it points +- **WHEN** the active panel shows `C:\work\proj` and the user presses F7, types `..\shared`, and presses Enter +- **THEN** the directory `C:\work\shared` is created +- **AND** a panel showing `C:\work` re-reads when the operation completes + +#### Scenario: An unresolvable destination keeps the dialog open +- **WHEN** the source panel shows the drive root `C:\` and the user confirms the destination `..` +- **THEN** the dialog stays open with `..` in the field and no operation starts diff --git a/openspec/changes/archive/2026-10-14-relative-destination-resolution/tasks.md b/openspec/changes/archive/2026-10-14-relative-destination-resolution/tasks.md new file mode 100644 index 0000000..dcbd78e --- /dev/null +++ b/openspec/changes/archive/2026-10-14-relative-destination-resolution/tasks.md @@ -0,0 +1,53 @@ +# Tasks: relative-destination-resolution + +## 1. Shared resolver (`crates/filecommand-core/src/update.rs`) + +- [x] 1.1 In `resolve_cd_target`, on Windows only, treat a target of the form `X:` followed by a non-separator character (`D:old`) as drive-relative. Join the rest to `cwd` when `cwd` is on drive `X` (case-insensitive, via `drives::drive_letter_of`), and to `drives::drive_root(X)` otherwise (design D2) +- [x] 1.2 Pass every `Some` result, joined or typed absolute, through `fs_ops::path::lexically_normalize`, and update the doc comment to the "absolute and normalized whenever `cwd` is" contract (design D2) +- [x] 1.3 Unit tests in `update/tests.rs`: + - `C:\work\proj` + `..\archive` → `C:\work\archive` + - `C:\work` + `backup` → `C:\work\backup` + - `C:\work` + `\archive` → `C:\archive` + - `D:\work` + `D:old` → `D:\work\old` + - `C:\work` + `D:old` → `D:\old` + - UNC and absolute pass through unchanged + - `C:\` + `..` → `None` + - The existing `cd_dotdot_and_bare_drive_letters_resolve` and `cd_accepts_a_manually_entered_unc_path` still pass +- [x] 1.4 Test that `cd ..\x` begins a listing of the normalized `C:\…\x` path (the `StartListing` path has no `..` component) (command-line: "cd with a parent-relative path lands on the normalized directory"; design D2) +- [x] 1.5 Test that `cd D:old` from `D:\work` targets `D:\work\old` and from `C:\work` targets `D:\old`, and that `del ..\old.txt` from `C:\work\proj` opens the delete confirmation for `C:\work\old.txt` (command-line: "cd with a drive-relative path resolves against the panel or the drive root", "del with a parent-relative path names the resolved file"). If `offload-blocking-io` has landed, assert on the probe request's path instead of the synchronous outcome + +## 2. Destination dialog (`update.rs`) + +- [x] 2.1 In `confirm_destination_input`, for Copy/Move, set `dest_dir` from `resolve_cd_target(&source_dir, trimmed)`. On `None`, return the unchanged `DestinationInput` phase, as for an empty field (operation-dialogs: "Relative destinations resolve against the source panel directory"; design D1) +- [x] 2.2 In `confirm_destination_input`, for Mkdir, resolve the same way and build `Job { dest_dir: target.parent(), new_dir_name: target.file_name() }`. Keep the dialog open when there is no parent or file name (design D3) +- [x] 2.3 In the `FileOpSetup::RenameInput` confirm arm, resolve the same way and build `Job { dest_dir: target.parent(), new_dir_name: target.file_name() }`. A plain name must yield exactly today's job (design D3) +- [x] 2.4 Update the `Job::dest_dir` / `new_dir_name` doc comments in `fs_ops/job.rs` (design D3) + +## 3. RealFs invariant (`crates/filecommand-core/src/fs_ops/fs.rs`) + +- [x] 3.1 Add a private `ext(path)` helper with `debug_assert!(path.is_absolute(), …)` that returns `to_extended_path(path)`. Route every `RealFs` method in both `windows_impl` and `portable_impl` through it. Leave `to_extended_path` itself unasserted (design D4) + +## 4. Reducer tests (`crates/filecommand-core/src/update/tests.rs`) + +Use Windows-style absolute panel paths, behind `#[cfg(windows)]` where `Path` semantics require it, so the drive forms are meaningful. + +- [x] 4.1 F6 with source `C:\work`, field `backup`: `Effect::RunJob` carries `dest_dir == C:\work\backup` (scenario "A relative name lands inside the source panel's directory") +- [x] 4.2 `..\archive` from `C:\work\proj` gives `dest_dir == C:\work\archive`. With the other panel at `C:\work\archive`, a following `JobDone` re-reads that panel (scenario "A parent-relative path is resolved and normalized") +- [x] 4.3 `D:` gives `D:\`, and `\archive` gives `C:\archive` (scenarios "A bare drive letter means that drive's root", "A drive-rooted path keeps the source panel's drive") +- [x] 4.4 `D:old` from `D:\work` gives `D:\work\old`, and from `C:\work` gives `D:\old` (scenario "A drive-relative path resolves against the source panel or the drive root") +- [x] 4.5 `\\server\share\dest` and `E:\backup` are unchanged (scenario "UNC and absolute destinations are used unchanged") +- [x] 4.6 Drag from the inactive right panel (`D:\src`) to the left panel, edit the drop field to `sub`, `FileOpConfirmAs(Copy)`: `dest_dir == D:\src\sub` (scenario "An edited drop destination resolves against the drag's source panel") +- [x] 4.7 F7 `..\shared` from `C:\work\proj` gives `Job { dest_dir: C:\work, new_dir_name: shared }`, and `JobDone` re-reads a panel at `C:\work`. F7 `sub` yields exactly today's job (scenario "A make-directory name with a parent path creates it where it points") +- [x] 4.8 `..` from `C:\` keeps the `DestinationInput` phase with the text intact and emits no effects (scenario "An unresolvable destination keeps the dialog open") +- [x] 4.9 In-place Rename with a plain new name produces the same `Job` as before. `rename_case_only_edit_reaches_the_job_unchanged` still passes (design D3) +- [x] 4.10 Confirm the existing `destination_input_*`, `pre_filled_destination_accepts_unchanged_with_enter`, `file_action_menu_copy/move_*` and drop-dialog tests still pass. The `/left`/`/right` test paths compare equal after normalization because `Path` equality is component-wise + +## 5. Verify + +- [x] 5.1 `cargo test --workspace` green, including debug-build runs where the `RealFs` assertion is active +- [x] 5.2 `cargo clippy --workspace --all-targets` introduces no new warnings +- [x] 5.3 `openspec validate relative-destination-resolution --strict` clean +- [ ] 5.4 Manual: launch FileCommand from `C:\Users\`, navigate the left panel to a scratch directory, and confirm each of the following: + - F5 with `backup` copies into `\backup`, not the launch directory. + - F5 with `..\x` lands in the scratch directory's parent. + - `cd ..\x` on the command line lists normally. diff --git a/openspec/changes/archive/2026-10-14-resize-preserves-session-state/.openspec.yaml b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-resize-preserves-session-state/design.md b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/design.md new file mode 100644 index 0000000..56007d0 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/design.md @@ -0,0 +1,123 @@ +# Design: resize-preserves-session-state + +## Context + +This is the code on `build/open-proposals-2026-10` (`1bada6f`). + +- **Resize maps every phase to a placeholder phase.** `update_impl` handles `Command::Resize` before anything else (`update.rs:1202-1216`): + + ```rust + state.phase = match (&state.phase, too_small) { + (UiPhase::Placeholder, false) => UiPhase::Panels, + (UiPhase::Placeholder, true) => UiPhase::Placeholder, + (_, true) => UiPhase::Placeholder, + (other, false) => other.clone(), + }; + ``` + + `UiPhase::Editor(EditorState)`, `Viewer(ViewerState)`, `FileOpSetup(..)`, `FileOpRunning { .. }` and `FileOpSummary(..)` own their state, so shrinking the terminal drops it. `State::initial` also starts in `Placeholder` when the first size is too small (`update.rs:428`), which skips the splash. +- **Job events are dropped while the placeholder shows.** They are routed to `handle_file_op` by command type, whatever the phase (`update.rs:1328-1333`). `handle_file_op` does `mem::replace(&mut state.phase, Panels)` and, for any phase other than the three file-op phases, falls into `other => state.phase = other` (`update.rs:3084`), which drops the event. This confirms the audit: + - `JobConflict` and `JobError` are never shown, so the worker parks in `reply_rx.recv()` (`worker.rs:240`, `:250`). + - `JobDone` never triggers the panel re-read. + - After the user enlarges the terminal, the phase is `Panels`, so F5 starts a second job. `Effect::RunJob` overwrites `rt.active_job` (`app.rs:849`), which drops the first job's `reply_tx`. Its `recv()` then fails and it continues with `Skip` or `Abort`. +- **Quit from the placeholder does not cancel the job.** `ConfirmQuit` pushes `Effect::CancelJob` only when the phase is `FileOpRunning` (`update.rs:1302-1306`). From the placeholder, quit leaves the job thread running until the process exits. +- **The viewer's file stays open.** `rt.viewer_source` is set on `Effect::OpenViewer` and cleared only when `apply` sees `Command::ViewerClose` (`app.rs:801-803`). Once the phase has become `Placeholder`, `ViewerClose` can no longer be issued, so the `ByteSource` (an mmap on most files) lives for the rest of the session. +- **Input in the placeholder today.** + - `run` dispatches keys through `input::map_key` unless the phase is `Viewer` or `Editor` (`app.rs:368-393`). `Placeholder` is not one of those, so `map_key` runs. + - With no overlay open, `map_key` falls to `map_panel_key`. F10 and Esc produce `RequestQuit`, which `update_impl` handles globally (`update.rs:1293-1296`), so the quit-confirmation dialog opens. `render` draws it over the placeholder (`views/mod.rs:290-292`). + - Every other panel command reaches `UiPhase::Placeholder => {}` (`update.rs:1418`) and is ignored. + - With an overlay open (a menu, fuzzy jump, help, and so on — these fields survive a resize), `map_key` routes keys to that overlay's mapper, which the reducer applies to an overlay the user cannot see. This blind input is a latent hazard. It is not cited in the audit. + - Mouse input is ignored: `input::mouse::context` returns `Context::Ignored` for `Placeholder` (`input/mouse.rs:186`). The one exception is the quit-confirmation dialog: `context` checks `quit_confirm` first and returns `Context::DialogButtons`, and `build_hitmap` records the quit buttons whatever the phase, so its Yes/No buttons are clickable over the placeholder today. + - A drag in progress is cleared by the shrink, because `drag_allowed` requires `UiPhase::Panels`, so Esc never reaches `DragCancel` there. +- **Listing events and other replies.** Listing events, `DriveLabelResolved`, `InfoResolved`, `GitInfoResolved` and `TreeNodeExpanded` are already applied whatever the phase (`update.rs:1241-1287`). Replies that are handled inside the `Panels` arm, such as `EditorOpened`, `ViewerOpened`, `DriveListReady` and `ClipboardResult`, are swallowed by the `Placeholder` arm today. + +## Goals / Non-Goals + +**Goals:** + +- No user-visible state is lost by shrinking and re-enlarging the terminal. The splash is the one documented exception. +- A job keeps running and reporting while the placeholder shows. A conflict or error waits for the user and does not resolve by default. +- Input cannot act on a screen the user cannot see. +- The viewer's file handle is held exactly as long as the viewer exists. + +**Non-Goals:** + +- Rendering anything below 60x16 other than the placeholder and the two top-level modals (quit confirmation and startup warning) that are already drawn over it. +- Changing the floor, the degraded band, or `responsive-layout`. +- Restructuring the phase and overlay model as a whole. That is `reducer-overlay-structure` (B-02/B-08). +- Changing the viewer backend or the mmap SAFETY argument. That is `viewer-mapping-safety` (R-20). +- Quit-time job cleanup, the conflict-dialog cancel, and worker panic handling. Those are `process-lifecycle-hardening` (R-10/R-11/R-12/R-18). + +## Decisions + +### D1: `State::undersized: bool` replaces `UiPhase::Placeholder` + +Add a field `undersized` to `State` and remove the `Placeholder` variant. The `Command::Resize` arm becomes: + +- `state.term_size = (w, h)`. +- `state.undersized = State::too_small((w, h))`. +- If the terminal is now undersized and the phase is `Splash`, set the phase to `Panels` (D2). +- Re-clamp both panel viewports as today. + +No other phase is touched. With the phase intact, every existing handler keeps working on the hidden screen: `handle_file_op_running` for job events, `handle_editor` for `EditorSaved`/`EditorSaveFailed`, `handle_viewer` for `ViewerSearchResult`, and the `Panels` arm for `EditorOpened`/`ViewerOpened`/`DriveListReady`. The fall-through arm of `handle_file_op` stays as a defensive guard but is no longer reachable by a resize. + +*Alternative considered:* `Placeholder { previous: Box }`, which stashes the phase. This was rejected. Every handler that matches on the phase would have to look inside the box, including job events, editor saves and viewer search replies, which brings back the routing bug in a new form. The overlays that already live beside the phase (`menu`, `fuzzy_jump`, …) would still need separate handling. + +*Alternative considered:* skip `update` for resizes in the TUI and keep a size flag there. This was rejected. The floor check, the splash exception and the drag-clearing rule belong in the pure reducer, where they are unit-testable with no terminal. + +### D2: The splash exception and startup below the floor + +Shrinking during `Splash` sets the phase to `Panels` and the flag to `true`. Enlarging only clears the flag, so the splash cannot return (startup-splash "Shrinking below the floor mid-splash"). `State::initial` with a too-small size starts with the phase `Panels` and the flag `true`, so the splash is skipped (startup-splash "Below the floor at startup"). Both behaviors are unchanged from today. Only their encoding changes. + +### D3: Input gating lives in the TUI mapper + +`input::map_key` gets a first-checked arm for `state.undersized`, after the `quit_confirm` and `startup_warning` checks, which keep their keys because both dialogs are drawn over the placeholder. That arm is `map_undersized_key`, which returns `Some(Command::RequestQuit)` for F10 and Esc and `None` for every other key. + +- When the hidden phase is `Editor`, `map_undersized_key` returns `None` for F10 and Esc too. Otherwise a confirmed quit would discard unsaved edits without the editor's save-on-exit prompt. This matches full size, where the global quit dialog cannot be opened from inside the editor. +- In `app.rs`, the `run` key dispatch routes through `map_key` while `state.undersized` is set, ahead of the direct `map_viewer_key`/`map_editor_key` dispatch. This mirrors the existing `quit_confirm` precedence (`app.rs:368`). +- `apply_mouse_batch` skips the viewer/editor wheel arms while undersized and sends every event through `input::map_mouse`. In `input::mouse::context`, the undersized check goes after the existing `quit_confirm` check and returns `Context::Ignored`, so the quit dialog's buttons stay clickable exactly as today, and nothing else is. + +Compared with today's placeholder, the input rules differ in exactly two ways, both deliberate: + +- **Hidden overlay.** Today a key pressed while a menu, fuzzy jump, find file, help or another overlay is open is routed blindly to that overlay's mapper, which the reducer applies to an overlay the user cannot see. That stops. It was harmless only because nothing important survived the shrink; once the editor and dialogs survive, a blind Enter could confirm something. +- **Hidden editor.** F10 and Esc no longer open the quit dialog when the editor is the hidden screen. Today they do, but today the buffer is already gone by then. + +Without an overlay and outside the editor, the rules are today's: F10 and Esc open the quit dialog, every other key does nothing, mouse input does nothing except the quit dialog's buttons, and the quit-confirmation and startup-warning dialogs keep their keys. + +*Alternative considered:* gate in the reducer by ignoring key-derived commands while undersized. This was rejected. The reducer cannot tell a key-derived command from a reply of the same shape (`EditorSaved` and `JobConflict` must still be applied), and the TUI mapper already owns which keys reach which context. + +### D4: Quit from the placeholder + +`RequestQuit` and `ConfirmQuit` keep their global handling. Because the phase is preserved, `ConfirmQuit` during a hidden `FileOpRunning` now emits `Effect::CancelJob` as it does at full size. That fixes the quit-without-cancel path described in Context. Waiting for the cancellation to finish is `process-lifecycle-hardening`'s concern. + +### D5: Rendering while undersized + +`render_phase` draws `placeholder::render_placeholder` when `state.undersized` is set, whatever the phase, and returns no cursor. `render` still draws the quit-confirmation and startup-warning dialogs after it, unchanged. `build_hitmap` records nothing for the hidden phase while undersized; its existing phase-independent block still records the quit dialog's buttons when `quit_confirm` is set, which is the only part of the map consulted while undersized (D3). An otherwise empty map also keeps a stale panel hit map from outliving the shrink. The existing placeholder snapshots must stay byte-identical. + +### D6: Drag state and editor scrolling while undersized + +- `drag_allowed` adds `&& !state.undersized`. mouse-drag "Cancel and phase-change clear the drag" lists "resize below the minimum" as a trigger, and the phase no longer changes to signal it. +- `handle_editor` calls `editor.ensure_caret_visible(rows, cols)` from `editor_viewport(state.term_size)` on every command. `editor_viewport` already clamps to at least 1x1 (`update.rs:2234-2236`). Even so, a reply that arrives while undersized (`EditorSaved`/`EditorSaveFailed`) would re-scroll the buffer to fit a one- or two-row window. So `ensure_caret_visible` is skipped while undersized. On the first resize back above the floor, the `Resize` arm re-runs it for an `Editor` phase, so the caret is visible at the restored size. The buffer, caret and modified state are never touched by a resize. + +### D7: The viewer's byte source follows the viewer phase + +`apply` keeps the existing pre-`update` release on `ViewerClose`. It also records whether the phase was `Viewer` before `update` (the same before/after pattern it already uses for `editing_user_menu`) and drops `rt.viewer_source` when the phase was `Viewer` and no longer is. It additionally drops the source when a `ViewerOpened` was applied but the phase did not become `Viewer` (the reply was discarded). + +The release must be keyed to the transition, not to "the phase is not `Viewer`". Today `Effect::OpenViewer` stores the source in `rt.viewer_source` inside `run_effects` while the phase is still `Panels`, and the phase only becomes `Viewer` when the `ViewerOpened` it queued is drained later. Any command applied in between, such as a listing chunk already queued ahead of it, would see a non-`Viewer` phase and drop the freshly opened source, leaving the viewer to open with no source. + +With D1, the viewer phase survives a shrink, so the source is kept while the placeholder shows (the viewer must reappear at the same position) and is released when the viewer closes. The transition check makes "the handle is released when the viewer phase ends" an invariant, so a future phase transition cannot leak the mapping as `Placeholder` did. `viewer-mapping-safety` may change what `ByteSource` holds (an mmap or positioned reads). `offload-blocking-io` moves the open to a worker and installs the source only when `ViewerOpened` turns the phase into `Viewer`. This decision depends on neither. + +## Risks / Trade-offs + +- [The placeholder hides a conflict dialog the job is waiting on.] → The job waits, as at full size with the user away. Answering requires enlarging the terminal. Quitting from the placeholder cancels the job (D4). This is preferable to resolving conflicts by default without the user's answer. +- [`UiPhase::Placeholder` is used in tests and snapshot fixtures.] → Those fixtures move to `State { undersized: true, .. }`. The snapshot output is unchanged by construction (D5). +- [A future handler might read `term_size` and lay out at a sub-floor size.] → Rendering never runs a phase view while undersized (D5). Reducer-side viewport math clamps to at least 1, as `panel_viewport_rows` and `editor_viewport` already do. +- [Overlap with `reducer-overlay-structure`.] → That change must model `undersized` as a screen-level flag above all phases and overlays, not as an overlay that can be dismissed. + +## Migration Plan + +None. The flag lives only in memory, and no persisted format changes. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-resize-preserves-session-state/proposal.md b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/proposal.md new file mode 100644 index 0000000..b870f99 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/proposal.md @@ -0,0 +1,54 @@ +# Change: resize-preserves-session-state + +## Why + +Audit finding **R-03 (Critical)**: shrinking the terminal below the 60x16 floor throws away whatever screen was active. `Command::Resize` maps every phase to `UiPhase::Placeholder` (`(_, true) => UiPhase::Placeholder`, `update.rs:1202-1216`) and maps `Placeholder` back to `Panels` when the terminal grows again. `Editor`, `Viewer`, and `FileOpRunning` hold their state inside the phase, so that state is dropped: + +- **Editor**: unsaved edits are lost with no prompt. +- **Running job**: later `JobProgress`, `JobConflict`, `JobError`, and `JobDone` hit the `other => state.phase = other` arm of `handle_file_op` (`update.rs:3084`) and are dropped. A job that then needs a conflict or error answer blocks forever in `reply_rx.recv()`. The panels are never re-read when it completes. A second F5 replaces `rt.active_job`, which drops the old reply sender, so the orphaned job resumes with default answers while the new job runs. Confirming quit from the placeholder emits no `CancelJob`, because `ConfirmQuit` only cancels when the phase is `FileOpRunning`. +- **Viewer**: `rt.viewer_source` is cleared only on `ViewerClose`, which can no longer be reached, so the memory map stays open for the rest of the session. On Windows, this stops other programs from truncating the file. + +A Windows Terminal pane split, a window snap, or a font-size change is enough to trigger any of these. Work is lost silently, and a file operation can hang where the user cannot see it. + +## What Changes + +- "Terminal too small" becomes a flag beside the phase (`State::undersized`), no longer a phase. The placeholder is drawn over the active screen without discarding it. The panels, built-in editor, viewer, file-operation dialogs, running job, end-of-job summary, and open menus, dialogs, and overlays all persist while the terminal is undersized. They reappear unchanged when it is enlarged to at least 60x16. +- The startup splash is still dismissed when the terminal shrinks below the floor, and it does not return. A start below the floor still skips the splash. +- Worker results are still applied while the terminal is undersized: listing events, job progress, conflict, error, and completion, and replies to requests made before the shrink. A job that finishes while the placeholder shows still re-reads the affected panels. A conflict or error raised while undersized waits for an answer, and its dialog appears when the terminal is enlarged. +- Input never reaches the hidden screen. Mouse input is ignored, apart from the quit-confirmation dialog's buttons, as today. F10 and Esc open the quit-confirmation dialog over the placeholder, as they do today, except when the built-in editor is the hidden screen. There they are also ignored, so unsaved edits can only be discarded through the editor's own save-on-exit prompt. The quit-confirmation and startup-warning dialogs keep their keys, because both are drawn over the placeholder. +- `UiPhase::Placeholder` is removed. While undersized, rendering draws the placeholder (plus the quit or startup-warning dialog) in place of the phase. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `application-shell`: "Resize handling with 60x16 hard floor and placeholder". The placeholder preserves the active screen and its state, worker replies keep being applied, input routing while undersized is defined, and the splash exception is kept. All existing scenarios are kept verbatim, and new ones are added for each stateful screen. + +## Impact + +- `crates/filecommand-core/src/update.rs`: + - `UiPhase` (`:151`): remove `Placeholder`. + - `State`: new `undersized: bool`. `State::too_small` (`:403`) and `State::initial` (`:428`) change. + - The `Command::Resize` arm of `update_impl` (`:1202-1216`) changes. + - Remove the `UiPhase::Placeholder => {}` arm (`:1418`). + - `drag_allowed` (`:1164`) must also refuse while undersized (mouse-drag "resize below the minimum" clears the drag). + - `handle_editor` / `editor_viewport` (`:2234`) must not re-scroll against an undersized viewport. + - The fall-through arm in `handle_file_op` (`:3066-3087`) stays, but only as a defensive guard. +- `crates/filecommand-tui/src/app.rs`: + - The key dispatch in `run` (`:368`) must route through `input::map_key` while undersized, the same way it already does for `quit_confirm`. + - Viewer/editor wheel handling in `apply_mouse_batch` (`:535`) is skipped while undersized. + - `apply` (`:796-803`) adds a before/after check that releases `rt.viewer_source` when the phase leaves `Viewer` (or a `ViewerOpened` is discarded), not merely whenever the phase is not `Viewer`, because the synchronous open stores the source before the phase changes. +- `crates/filecommand-tui/src/input/mod.rs`: `map_key` (`:28`) gets an undersized arm, `map_undersized_key`. `input/mouse.rs` `context` (`:186`) returns `Context::Ignored` while undersized. +- `crates/filecommand-tui/src/views/mod.rs`: `render` and `render_phase` (`:264-307`, `:387-390`) draw the placeholder when `state.undersized` is set, whatever the phase. `build_hitmap` records nothing underneath it. +- Tests: regression tests for every stateful phase and overlay (shrink, then enlarge, then assert the phase is unchanged); job replies applied while undersized; input gating; the existing placeholder snapshots (`tests/snapshots.rs:138,154`, `tests/snapshot_views.rs:226`) re-expressed through the flag with byte-identical output. + +**Audit findings:** R-03 + +**Coordination:** +- `reducer-overlay-structure` (B-02/B-08) restructures the phase and overlay model. Whichever change lands second must carry `undersized` into that model as a screen-level flag above every phase and overlay. R-03 is Critical, so this change should land first. +- `viewer-mapping-safety` (R-20) changes the `ByteSource` backend and the hidden-viewer mapping rule. This change only relies on the source being released when the viewer closes. +- `process-lifecycle-hardening` (R-11/R-18) changes the job lifecycle on quit and adds a conflict-dialog cancel. Its quit-time cancel must not depend on the core phase. Either order works. diff --git a/openspec/changes/archive/2026-10-14-resize-preserves-session-state/specs/application-shell/spec.md b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/specs/application-shell/spec.md new file mode 100644 index 0000000..a83fb85 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/specs/application-shell/spec.md @@ -0,0 +1,113 @@ +## MODIFIED Requirements + +### Requirement: Resize handling with 60x16 hard floor and placeholder + +The system SHALL reflow the UI on terminal resize events, laying out the interface at any size at or above the 60x16 hard floor, and MUST draw a `screen.placeholder` "terminal too small" message instead whenever the terminal is below that floor, using a single size check that governs both normal and splash states. The placeholder message SHALL name the floor: "resize to at least 60x16". Between the floor and the 80x24 nominal size the interface renders in the degraded forms defined by the `responsive-layout` capability; at or above 80x24 it renders at full fidelity. + +The placeholder SHALL be drawn over the active screen without discarding it. While the terminal is below the floor, the following SHALL persist unchanged and SHALL reappear when the terminal is enlarged to at or above the floor: + +- the panels and their tabs, cursor, selection, quick filter, and command-line text; +- the built-in editor: its buffer including unsaved edits, caret position, insert/overwrite mode, and modified state; +- the F3 viewer: its file, position, and text/hex and wrap modes; +- a file-operation setup dialog with its typed input; +- a running file-operation job, with its progress, overwrite-conflict, or error-recovery dialog; +- the end-of-job skipped-files summary; +- any open pull-down menu, dialog, or overlay. + +The startup splash is the only exception: it SHALL be dismissed when the terminal shrinks below the floor and SHALL NOT return when the terminal is enlarged. + +Worker results SHALL continue to be applied while the terminal is below the floor. These include directory-listing events, file-operation progress, conflict, error, and completion events, and replies to requests made before the shrink. A job that completes while the placeholder is shown therefore still re-reads the affected panels, and a conflict or error raised while it is shown waits for the user's answer. + +While the terminal is below the floor, input SHALL NOT reach the screen hidden under the placeholder: + +- Mouse input SHALL be ignored, except that while the quit-confirmation dialog is open over the placeholder its buttons SHALL stay clickable. +- F10 and Esc SHALL open the quit-confirmation dialog, which is drawn over the placeholder. The exception is when the built-in editor is the hidden screen: there F10 and Esc SHALL be ignored, so unsaved edits are never discarded without the editor's save-on-exit prompt. +- While the quit-confirmation dialog or the startup-warning dialog is open over the placeholder, that dialog's own keys SHALL work as usual. +- Every other key SHALL be ignored. + +While the terminal is below the floor, the screen under the placeholder does not own input, so these rules take precedence over that screen's own key handling, including Esc's cancel/dismiss meaning in a hidden menu or dialog under "Quit request keys and confirmation". + +#### Scenario: Reflow at or above the floor + +- **WHEN** the terminal is resized to a size at or above 60x16 +- **THEN** the UI reflows and lays out its regions to the new dimensions + +#### Scenario: Degraded band renders panels, not the placeholder + +- **WHEN** the terminal is 70x20 — below the 80x24 nominal size but at or above the floor +- **THEN** the panels, command line, and key bar render in their degraded forms and the placeholder is not shown + +#### Scenario: Placeholder below the floor + +- **WHEN** the terminal is below 60 columns or below 16 rows +- **THEN** the "terminal too small" placeholder message is drawn instead of the normal layout, naming the 60x16 floor + +#### Scenario: Shrinking below the floor during splash + +- **WHEN** the terminal shrinks below 60x16 while the startup splash is showing +- **THEN** the placeholder replaces the splash, and the splash does not return when the terminal is enlarged again + +#### Scenario: Recovery when resized back up + +- **WHEN** the terminal is enlarged from below the floor back to at or above 60x16 +- **THEN** the normal layout is drawn again in place of the placeholder + +#### Scenario: Unsaved editor buffer survives a shrink + +- **WHEN** the built-in editor has unsaved edits with the caret on line 12 in overwrite mode, and the terminal shrinks to 50x12 and is then enlarged to 80x24 +- **THEN** the editor is shown again with the same buffer contents, the caret on line 12, overwrite mode, and the modified indicator +- **AND** nothing was written to disk and no prompt was raised while the placeholder was shown + +#### Scenario: Viewer position and mode survive a shrink + +- **WHEN** the F3 viewer shows a file in hex mode scrolled to offset 0x4000, and the terminal shrinks below the floor and is then enlarged +- **THEN** the viewer is shown again on the same file, in hex mode, at offset 0x4000 + +#### Scenario: Viewer closes normally after a shrink + +- **WHEN** the terminal shrinks below the floor and is enlarged while the viewer is open, and the user then closes the viewer +- **THEN** the panels return and the viewer no longer holds the file open + +#### Scenario: Running job keeps reporting progress while undersized + +- **WHEN** a copy job is running with its progress dialog shown and the terminal shrinks below the floor +- **THEN** the placeholder is shown, the job keeps running, and its progress events keep updating the hidden progress dialog +- **AND** when the terminal is enlarged before the job ends, the progress dialog reappears showing the job's latest progress + +#### Scenario: Conflict raised while undersized waits for an answer + +- **WHEN** a running copy job reaches an overwrite conflict while the terminal is below the floor +- **THEN** the job waits for an answer and the conflict is not resolved by default +- **AND** when the terminal is enlarged the overwrite-conflict dialog is shown for that file, and answering it resumes the job + +#### Scenario: Job finishing while undersized still refreshes the panels + +- **WHEN** a copy job completes while the terminal is below the floor +- **THEN** the panels showing the source and destination directories re-read while the placeholder is still shown +- **AND** when the terminal is enlarged the panels show the copied files, or the end-of-job summary when items were skipped + +#### Scenario: Open dialog and typed text survive a shrink + +- **WHEN** the F5 destination dialog is open with `D:\backup` typed and the terminal shrinks below the floor and is then enlarged +- **THEN** the destination dialog is shown again containing `D:\backup` with the caret where it was + +#### Scenario: Typing over the hidden editor does nothing + +- **WHEN** the built-in editor is the hidden screen while the terminal is below the floor and the user types letters, presses Enter, F2, F10, or Esc +- **THEN** the buffer, caret, and file on disk are unchanged, no save runs, and no quit-confirmation or save-on-exit dialog opens + +#### Scenario: F10 over the placeholder asks to quit + +- **WHEN** the panels are the hidden screen while the terminal is below the floor and the user presses F10 +- **THEN** the quit-confirmation dialog opens over the placeholder +- **AND** cancelling it leaves the placeholder shown and the hidden panels unchanged + +#### Scenario: Mouse input is ignored while undersized + +- **WHEN** the terminal is below the floor, no quit-confirmation dialog is open, and the user clicks or turns the mouse wheel anywhere in it +- **THEN** no command reaches the hidden screen and nothing about it changes + +#### Scenario: Quit dialog buttons stay clickable over the placeholder + +- **WHEN** the quit-confirmation dialog is open over the placeholder and the user clicks its No button +- **THEN** the dialog closes, the placeholder stays shown, and the hidden screen is unchanged diff --git a/openspec/changes/archive/2026-10-14-resize-preserves-session-state/tasks.md b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/tasks.md new file mode 100644 index 0000000..8c93790 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-resize-preserves-session-state/tasks.md @@ -0,0 +1,63 @@ +# Tasks: resize-preserves-session-state + +## 1. Core state and reducer (`crates/filecommand-core/src/update.rs`) + +- [x] 1.1 Add `pub undersized: bool` to `State` (`false` in `State::empty`). Remove `UiPhase::Placeholder` and its doc comment, and fix every match on `UiPhase` in both crates (design D1). +- [x] 1.2 `State::initial`: when `too_small(term_size)`, start with `phase = Panels` and `undersized = true`, so the splash is skipped (design D2; startup-splash "Below the floor at startup"). +- [x] 1.3 `Command::Resize` arm: set `term_size` and `undersized`. Turn `Splash` into `Panels` only when shrinking below the floor, and leave every other phase untouched. Keep the two `reconcile_panel_viewport` calls. When the terminal is enlarged out of the undersized state and the phase is `Editor`, run `ensure_caret_visible` against the restored viewport (application-shell "Resize handling with 60x16 hard floor and placeholder"; design D1, D2, D6). +- [x] 1.4 Delete the `UiPhase::Placeholder => {}` arm of the phase match. Keep the fall-through arm of `handle_file_op`, and update its comment to say it is defensive only (design D1). +- [x] 1.5 `drag_allowed`: add `&& !state.undersized` (design D6; mouse-drag "Cancel and phase-change clear the drag"). +- [x] 1.6 `handle_editor`: skip `ensure_caret_visible` while `state.undersized` (design D6). + +## 2. TUI input (`crates/filecommand-tui/src/input/`, `app.rs`) + +- [x] 2.1 `input::map_key`: after the `quit_confirm` and `startup_warning` checks, return `map_undersized_key(key, state)` when `state.undersized`. F10 and Esc map to `Command::RequestQuit` unless the phase is `Editor`. Everything else is `None` (design D3). +- [x] 2.2 `app::run` key dispatch: route through `input::map_key` when `state.quit_confirm || state.undersized`, ahead of the direct `map_viewer_key`/`map_editor_key` dispatch (design D3). +- [x] 2.3 `apply_mouse_batch`: when `state.undersized`, skip the viewer and editor wheel arms and route every event through `input::map_mouse`. `input::mouse::context` returns `Context::Ignored` when `state.undersized`, checked after the existing `quit_confirm` check so the quit dialog's buttons stay clickable (design D3). + +## 3. TUI rendering and runtime (`crates/filecommand-tui/src/views/mod.rs`, `app.rs`) + +- [x] 3.1 `render_phase`: when `state.undersized`, draw `placeholder::render_placeholder` and return no cursor, whatever the phase. `render` keeps drawing the quit-confirmation and startup-warning dialogs over it (design D5). +- [x] 3.2 `build_hitmap`: when `state.undersized`, record nothing for the phase; the existing quit-dialog block still records its buttons when `quit_confirm` is set (design D5). +- [x] 3.3 `apply`: keep the existing pre-`update` `ViewerClose` release. Record whether the phase was `Viewer` before `update`, and set `rt.viewer_source = None` when it was and no longer is, or when the command was `ViewerOpened` and the phase did not become `Viewer`. Never drop the source merely because the phase is not `Viewer` (design D7). +- [x] 3.4 TUI test for 3.3: run `Effect::OpenViewer`, apply an unrelated command (for example a `ListingChunk`) before the queued `ViewerOpened`, then apply `ViewerOpened`; `rt.viewer_source` is still set. Then shrink, enlarge and close the viewer; the source is `None` after the close and not before. + +## 4. Regression tests: state survives a shrink (core, `update/tests.rs`) + +- [x] 4.1 A helper that applies `Resize(50, 12)` and then `Resize(80, 24)` and asserts that `state.phase` and every overlay field are equal before and after, with `undersized` set in between. +- [x] 4.2 Run 4.1 for each stateful phase: + - `Editor` with a modified buffer, caret on line 12, and overwrite mode (also assert `is_modified()` and the caret); + - `Viewer` in hex mode with a non-zero top offset; + - `FileOpSetup::DestinationInput` with typed text; + - `FileOpSetup` delete confirmation; + - `FileOpRunning` with each of the `Progress`, `Conflict` (with and without `rename_input`) and `Error` dialogs; + - `FileOpSummary`. +- [x] 4.3 Run 4.1 over `Panels` with each overlay open: F9 menu, drive select, fuzzy jump, find file, user menu, history dialog, theme picker, file-action menu, help, quick filter, type-ahead, and command-line text. +- [x] 4.4 Splash: `Resize` below the floor during `Splash` gives `Panels` with `undersized` set, and enlarging stays in `Panels`. `State::initial` with a too-small size gives `Panels` with `undersized` set (startup-splash scenarios unchanged). +- [x] 4.5 Job replies are applied while undersized: + - `JobProgress` updates the hidden `Progress` dialog. + - `JobConflict` and `JobError` switch the hidden dialog. + - `JobDone` emits `StartListing` for the matching panels and moves to `Panels` or `FileOpSummary`, all with `undersized == true`. +- [x] 4.6 `EditorSaved`, `ViewerOpened` and `EditorOpened` arriving while undersized are applied to the hidden screen. +- [x] 4.7 `ConfirmQuit` while undersized over `FileOpRunning` emits `[CancelJob, Quit]`. Before this change it emitted only `Quit`. +- [x] 4.8 A drag in progress is cleared by `Resize` below the floor. + +## 5. TUI tests + +- [x] 5.1 `input` tests: + - while undersized over `Editor`, letters, Enter, F2, F10 and Esc all map to `None`; + - over `Panels` and over `Viewer`, F10 and Esc map to `RequestQuit` and other keys to `None`; + - with `quit_confirm` set, its keys still map; + - with `startup_warning` set, dismiss still maps. +- [x] 5.2 `input::mouse` tests: `context` returns `Ignored` while undersized for every phase, and `DialogButtons` while undersized with `quit_confirm` set; a click on the quit dialog's No button over the placeholder maps to `DialogButtonClick(ButtonId::QuitNo)`. Update `input/mouse/tests.rs:593`. +- [x] 5.3 Snapshots: rewrite the placeholder fixtures in `tests/snapshots.rs:138,154` and `tests/snapshot_views.rs:226` as `State { undersized: true, .. }`. Their accepted snapshots must stay byte-identical. Add a snapshot of the placeholder drawn while the phase is `Editor`, which must be identical to the panels case, and one of the quit dialog over the placeholder. + +## 6. Verification + +- [x] 6.1 `cargo test --workspace` passes. +- [x] 6.2 `cargo clippy --workspace --all-targets` reports no new warnings. +- [x] 6.3 `openspec validate resize-preserves-session-state --strict` is clean. +- [ ] 6.4 Manual check on Windows Terminal: + - Open the editor, type without saving, split the pane until the placeholder shows, then close the split. The edits are still there. + - Start a large copy, shrink below the floor, wait for it to finish, then enlarge. The panels show the copied files. + - Open F3, shrink, enlarge, then press Esc. The file can then be deleted by another program. diff --git a/openspec/changes/archive/2026-10-14-streaming-listing-performance/.openspec.yaml b/openspec/changes/archive/2026-10-14-streaming-listing-performance/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-streaming-listing-performance/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-streaming-listing-performance/design.md b/openspec/changes/archive/2026-10-14-streaming-listing-performance/design.md new file mode 100644 index 0000000..c2aa26c --- /dev/null +++ b/openspec/changes/archive/2026-10-14-streaming-listing-performance/design.md @@ -0,0 +1,122 @@ +# Design: streaming-listing-performance + +## Context + +Verified on `build/open-proposals-2026-10` (commit `1bada6f`): + +- **Reader.** `FsReader` has one method, `read_dir(&self, &Path) -> io::Result>` (`listing.rs:439-441`). `StdFsReader::read_dir` (`listing.rs:465-481`) loops over `std::fs::read_dir` and pushes every entry into a `Vec` before returning. `list_dir_chunked` (`listing.rs:499-513`) calls it, then slices the finished vector with `raw.chunks(chunk_size)` and clones each raw entry into an `Entry`. `worker::spawn_listing` (`worker.rs:178-203`) passes `CHUNK_SIZE = 256`. Nothing reaches the UI until enumeration has finished (P-01 confirmed). Other `FsReader` users: `worker::gather_info`, `listing::list_child_dirs`, `listing::find_in_subtree`, and test fakes in `listing.rs` and `worker.rs`. +- **Apply.** `apply_listing_event` (`update.rs:3848-3852`) calls `PanelState::insert_streamed` once per entry; `insert_streamed` (`panel.rs:467-475`) calls `insert_sorted` (`panel.rs:1001-1004`: `partition_point` + `Vec::insert`). While the user has not moved, the cursor is pinned to 0; once moved it is only clamped, so its **index** stays fixed and the entry under it changes whenever an entry is inserted above it. Per chunk the handler then runs the in-flight re-read anchor (`apply_reread_anchor`, `panel.rs:589-611`) and `reconcile_panel_viewport`. +- **Drain.** `drain_events` (`app.rs:828-839`) is `while let Ok(cmd) = rx.try_recv() { apply(..) }` with no budget, called at the top of every loop iteration and again just before drawing (`app.rs:350`, `app.rs:443-447`). +- **Visible list.** `visible_indices()` (`panel.rs:696-701`) returns `(0..len).collect()` without a filter and, with one, lowercases every name and the pattern per entry. Callers: `ensure_cursor_visible` (`panel.rs:406`), `ensure_cursor_visible_brief` (`panel.rs:432`), `move_cursor_filtered` (`panel.rs:452`), `apply_reread_anchor` (`panel.rs:606`), `snap_cursor_to_visible` (`panel.rs:740`), `update::jump_to_prefix` (`update.rs:2020`, which also clones every visible `Entry`), and per frame `views/panel.rs` `hit_test` (`:385`), Full-mode rows (`:611`), Brief-mode rows (`:739`). +- **Comparators.** `lower_name` (`listing.rs:147-149`) allocates; `cmp_by_name`, `cmp_by_extension`, `cmp_by_time`, `cmp_by_size` (`listing.rs:162-183`) call it for both operands (the latter two on ties). +- **Benchmark.** `crates/filecommand-tui/tests/bench_100k_ignored.rs` feeds `insert_sorted` names that are *already in sorted order* (`file-000000.txt`, …), so every insert lands at the end and nothing shifts: its 248–258 ms is almost entirely comparator allocation (P-04), not the quadratic shift P-02 describes — the shift cost only shows for unsorted arrival order, which the benchmark does not exercise. The render bench assigns `panel.entries` directly. +- **Unnoticed by the audit:** the in-flight re-read anchor resolves the cursor with `names.iter().find_map(|n| entries.iter().position(..))` on every chunk. Until the anchored entry has arrived, each chunk costs O(captured names × entries so far) — for a re-read of a 100k-entry directory with the cursor mid-list that is billions of comparisons per chunk, a far larger stall than P-02. + +## Goals / Non-Goals + +**Goals:** + +- First entries and a live `Reading… N` count while enumeration is still running, on local disks and slow shares alike. +- No listing work blocks input or drawing for more than a frame-sized slice; a worker flood can never starve input. +- Total streaming cost close to one bulk sort; per-frame cost proportional to visible rows, with or without a quick filter. +- Entries still appear in sorted position as they arrive (existing "Streaming listing mini-status" requirement), and the cursor stays on its entry once the user has moved it. +- Measured, documented numbers that replace the stale spike conclusion. + +**Non-Goals:** + +- Request ids and cancellation of superseded listings (`listing-request-guard`, which lands first). +- How links and unreadable entries are classified (`link-aware-listing`, R-05/R-21). +- Quick-filter cursor edge cases at roots without `..`, hidden selections, and `wildcard_match` (`filter-selection-safety`, R-17/R-19). +- Idle redraw, key coalescing, progress throttling (`idle-aware-event-loop`, P-05/P-06/P-11). +- `hit_test` cloning each visible row's name and duplicated dialog geometry (B-08, `reducer-overlay-structure`). +- A CI perf gate (B-03/B-09 CI part, `ci-and-build-tooling`); this change keeps `bench_100k_ignored` as a manual measurement. +- The selection mini-status total. While any entry is selected, `selection_status` → `selected_bytes` (`panel.rs:669-671`) walks every entry with a set lookup on each frame. That is O(n) per frame only while a selection exists and is not cited by the audit; the requirement's frame-cost clause is scoped to "no entries selected" and a cached selection total is left to a follow-up. + +## Decisions + +### D1: Streaming reader via a callback with a `Vec` default + +Add to `FsReader`: + +```rust +fn for_each_entry(&self, path: &Path, f: &mut dyn FnMut(RawDirEntry) -> ControlFlow<()>) -> io::Result<()> { + for e in self.read_dir(path)? { if f(e).is_break() { break; } } + Ok(()) +} +``` + +Fakes keep implementing only `read_dir`. `StdFsReader` overrides `for_each_entry` with the real per-entry loop over `std::fs::read_dir` and implements `read_dir` by collecting through it, so `gather_info`, `list_child_dirs`, and `find_in_subtree` are untouched. `ControlFlow::Break` lets the listing worker stop mid-enumeration when `listing-request-guard`'s `CancelFlag` is set — the check moves from "between chunks" to "per entry". + +*Alternatives:* return an `Iterator` — rejected: a boxed iterator over `ReadDir` with per-item `io::Result` and `to_long_path` lifetimes is clumsier for fakes and gains nothing over a callback on a dedicated worker thread. A channel per entry — rejected: per-entry sends are exactly the flood the batching avoids. + +### D2: Batches bounded by a growing count and ~50 ms, decided by a pure policy + +`list_dir_streaming(reader, path, cancel, policy, now: &dyn Fn() -> Instant, on_chunk)` replaces `list_dir_chunked`. It flushes the batch when either + +- the batch reaches `clamp(sent_so_far / 4, 256, 16_384)` entries — the size grows geometrically with what has already been sent, so the number of batches for *n* entries is O(log n) on fast media, which keeps D3's total merge cost near-linear; or +- at least 50 ms have passed since the last flush when the next entry arrives — so slow media still show progress at ~20 Hz. + +The `..` chunk is still sent first. `BatchPolicy::should_flush(batch_len, sent, since_last_flush)` is a pure function, and the clock is injected, so the policy is unit-tested without sleeping. The cap bounds the worst single merge (D3) at large *n*. + +*Alternatives:* fixed 256-entry chunks — ~390 merges for 100k entries, each O(n), ≈20M element moves; acceptable but wasteful, and pathological at 1M. Time-only batching — a fast local enumeration would produce one giant batch, i.e. P-01 again. + +### D3: Sort each batch, merge once — not per-entry insert, not sort-on-complete + +`PanelState::merge_streamed(batch: Vec)` stable-sorts the batch with `cmp_entries` (cheap with D4 keys), then merges it into `entries` in one O(n + k) pass: extend `entries` by `k`, then merge from the back so no second buffer is allocated. Ties keep existing entries first, matching today's `insert_sorted` (`partition_point(.. != Greater)`). `Unsorted` mode appends, which is enumeration order. During the same pass the merge counts how many batch entries land before the cursor's old index: if the user has moved, `cursor += that count` (the cursor stays on its entry); otherwise it stays pinned at 0. Selection is keyed by name and needs nothing. On a same-directory re-read, the in-flight re-read anchor (D6) is applied after the merge, exactly as today, and still decides the cursor while the re-read streams; the requirement's "stays on its entry" clause defers to it explicitly. + +*Alternatives:* +- *Append and sort once on `ListingComplete`* — the cheapest total (one sort), but entries would show in enumeration order while streaming and then reshuffle at completion, with the cursor's entry jumping; it breaks the existing "Entries SHALL be inserted in sorted position as they arrive" and "Cursor holds on first row during streaming" contract. Rejected. +- *Append and fully re-sort per batch* — O(n log n) per batch, strictly worse than a merge. +- *Keep per-entry insert with cheaper keys* — fixes P-04's constant but not P-02's O(n²) shift for unsorted arrival (Unix `readdir`, ReFS, network redirectors). Rejected. + +### D4: A lowercase key computed once per entry + +`Entry` gains a private `sort_key: Box` (`name.to_string_lossy().to_lowercase()`) set by `Entry::new(name, kind, size, modified, hidden)`, `Entry::parent_dir()`, and `From` — the only constructors — with a `sort_key()` accessor. `cmp_by_name` compares keys; `cmp_by_extension` takes `extension_of` on the keys; `cmp_by_time`/`cmp_by_size` break ties on keys. Quick-filter matching becomes `entry.sort_key().contains(pattern_lower)` with the pattern lowercased once per edit. Orderings are byte-for-byte what they are today (same lowercase function, same tie-breaks), which a proptest pins against the old allocating comparators. The key is private so no struct literal can construct an `Entry` with an inconsistent key; the ~60 literal sites (mostly tests and the benchmark) move to `Entry::new`. `name` stays `pub` for reading; nothing in production mutates it after construction, and the field doc says so. + +Memory: one extra boxed string per entry (≈ name length + 16 bytes; ≈4 MB at 100k entries), accepted. + +*Alternative:* allocation-free per-comparison case folding (`chars().flat_map(char::to_lowercase)`) — no struct change, but still O(name length) per comparison, slightly different from `str::to_lowercase` for word-final `Σ`, and does nothing for filter matching. Rejected. + +### D5: Cached visible list; identity without a filter + +`PanelState` gains a private `filtered: Option>`: `None` while no quick filter is active (the visible list is the identity, never materialized), `Some(ascending entry indices)` while one is. It is rebuilt — one O(n) pass over precomputed keys — by every method that changes the entries or the pattern: `merge_streamed`, `resort`/`set_sort_mode`, `begin_new_listing`, `quick_filter_push`/`_backspace`/`activate_quick_filter`/`clear_quick_filter`, `adopt_tab_data` (the cache is not stashed in `TabData`; it is rebuilt from the restored pattern), and a new `set_entries(Vec)` for wholesale replacement (tests, benchmark). Accessors: + +- `visible_len()`; +- `visible_at(pos) -> Option` (entry index at a visible position); +- `visible_position(entry_index) -> Option` — identity, or `binary_search` in the ascending list (O(log n)); +- `visible_window(start, len) -> impl Iterator` for rendering. + +All production callers listed in Context switch to these; `ensure_cursor_visible*` become O(log n); `jump_to_prefix` matches over the visible window without cloning entries. `visible_indices()` stays as an allocating convenience documented as not for per-frame use (tests and the TUI integration test call it). A `debug_assert!` in the accessors checks cached indices are in range, catching a direct `entries` mutation that bypassed `set_entries`. + +*Alternatives:* a lazily recomputed cache behind `RefCell` with a generation stamp — interior mutability in `State` and a stamp that direct `entries` writes would not bump; rejected. Recomputing only per frame in the TUI — leaves the reducer's per-keystroke O(n) work. Rejected. + +### D6: Re-read anchor resolution proportional to the batch + +When the in-flight re-read anchor is captured, also build `rank: HashMap` giving each captured name its priority in the existing resolution order (`names[cursor]` = 0, then `names[cursor+1..]`, then `names[..cursor]` reversed). `merge_streamed` looks up only the **batch's** entries in the map and keeps the best rank seen so far plus that entry's index (updated by the merge's position bookkeeping). The cursor resolves to the best-ranked present name — the same answer as today's scan, at O(k) per batch instead of O(names × entries). The anchor's scroll-offset restore and clamp are unchanged. + +This is a pure re-implementation: the resolved cursor and offset are re-applied after every batch exactly when today's `apply_reread_anchor` would apply them (whenever some captured name is present, whether or not the best rank changed with this batch), the anchor is still consumed on `ListingComplete` after find-file's deferred settle, and `cursor_user_moved` is set on a hit as today. So every scenario of the in-flight "Same-directory re-read preserves cursor and scroll position" holds unchanged, including the case where a better-ranked name arrives in a later batch. A re-sort while the re-read streams (Ctrl+F3..F6) invalidates the tracked index; it is re-found with one linear scan for the best-ranked name, still O(n) rather than O(names × entries). + +### D7: Budgeted drain with a backlog signal + +`drain_events` stops after 64 commands or 8 ms, whichever comes first, and reports `backlog: bool`. Because `mpsc::Receiver` cannot peek, a message taken by the budget check is held in a one-slot buffer on `Runtime` and applied first by the next drain. When `backlog` is set, the loop polls input with a zero timeout, then draws, then continues draining — so input and redraw interleave with a flood instead of waiting for it. The pre-draw drain in the `dirty` branch uses the same budget. + +### D8: Benchmarks are the acceptance measurement + +`bench_100k_ignored.rs` stays the measurement (`cargo test --release -p filecommand-tui --test bench_100k_ignored -- --ignored --nocapture`) and gains: streamed merge of 100k entries in D2's batch sizes, sorted and shuffled arrival; the old per-entry insert with shuffled arrival (for the record); 100k entries applied through `update()` `ListingChunk`s end to end; re-sort by each mode; render at 1k and 100k entries with and without an active quick filter. The spec scenarios compare numbers measured in the same run (merge total ≤ 3× bulk sort; 100k render ≤ 2× 1k render), so they hold on any machine. The spike document is re-run and rewritten with the new numbers, and its "render cost is independent of directory size" claim is replaced by what the new measurement shows. + +## Risks / Trade-offs + +- [A single merge at ~1M entries still moves the whole vector (tens of MB) in one reducer call] → the 16,384-entry batch cap keeps batches few (~60 at 1M) and each merge a few milliseconds on current hardware; D7 keeps input interleaved between merges. If measurement at 1M shows frame-sized stalls, see the Open Question. +- [The 50 ms bound is checked when an entry arrives, so entries read just before an enumeration stall (for example the end of one SMB directory page) wait in the batch until the next entry or the end of enumeration] → the wait is at most one server round trip; the requirement states the bound as "while entries keep arriving". A flush timer on a second thread would remove it and is left until a measurement on a slow share shows it matters. +- [A fed-back command (drive enumeration) queued behind a worker flood can miss the frame it used to land on] → only under a flood, and only by one frame; the budget is what keeps the flood from starving input. +- [The cursor following its entry is a visible change for users who move during streaming] → it replaces a drift where the highlighted entry silently changed underneath a stationary cursor; covered by a new scenario. +- [Tests that assign `panel.entries` directly with an active filter would read a stale cache] → `set_entries` plus the debug assertion; the migration task covers the quick-filter test module. +- [Extra memory for keys] → ≈4 MB per 100k entries. + +## Migration Plan + +None — no persisted data or configuration changes. + +## Open Questions + +- Whether directories in the million-entry range need a different structure during streaming (for example an unsorted tail merged on a timer) — decide from the 1M measurement taken while implementing D8; the requirement is written against the 100k benchmark. diff --git a/openspec/changes/archive/2026-10-14-streaming-listing-performance/proposal.md b/openspec/changes/archive/2026-10-14-streaming-listing-performance/proposal.md new file mode 100644 index 0000000..426f2e6 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-streaming-listing-performance/proposal.md @@ -0,0 +1,43 @@ +## Why + +The panel spec promises that a large directory streams in — a running `Reading… 12,345` count and a partial listing that stays interactive — but the code does not deliver it, and the way it applies listing data can freeze the UI (audit 2026-10-12, §6 "Streaming listing mini-status": **DRIFT**): + +- **P-01 (High):** `StdFsReader::read_dir` collects the entire directory into a `Vec` before `list_dir_chunked` slices it into chunks, so a 100k-entry directory or a slow network share shows `Reading… 0` for the whole enumeration and then receives every chunk in one burst. +- **P-02 (High):** every entry of every chunk is inserted one at a time with `partition_point` + `Vec::insert` (O(n²) for unsorted arrival), inside a `drain_events` loop with no budget. Because P-01 delivers all chunks at once, the whole insert runs before the loop returns to input or drawing — no key, resize, or redraw is processed meanwhile, contradicting "a partially filled panel is fully interactive". The audit's release run measured 257.8 ms for 100k streamed inserts vs 13.1 ms for one bulk sort; 1M entries (a large `node_modules` or log directory) extrapolates to multi-second freezes. +- **P-03 (Medium):** `visible_indices()` allocates an O(n) `Vec` on every call — four times per frame for rendering and hit-testing, again for each cursor move and viewport re-clamp — and with a quick filter it lowercases every name and the pattern per entry per call. Render cost at 100k entries is now 1.237 ms/frame (the spike recorded 0.209 ms and concluded "independent of directory size"; that conclusion is stale). +- **P-04 (Medium):** the name comparators call `lower_name` — `to_string_lossy().to_lowercase()` — for both operands on every comparison, so every sort, re-sort (Ctrl+F3..F6), and insert allocates two `String`s per comparison; this dominates the measured insert cost. + +## What Changes + +- The directory reader streams: a new callback-style `FsReader` enumeration hands entries to the listing worker as the OS returns them (tests keep the `Vec` API through a default method), and the worker sends batches bounded by a growing entry count and by ~50 ms of enumeration time, so the first entries and a live count appear while enumeration continues. The cancel check introduced by `listing-request-guard` moves inside the enumeration. +- Each batch is sorted once and merged into the panel's sorted list in a single linear pass (no per-entry mid-list insertion). Entries still appear in sorted position as they arrive; once the user has moved the cursor during streaming, the cursor stays on its entry while batches land around it. +- `Entry` carries a lowercase name key computed once when the entry is read; all name-based comparators and quick-filter matching use it instead of allocating per comparison. +- `PanelState` caches the quick-filter-narrowed index list, rebuilt only when the entries or the pattern change; with no filter the visible list is the identity and is never materialized. Rendering, hit-testing, cursor movement, and viewport re-clamping use non-allocating accessors. +- The event loop drains worker messages under a budget (~8 ms or 64 messages) before it reads input and draws; a backlog makes the next input poll non-blocking so the rest is applied on the following iterations. +- The in-flight `reread-preserves-cursor` anchor lookup, which scans the whole captured name list against the whole listing on every batch, becomes proportional to the batch size. +- The 100k-entry benchmark is extended (streamed merge, shuffled arrival, quick-filter render, re-sort) and the spike document is re-run and corrected. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `panel-navigation`: ADDED "Large directories stream progressively and stay responsive". + +## Impact + +- **Core (`crates/filecommand-core`):** + - `listing.rs` — `FsReader::for_each_entry` (default implemented over `read_dir`), streaming `StdFsReader`, `list_dir_chunked` replaced by a batching `list_dir_streaming` with an injectable clock and batch policy, `Entry::new` + private `sort_key`, comparators (`cmp_by_name`, `cmp_by_extension`, `cmp_by_time`, `cmp_by_size`) and `lower_name` reworked. + - `panel.rs` — `merge_streamed` replaces `insert_streamed`; `insert_sorted` kept only for callers that insert single entries; cached visible-index list with `visible_len`/`visible_at`/`visible_position` accessors; `matches_quick_filter`, `ensure_cursor_visible`, `ensure_cursor_visible_brief`, `move_cursor_filtered`, `snap_cursor_to_visible`, `apply_reread_anchor`, `adopt_tab_data`, `resort`, `set_sort_mode`, `begin_new_listing` maintain or use the cache; `set_entries` for wholesale replacement. + - `update.rs` — `apply_listing_event` calls `merge_streamed` once per chunk; `jump_to_prefix` uses the accessors. + - All `Entry { .. }` struct literals (core, TUI, tests, benchmarks) move to `Entry::new`. +- **TUI (`crates/filecommand-tui`):** `worker.rs` `spawn_listing` drives `list_dir_streaming`; `app.rs` `drain_events` gains a budget and a backlog signal used by the loop's poll; `views/panel.rs` `hit_test`, `render_panel` (Full) and the Brief body iterate only the window via the accessors. +- **Benchmarks/docs:** `crates/filecommand-tui/tests/bench_100k_ignored.rs` (kept as the measurement, extended), `docs/spikes/c-100k-entry-render-benchmark.md` (re-run, numbers and conclusion updated — the spike half of B-09). +- **No persisted-format, config, or dependency changes.** + +**Audit findings:** P-01, P-02, P-03, P-04 (plus the spike-document refresh part of B-09) + +**Coordination:** `listing-request-guard` **lands first** — this change builds on its listing request ids and the `CancelFlag` parameter of the listing worker. `link-aware-listing` changes how `StdFsReader` classifies each entry (and R-21's skip-unreadable rule); that per-entry body moves into `for_each_entry` here, so whichever lands second rebases its classification onto the other. `filter-selection-safety` (R-17) changes quick-filter cursor logic that now reads the cached visible list. `idle-aware-event-loop` restructures the same event loop; its poll-timeout logic must honor the drain backlog introduced here. The in-flight `reread-preserves-cursor` anchor is re-implemented for per-batch cost without changing its behavior. diff --git a/openspec/changes/archive/2026-10-14-streaming-listing-performance/specs/panel-navigation/spec.md b/openspec/changes/archive/2026-10-14-streaming-listing-performance/specs/panel-navigation/spec.md new file mode 100644 index 0000000..cbcb17c --- /dev/null +++ b/openspec/changes/archive/2026-10-14-streaming-listing-performance/specs/panel-navigation/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Large directories stream progressively and stay responsive + +The system SHALL show a directory's entries progressively while the directory is still being enumerated: the reader SHALL hand entries to the UI in batches bounded by an entry count and, while entries keep arriving, flushed at least about every 50 ms of enumeration time, so that the first entries and a growing `Reading… N` count appear before enumeration finishes, on local disks and slow network shares alike. Applying listing data SHALL NOT stall input handling or redrawing: each batch SHALL be merged into the sorted listing as a whole, in time proportional to the listing's size plus the cost of sorting the batch, never by inserting entries one at a time into the middle of the list; and the event loop SHALL apply at most about 8 ms or 64 messages of queued worker results before it processes pending input and redraws. Once the user has moved the cursor while a listing streams, arriving entries SHALL NOT move the cursor off the entry it is on, except where a same-directory re-read restores the cursor as "Same-directory re-read preserves cursor and scroll position" specifies. Name-based ordering and quick-filter matching SHALL use a lowercase name key computed once per entry when the entry is read, and the list of entries visible under the quick filter SHALL be recomputed only when the entries or the filter pattern change, so that, while no entries are selected, drawing a frame, hit-testing the mouse, and moving the cursor cost time proportional to the rows shown rather than to the directory's size. + +#### Scenario: First entries appear before enumeration finishes + +- **WHEN** a directory on a slow network share takes 20 seconds to enumerate and its entries arrive steadily throughout +- **THEN** entries and a growing `Reading… N` count are shown within about 50 ms of the first entries being read and keep growing while enumeration continues + +#### Scenario: Input stays responsive while a 100,000-entry directory streams in + +- **WHEN** a 100,000-entry directory is streaming in, many batches are already queued, and the user presses Down +- **THEN** the key is processed and the screen redrawn after at most about 8 ms or 64 queued worker messages, without waiting for the remaining batches to be applied + +#### Scenario: Streaming 100,000 entries costs about one sort + +- **WHEN** the release-build benchmark `bench_100k_ignored` streams 100,000 entries, in shuffled arrival order, into a panel in the reader's batch sizes +- **THEN** the total time spent applying the batches is at most three times the time of one bulk sort of the same 100,000 entries measured in the same run + +#### Scenario: Cursor stays on its entry while entries stream in + +- **WHEN** the user has moved the cursor to `report.txt` while the listing is still streaming, and a batch arrives containing entries that sort before `report.txt` +- **THEN** the cursor is still on `report.txt` after the batch is applied, and the entries remain in sorted order + +#### Scenario: Frame cost does not grow with directory size + +- **WHEN** the benchmark renders frames of a 100,000-entry panel and of a 1,000-entry panel with no entries selected, once with no quick filter and once with an active quick filter +- **THEN** a 100,000-entry frame takes no more than twice as long as a 1,000-entry frame under the same conditions + +#### Scenario: Orders are unchanged by precomputed keys + +- **WHEN** a listing is sorted by name, extension, time, or size, ascending or descending +- **THEN** the resulting order is identical to the case-insensitive orders and tie-breaks the panel produced before keys were precomputed, with `..` first diff --git a/openspec/changes/archive/2026-10-14-streaming-listing-performance/tasks.md b/openspec/changes/archive/2026-10-14-streaming-listing-performance/tasks.md new file mode 100644 index 0000000..116942d --- /dev/null +++ b/openspec/changes/archive/2026-10-14-streaming-listing-performance/tasks.md @@ -0,0 +1,54 @@ +# Tasks: streaming-listing-performance + +Prerequisite: `listing-request-guard` is merged (listing request ids and the listing worker's `CancelFlag`). + +## 1. Precomputed sort keys (`crates/filecommand-core/src/listing.rs`) + +- [x] 1.1 Add private `Entry::sort_key: Box` with `Entry::new(name, kind, size, modified, hidden)`, `sort_key()`, and key computation in `Entry::parent_dir()` and `From` (design D4) +- [x] 1.2 Rework `cmp_by_name`, `cmp_by_extension`, `cmp_by_time`, `cmp_by_size` to compare `sort_key()`; remove `lower_name` (design D4) +- [x] 1.3 Proptest: new comparators agree with the previous allocating implementation (kept in the test module) for every mode and direction (panel-navigation "Orders are unchanged by precomputed keys") +- [x] 1.4 Migrate every `Entry { .. }` struct literal in core, TUI, tests, and `bench_100k_ignored.rs` to `Entry::new` + +## 2. Streaming reader and batching (`listing.rs`, `crates/filecommand-tui/src/worker.rs`) + +- [x] 2.1 Add `FsReader::for_each_entry` with a default over `read_dir`; implement it in `StdFsReader` as the real per-entry loop and implement `StdFsReader::read_dir` by collecting through it (design D1) +- [x] 2.2 Add `BatchPolicy` (`min 256`, `max 16_384`, growth `sent / 4`, `max_age 50 ms`) with a pure `should_flush(batch_len, sent, since_last_flush)` and unit tests for each bound (design D2) +- [x] 2.3 Replace `list_dir_chunked` with `list_dir_streaming(reader, path, cancel, policy, now, on_chunk)`: break the enumeration via `ControlFlow` when the cancel flag is set; flush per the policy; flush the remainder at the end (design D1, D2) +- [x] 2.4 Tests with a fake reader and an injected clock: batches grow geometrically on fast input; an entry stream advancing the clock 60 ms per entry flushes every entry; a flag set mid-enumeration stops reading and sends nothing further (panel-navigation "First entries appear before enumeration finishes") +- [x] 2.5 `worker.rs` `spawn_listing`: drive `list_dir_streaming` with `Instant::now` (keeping the `..` chunk first and the request id on every reply) + +## 3. Merge per batch (`crates/filecommand-core/src/panel.rs`, `update.rs`) + +- [x] 3.1 Add `PanelState::merge_streamed(Vec)`: stable-sort the batch, back-to-front in-place merge with existing-first ties, `Unsorted` appends; cursor pinned at 0 until the user moves, otherwise advanced by the number of batch entries merged before it (design D3) +- [x] 3.2 `update.rs` `apply_listing_event`: call `merge_streamed` once per `ListingChunk` instead of `insert_streamed` per entry; remove `insert_streamed` +- [x] 3.3 Re-read anchor: build the name→rank map at capture; `merge_streamed` resolves the best-ranked present name from the batch only and keeps its index current across later merges; `apply_reread_anchor` keeps its offset restore/clamp (design D6) +- [x] 3.4 Tests: merge result equals a bulk stable sort of all entries for random batch splits and every sort mode (proptest); cursor stays on its entry across a batch that sorts before it (panel-navigation "Cursor stays on its entry while entries stream in"); cursor stays at 0 when the user has not moved; existing re-read anchor tests pass unchanged plus one where the anchored entry arrives in the last batch of many, one where a better-ranked captured name arrives after a worse-ranked one (cursor moves to it, as today), one where the user moves during a re-read stream (the next batch re-applies the anchor, as today), and one with a Ctrl+F3 re-sort mid-re-read (design D6) + +## 4. Cached visible list (`panel.rs`, `update.rs`, `crates/filecommand-tui/src/views/panel.rs`) + +- [x] 4.1 Add private `filtered: Option>`, rebuilt in `merge_streamed`, `resort`, `set_sort_mode`, `begin_new_listing`, the quick-filter mutators, `adopt_tab_data`, and new `set_entries` (design D5) +- [x] 4.2 Add `visible_len`, `visible_at`, `visible_position`, `visible_window`; quick-filter matching uses `sort_key()` and a once-lowercased pattern (design D4, D5) +- [x] 4.3 Switch `ensure_cursor_visible`, `ensure_cursor_visible_brief`, `move_cursor_filtered`, `snap_cursor_to_visible`, `apply_reread_anchor`, and `update::jump_to_prefix` (no entry clones) to the accessors +- [x] 4.4 Switch `views/panel.rs` `hit_test`, the Full-mode row loop, and the Brief-mode body to `visible_len`/`visible_window`; scrollbar thumb uses `visible_len` +- [x] 4.5 Keep `visible_indices()` as a documented allocating convenience; migrate tests that assign `entries` under an active filter to `set_entries`; add a `debug_assert!` that cached indices are in range +- [x] 4.6 Tests: cache equals a from-scratch filter after every mutator, including across a tab switch and a merge with an active filter; all existing quick-filter, scrolling, and Brief-mode tests pass; insta snapshots unchanged + +## 5. Budgeted drain (`crates/filecommand-tui/src/app.rs`) + +- [x] 5.1 `drain_events`: stop after 64 commands or 8 ms, hold the next taken message in a one-slot `Runtime` buffer applied first by the next drain, and return a `backlog` flag (design D7) +- [x] 5.2 Main loop: when `backlog` is set, poll input with a zero timeout and draw before draining further; the pre-draw drain uses the same budget (panel-navigation "Input stays responsive while a 100,000-entry directory streams in") +- [x] 5.3 Tests: a channel pre-filled with 1,000 commands is applied in budgeted slices with order preserved and nothing lost; the held message is applied first next time + +## 6. Benchmark and spike document + +- [x] 6.1 Extend `crates/filecommand-tui/tests/bench_100k_ignored.rs`: streamed merge in policy batch sizes (sorted and shuffled arrival), old per-entry insert with shuffled arrival (reference), 100k entries applied through `update()` chunks, re-sort by each mode, render at 1k and 100k with and without a quick filter; print the ratios the spec uses (design D8) +- [x] 6.2 Run `cargo test --release -p filecommand-tui --test bench_100k_ignored -- --ignored --nocapture`; confirm merge total ≤ 3× bulk sort and 100k render ≤ 2× 1k render (panel-navigation "Streaming 100,000 entries costs about one sort", "Frame cost does not grow with directory size") +- [x] 6.3 Rewrite `docs/spikes/c-100k-entry-render-benchmark.md` with the new method, numbers, and conclusions, replacing the stale "render cost is independent of directory size" claim and noting that the original insert bench used pre-sorted input +- [x] 6.4 Record a one-off 1M-entry measurement in the spike document to settle the design's open question + +## 7. Verify + +- [x] 7.1 `cargo test --workspace` green +- [x] 7.2 `cargo clippy --workspace --all-targets` with no new warnings +- [x] 7.3 `openspec validate streaming-listing-performance --strict` clean +- [ ] 7.4 Manual (Windows): open a 100k-entry directory and a large directory on a network share → `Reading… N` grows while entries fill in sorted order; holding Down while it streams moves the cursor smoothly diff --git a/openspec/changes/archive/2026-10-14-viewer-mapping-safety/.openspec.yaml b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/.openspec.yaml new file mode 100644 index 0000000..b9ebfc7 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-12 diff --git a/openspec/changes/archive/2026-10-14-viewer-mapping-safety/design.md b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/design.md new file mode 100644 index 0000000..28ef424 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/design.md @@ -0,0 +1,121 @@ +# Design: viewer-mapping-safety + +## Context + +Verified on `build/open-proposals-2026-10` (`1bada6f`). + +**The byte source.** `crates/filecommand-core/src/viewer/byte_source.rs` defines `enum Backend { Mmap(memmap2::Mmap), File(File) }` and `ByteSource { len, backend }`. +- `ByteSource::open` calls `File::open`, snapshots `metadata().len()`, and then tries `unsafe { memmap2::Mmap::map(&file) }`. It falls back to `Backend::File` when mapping fails. Mapping fails for a zero-length file, and for some network or special paths. +- `open_chunked` forces the `File` backend. It exists only so a test can compare the two backends. +- `read_range(offset, len)` clamps to the snapshot length, then: + - Mmap: copies `mmap[offset..offset+n].to_vec()`. + - File: allocates `vec![0; n]` and fills it with `positioned_read_to_eof`, a loop over `seek_read` (Windows) or `read_at` (Unix) that handles short reads. It maps an I/O error to an empty result (`unwrap_or(0)`) and truncates the buffer to the bytes actually read. +- On targets that are neither Windows nor Unix, `positioned_read` is a stub returning `Ok(0)`. An unmappable file then renders as empty without any error. + +**Who reads, and how much.** Every consumer reads a bounded window and works on the returned `Vec`: +- The text renderer reads `DEFAULT_CHUNK_SIZE` (64 KiB) plus a UTF-8 margin per frame (`views/viewer.rs` `read_text_window`). +- Line navigation reads up to 64 KiB per step (`forward::next_line_start`, `backward::previous_line_start`). +- F7 search (`search::find_forward`) streams 64 KiB windows with overlap. +- Quick View (`views/panel.rs:883`, `render_quick_view_body`) opens a fresh `ByteSource` on the previewed file on every frame it draws. + +The mmap path never avoids the copy, so the only thing it saves compared with the fallback is one `ReadFile`/`pread` call per window. + +**Lifetimes.** The viewer's source lives in `Runtime::viewer_source` (`app.rs:61`). It is set on `Effect::OpenViewer` (`app.rs:926-937`) and cleared on `Command::ViewerClose` (`app.rs:801-803`). `spawn_viewer_search` (`worker.rs:133-145`) opens its own `ByteSource` and keeps it until `find_forward` returns. That can take a full pass over a multi-GB file, and the search cannot be cancelled today (audit P-10). + +**Sharing.** `std::fs::File::open` on Windows already requests `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`, so the *handle* does not block other programs. What blocks them on Windows is the *mapping*: a file with a mapped view cannot be truncated (`ERROR_USER_MAPPED_FILE`). A review probe on this machine confirmed both halves: with a read-only mapped view open, `SetLength` from a second handle failed with error 1224, and with only a shared read handle open, the same call succeeded. On Unix nothing blocks the writer, and the mapping instead turns a truncation into `SIGBUS` on the next access to a page past the new end. + +**Spec.** `openspec/specs/viewer/spec.md`, "Instant open of arbitrarily large files", says the viewer "MUST memory-map the file (falling back to positioned chunk reads when mapping is unavailable)". Its scenarios refer to "the mmap path" and "the mapped snapshot". No in-flight change has a delta on the `viewer` capability. The MODIFIED requirement changes the existing text in three places, all because the mapping is gone: + +- The description's "mapped length" becomes "file length". +- The scenario "Mapping unavailable falls back to chunk reads" describes a fallback that no longer exists. It is replaced by "Network and special files are read the same way", which keeps its intent: network and special paths do not panic, and the rendered content is identical to any other read of the same byte range. +- The THEN of "Offsets clamped to the snapshot length" says "does not read past the snapshot length" instead of "the mapped snapshot". Its WHEN is unchanged. + +## Goals / Non-Goals + +**Goals:** + +- No `unsafe` code in the viewer, and no way for another process to crash FileCommand by changing the file it shows. +- While the viewer is open, other programs can keep appending to, truncating, deleting, and renaming the file on Windows. +- Keep the existing contract: O(1) open, bounded reads, the snapshot length, clamped offsets, and identical rendering. +- Release the file promptly when the viewer closes, including from a search worker. + +**Non-Goals:** + +- Live "tail" behavior, where the viewer would notice that the file grew and extend its snapshot. The snapshot stays fixed at open, as the spec already requires. +- Making F7 search cancellable. That is `listing-request-guard` (P-10). This change only depends on it (D5). +- Changing the viewer's lifetime across a terminal resize. That is `resize-preserves-session-state` (R-03, its D7). +- Reducing Quick View's open-per-frame cost. It stays as it is. It is now a plain open and a read, with no map and unmap. +- The `unsafe` FFI elsewhere in core (`drives.rs`, `fs_ops/fs.rs`). That is `ci-and-build-tooling` (B-07). + +## Decisions + +### D1: Positioned reads on every platform; drop the mmap backend and `memmap2` + +`ByteSource` becomes `{ len: u64, file: File }`, and `read_range` always uses `positioned_read_to_eof`. + +Rationale: +- Every read already copies into a `Vec`, so mapping saves no memory traffic. +- All reads are bounded (at most about 64 KiB plus margin per call), so the extra cost is one system call per window. The data comes from the OS page cache, the same place a page fault would have taken it from. +- Sequential F7 scans benefit from the OS read-ahead that positioned reads trigger. +- Removing the mapping removes both platform hazards at their source: there is no `SIGBUS` on Unix and no truncation lock on Windows. +- It also deletes the only `unsafe` block in the viewer, and one dependency. + +**Scope: every mapping goes.** The F3 viewer, the F7 search worker and Quick View all obtain their bytes through `ByteSource::open`, so removing the backend removes all three mappings. That includes Quick View's map-and-unmap on every frame (`render_quick_view_body`), which today briefly pins the previewed file on Windows each frame and could fault on Unix if the file is truncated mid-frame. Quick View needs no code change of its own. + +*Alternative considered: keep mmap on Windows only and use positioned reads on Unix.* This is safe on Windows in the memory-safety sense, but it keeps the user-visible Windows hazard: a viewed log cannot be truncated or rotated by its writer. It also keeps two code paths and an `unsafe` block with a platform-specific SAFETY argument. No reason to keep it was found. Speed is not one, because both paths copy. **Rejected.** + +*Alternative considered: keep mmap and add a `SIGBUS` handler on Unix.* A signal handler that recovers from a fault inside safe Rust code is itself unsound and hard to test. **Rejected.** + +*Alternative considered: use mmap only for files that have not been modified recently.* This is the audit's suggestion for non-Windows systems. A heuristic still crashes when the guess is wrong, and it does nothing for the Windows rotation problem. **Rejected.** + +### D2: Explicit Windows share mode on open + +`ByteSource::open` opens through `OpenOptions::new().read(true)` plus `std::os::windows::fs::OpenOptionsExt::share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)` (the values `0x1 | 0x2 | 0x4`, declared as named constants beside the call). The standard library's default happens to be the same today. Writing it out makes the guarantee in the spec visible in the code, and it can be tested (D6) instead of being an unstated property of `std`. + +### D3: The snapshot rule is unchanged; shrinking files read short, never fault + +`len` is still captured once at open, and every read is still clamped to it. If the file shrinks while it is open, `positioned_read_to_eof` stops at the real end of file (`Ok(0)`) and `read_range` returns the bytes that exist, possibly none. + +The existing consumers already handle short and empty windows. Verified: +- `next_line_start` treats a short window as end of file. +- `previous_line_start` returns `scan_start` for an empty window. +- `find_forward` stops when a chunk comes back shorter than requested. +- `scroll_lines` and `scroll_to_end` stop once a scan makes no progress. + +So a truncated file shows blank rows past its new end, and navigation stays bounded. A file that grows is shown only up to the snapshot length, exactly as today. I/O errors keep today's behavior: they are mapped to an empty window, never to a panic. + +### D4: Replace the `Ok(0)` stub with a seek-and-read fallback + +On targets that are neither Windows nor Unix, `positioned_read` seeks the handle (`(&file).seek(SeekFrom::Start(offset))`) and then reads. `&File` implements `Read` and `Seek`, so `read_range(&self)` keeps its signature. A `ByteSource` is used by one thread at a time: the render loop owns the viewer's source, and the search worker opens its own. The shared cursor therefore cannot race. A comment records this so a future change that shares a source across threads has to revisit it. + +*Alternative considered: `compile_error!` on other targets.* Cross-platform builds are best-effort in this project, and a working slow path is better than no build. **Rejected.** + +### D5: Releasing the file on close + +- **Viewer.** No change. `apply` drops `rt.viewer_source` on `Command::ViewerClose`. After `resize-preserves-session-state`, it also drops it whenever the phase stops being `Viewer`. Because the handle shares delete, the file can be deleted even while it is open on Windows; releasing on close is what lets the directory that contains it be removed afterwards. +- **F7 search worker.** The worker's `ByteSource` is a local inside the spawned closure, so it is dropped when `find_forward` returns. `listing-request-guard` (P-10) passes a cancel flag into `find_forward` and sets it when the viewer closes or the search is superseded. Together they meet "search workers release the file when the viewer closes". This change adds the regression test (task 3.4) and does not add its own cancellation mechanism. Two mechanisms for the same flag would conflict, which is why the proposal asks to land after `listing-request-guard`. +- **Quick View.** Its source is a local in `render_quick_view_body` and is dropped at the end of each frame. + +### D6: Tests exercise the platform guarantees through real files + +These are `byte_source.rs` unit tests on temporary files. Each opens a `ByteSource`, then uses a second handle to: +- append, and assert that the append succeeds and that reads stay clamped to the snapshot length; +- truncate (`set_len`), and assert that the truncation succeeds and that reads past the new end come back short or empty without panicking; +- delete (`remove_file`) and rename the file, and assert that both succeed while the source is open. + +On Windows the truncate test fails against today's mmap backend with OS error 1224 (`ERROR_USER_MAPPED_FILE`), so it is a genuine regression test. On Unix the same test pins "no SIGBUS". The mmap-versus-chunked parity test is removed together with `open_chunked`. + +## Risks / Trade-offs + +- [Throughput on very fast scrolling or wheel bursts is about one system call per 64 KiB window, compared with a page-cache memcpy.] → The copy already happens on both paths. Wheel input is coalesced (`app.rs:454-523`), so a burst is still a bounded number of reads per frame. +- [A truncated file shows blank rows up to the old length until the viewer is reopened.] → This is the documented snapshot rule. A follow-up could offer re-reading the length, which is out of scope here. +- [On Windows with POSIX delete semantics, a deleted file's name disappears at once while the viewer still shows its contents.] → This is the intended effect of sharing delete. The viewer shows what it opened. +- [Removing `open_chunked` changes a `pub` API.] → Its only callers are tests in the same module. + +## Migration Plan + +None for users. Code: remove `memmap2` from both manifests and refresh `Cargo.lock`. No config, data, or snapshot changes are expected, because the rendered bytes are identical. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-10-14-viewer-mapping-safety/proposal.md b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/proposal.md new file mode 100644 index 0000000..e43aa97 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/proposal.md @@ -0,0 +1,45 @@ +# Change: viewer-mapping-safety + +## Why + +Audit finding **R-20 (Low)**: the F3 viewer memory-maps the file it shows (`unsafe { memmap2::Mmap::map(&file) }`, `crates/filecommand-core/src/viewer/byte_source.rs:37`). The `unsafe` block has no `// SAFETY:` comment. Its module doc says reads are safe "even if the on-disk file changes underneath us", but that is only partly true, and in a different way on each platform: + +- **Linux and other Unix systems:** if another process truncates the file while it is mapped, the next access to a page past the new end raises `SIGBUS` and kills FileCommand. Viewing a live log that gets rotated by truncation can crash the app. +- **Windows:** the mapping cannot be invalidated, but it pins the file. While the viewer is open, the writer cannot truncate the file (`SetEndOfFile` fails with `ERROR_USER_MAPPED_FILE`), so a program that rotates its log by truncation fails for as long as the user looks at the log. Each F7 search opens a second mapping on its own worker thread (`crates/filecommand-tui/src/worker.rs:133-145`), and Quick View maps the previewed file again on every frame (`crates/filecommand-tui/src/views/panel.rs:883`). + +The mapping gives nothing back for this risk. Every read already copies the bytes out (`mmap[..].to_vec()`), in windows of at most 64 KiB plus a small margin. The positioned-read fallback already in the same file does the same work with one system call per read. + +## What Changes + +- The viewer reads files only through bounded positioned reads on an open handle, on every platform. It no longer memory-maps them. The `Mmap` backend, the `unsafe` block, the `open_chunked` test-only constructor, and the `memmap2` dependency are removed. This covers every `ByteSource` user: the F3 viewer, its F7 search worker, and Quick View's per-frame preview. +- On Windows the viewer opens the file with `FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`, stated explicitly in code rather than left to the standard library's default. Other programs can keep appending to, truncating, deleting, or renaming the file while it is shown. +- A file that shrinks while it is shown never crashes the app. Reads past the new end return only the bytes that still exist. Navigation stays clamped to the length captured at open, as it is today. +- On targets that are neither Windows nor Unix, the stub that returns `Ok(0)` (and so makes the viewer silently show nothing) is replaced by a seek-and-read fallback. +- The viewer, and any F7 search worker still scanning for it, release the file when the viewer closes. +- The `viewer` spec stops requiring memory mapping, and states the sharing and truncation guarantees. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `viewer`: "Instant open of arbitrarily large files". Memory mapping is replaced by positioned reads on every platform. The mmap-fallback scenario becomes a scenario that network and special files are read the same way. New scenarios cover concurrent writers, truncation while viewing, and releasing the file on close. + +## Impact + +- `crates/filecommand-core/src/viewer/byte_source.rs`: `Backend` enum removed, so `ByteSource` holds `{ len, file }`. `ByteSource::open` opens with explicit Windows share flags. `open_chunked` is removed. `read_range` always goes through `positioned_read_to_eof`. The `cfg(not(any(windows, unix)))` stub becomes a seek-then-read fallback. The module doc and tests are rewritten. +- `crates/filecommand-core/Cargo.toml` and the workspace `Cargo.toml`: `memmap2` is removed from `[dependencies]` and `[workspace.dependencies]`. `Cargo.lock` drops `memmap2`. +- `crates/filecommand-tui/src/worker.rs`: the `spawn_viewer_search` doc comment no longer mentions "a fresh mmap open". The worker's `ByteSource` is scoped to the search. +- `crates/filecommand-tui/src/app.rs`: the `Effect::OpenViewer` comment ("opening/mapping") is updated. No behavior change. The viewer source is still released on `Command::ViewerClose`. +- `crates/filecommand-tui/src/views/panel.rs` (`render_quick_view_body`): no code change. Quick View now opens without mapping. +- Tests in `byte_source.rs` (truncate, append, delete, and rename while open), plus a TUI test that closing the viewer drops the source. + +**Audit findings:** R-20 + +**Coordination:** +- `resize-preserves-session-state` (R-03) keeps the viewer phase, and its `ByteSource`, alive while the terminal is undersized. It releases the source when the viewer phase ends (its D7). This change relies on that lifetime rule and does not change it. Either order works. +- `listing-request-guard` (R-04, P-10) makes F7 viewer searches cancellable. The "search worker releases the file when the viewer closes" scenario depends on that cancellation. **Land this change after `listing-request-guard`**, which is the natural order anyway (P-10 is part of a High-severity change; R-20 is Low). +- `ci-and-build-tooling` (B-03, B-04, B-07) lists every `unsafe` block and requires a `// SAFETY:` comment on each. This change deletes the `byte_source.rs:37` block instead of documenting it, and removes `memmap2` from the dependency list. If `ci-and-build-tooling` lands first, it has to give this block an interim SAFETY comment so its unsafe-documentation lint passes, and this change then deletes the block and the comment together. If this change lands first, that change has one block fewer to document. diff --git a/openspec/changes/archive/2026-10-14-viewer-mapping-safety/specs/viewer/spec.md b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/specs/viewer/spec.md new file mode 100644 index 0000000..8e3d8e8 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/specs/viewer/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Instant open of arbitrarily large files + +The viewer SHALL open any file, including multi-GB files, with first paint independent of file size. It MUST read the file through bounded positioned reads on an open handle, on every platform, and MUST NOT memory-map the file. It MUST NOT build a full line index or perform any whole-file scan on open. Only the bytes required for the visible screen SHALL be read to produce the first frame. The file length SHALL be treated as a snapshot taken at open, and all navigation offsets MUST be clamped to that length. + +The viewer MUST NOT prevent other programs from changing the file while it is shown. On Windows it SHALL open the file with read, write, and delete sharing (`FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`), so other programs can keep appending to, truncating, deleting, or renaming the file. A file that is truncated or otherwise shrinks while it is shown MUST NOT crash or hang the application: a read beyond the file's new end SHALL return only the bytes that still exist, and the viewer SHALL keep rendering and navigating within the snapshot length. When the viewer closes, it and any F7 search still running for it SHALL release the file. + +#### Scenario: First frame reads only the visible window + +- **WHEN** F3 opens a multi-GB file +- **THEN** the viewer paints the first screen without reading the whole file and without constructing a line index +- **AND** only the byte range needed for the visible rows is read from the file + +#### Scenario: Network and special files are read the same way + +- **WHEN** F3 opens a file on a network path, or a zero-length file +- **THEN** the viewer serves the visible window through the same positioned reads it uses for every other file, without panicking +- **AND** the rendered content is identical to what any other read of that byte range returns + +#### Scenario: Offsets clamped to the snapshot length + +- **WHEN** navigation would move the top anchor beyond the length captured at open +- **THEN** the top offset is clamped to the file length and the view does not read past the snapshot length + +#### Scenario: Another program keeps writing to the viewed file + +- **WHEN** a log file is open in the viewer and the program writing it appends more lines +- **THEN** the writes succeed +- **AND** the viewer keeps showing the file up to the length captured at open + +#### Scenario: Viewed file can be truncated, rotated, or deleted + +- **WHEN** a file is open in the viewer and another program truncates it, renames it away, or deletes it +- **THEN** that operation succeeds on every platform, including Windows + +#### Scenario: Truncation while viewing never crashes + +- **WHEN** a 10 MB file is open in the viewer and another program truncates it to 1 KB +- **AND** the user scrolls or jumps to an offset past the new end +- **THEN** the application keeps running, rows past the new end render empty, and navigation stays within the snapshot length + +#### Scenario: Closing the viewer releases the file + +- **WHEN** the user closes the viewer, including while an F7 search is still scanning the file +- **THEN** the viewer's handle is closed and the search stops scanning and closes its handle +- **AND** no FileCommand thread holds the file open afterwards diff --git a/openspec/changes/archive/2026-10-14-viewer-mapping-safety/tasks.md b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/tasks.md new file mode 100644 index 0000000..4343582 --- /dev/null +++ b/openspec/changes/archive/2026-10-14-viewer-mapping-safety/tasks.md @@ -0,0 +1,36 @@ +# Tasks: viewer-mapping-safety + +## 1. Byte source (`crates/filecommand-core/src/viewer/byte_source.rs`) + +- [x] 1.1 Replace `enum Backend` with a plain `file: File` field on `ByteSource`, and route `read_range` through `positioned_read_to_eof` unconditionally (viewer: "Instant open of arbitrarily large files"; design D1) +- [x] 1.2 Open through `OpenOptions::new().read(true)` and, under `cfg(windows)`, `OpenOptionsExt::share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)`, using named constants with a comment that cites the spec's sharing guarantee (design D2) +- [x] 1.3 Replace the `cfg(not(any(windows, unix)))` `Ok(0)` stub with a seek-then-read fallback on `&File`. Comment that a `ByteSource` is used by one thread at a time (design D4) +- [x] 1.4 Remove `open_chunked` and the `memmap2` import, and rewrite the module and `open` doc comments to describe positioned reads, the sharing mode, and the shrink-reads-short rule (design D1, D3) +- [x] 1.5 Confirm that `read_range` still maps an I/O error to an empty window and truncates to the bytes actually read, so a truncated file reads short (design D3) + +## 2. Dependencies and call sites + +- [x] 2.1 Remove `memmap2` from `[workspace.dependencies]` in `Cargo.toml` and from `crates/filecommand-core/Cargo.toml`, then refresh `Cargo.lock` (proposal: What Changes) +- [x] 2.2 `crates/filecommand-tui/src/worker.rs` `spawn_viewer_search`: update the doc comment ("a fresh mmap open"), and make sure the `ByteSource` is a local that drops when the search returns or is cancelled (design D5) +- [x] 2.3 `crates/filecommand-tui/src/app.rs` `Effect::OpenViewer` arm: change the "opening/mapping" comment to describe a positioned-read open. No behavior change (design D5) +- [x] 2.4 Quick View (`crates/filecommand-tui/src/views/panel.rs` `render_quick_view_body`) opens a `ByteSource` per frame. Confirm it needs no code change and that, after 1.1, it no longer maps the previewed file. Its snapshots are covered by 3.8 (design D1, "Scope: every mapping goes") +- [x] 2.5 Update the remaining memory-map wording: the `crates/filecommand-tui/src/views/viewer.rs` module doc ("into a memory map (or a small positioned read …)") and `README.md` § Viewer (F3) and editor (F4) ("streams/memory-maps"). The original design doc (`docs/superpowers/specs/2026-08-06-filecommand-design.md` §4.5) is historical and stays as it is; the spec supersedes it + +## 3. Tests + +- [x] 3.1 `byte_source.rs`: append through a second handle while a `ByteSource` is open. The write succeeds, and `read_range` stays clamped to the snapshot length (viewer: "Another program keeps writing to the viewed file") +- [x] 3.2 `byte_source.rs`: truncate a 10 KiB file to 1 KiB with `set_len` while it is open. The truncation succeeds (on Windows this fails today with OS error 1224). `read_range(5000, 100)` is empty, `read_range(0, 2000)` returns 1024 bytes, and nothing panics (viewer: "Truncation while viewing never crashes", "Viewed file can be truncated, rotated, or deleted") +- [x] 3.3 `byte_source.rs`: `std::fs::rename` and `std::fs::remove_file` on the file succeed while a `ByteSource` is open (viewer: "Viewed file can be truncated, rotated, or deleted") +- [x] 3.4 `forward.rs`, `backward.rs`, `search.rs`: after truncation, `next_line_start`, `previous_line_start`, and `find_forward` terminate and return in-range offsets (design D3) +- [x] 3.5 `crates/filecommand-tui/src/app.rs` tests: after `Effect::OpenViewer` then `Command::ViewerClose`, `rt.viewer_source` is `None` and the file can be removed together with its directory (viewer: "Closing the viewer releases the file") +- [x] 3.6 With `listing-request-guard`'s viewer-search cancellation in place: start a search on a large file, close the viewer, and assert that the worker thread finishes and the file and its directory can then be removed (viewer: "Closing the viewer releases the file"; design D5) +- [x] 3.7 Remove `chunked_fallback_serves_identical_bytes_to_the_mmap_path` and `chunked_open_offsets_are_also_clamped_to_snapshot_length`, and keep the empty-file, clamp, and missing-path tests passing through `open` (viewer: "Network and special files are read the same way") +- [x] 3.8 Confirm that the viewer and Quick View insta snapshots are unchanged (design: Migration Plan) + +## 4. Verify + +- [x] 4.1 `cargo test --workspace` green on Windows +- [x] 4.2 `cargo clippy --workspace --all-targets -- -D warnings` clean +- [x] 4.3 `cargo tree -p filecommand-core` no longer lists `memmap2`, and `git grep -n "unsafe" crates/filecommand-core/src/viewer` finds nothing +- [ ] 4.4 Manual: view a log that another process appends to and truncates (for example a PowerShell loop calling `Add-Content`, then `Clear-Content`). The writer never fails, and FileCommand keeps running +- [x] 4.5 `openspec validate viewer-mapping-safety --strict` clean diff --git a/openspec/specs/additional-panel-modes/spec.md b/openspec/specs/additional-panel-modes/spec.md index 454c09e..eae1b8c 100644 --- a/openspec/specs/additional-panel-modes/spec.md +++ b/openspec/specs/additional-panel-modes/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m5-editor-and-modern-extras. Update Purpose af ## 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/selection/cursor styling and the `▶UP--DIR◀` marker for `..`. +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 @@ -32,6 +32,11 @@ The panel SHALL provide a Brief display mode that lists entry names only, arrang - **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 + ### Requirement: Tree display mode structure and rendering The panel SHALL provide a Tree display mode that renders a directory tree of the current drive. The column-header row MUST read `Tree`; the first body row MUST be the drive root (e.g. `C:\`) in the directory (bright-white) style; descendants MUST be drawn one indent level per depth using the single-line branch glyphs `│ `, `├─`, and `└─` in the frame (cyan) style, with directory names shown in bright-white UPPERCASE. The cursor MUST be the standard inverse bar, and the mini-status line MUST show the highlighted directory's full path. No `+`/`-` expander glyphs are rendered in v1. diff --git a/openspec/specs/application-shell/spec.md b/openspec/specs/application-shell/spec.md index 03ced60..b9e25b6 100644 --- a/openspec/specs/application-shell/spec.md +++ b/openspec/specs/application-shell/spec.md @@ -91,11 +91,19 @@ The system SHALL acquire the alternate screen, raw mode, and (when enabled) mous ### Requirement: Panic hook restores the terminal before reporting -The system SHALL install a panic hook that disables mouse capture, leaves raw mode, and leaves the alternate screen BEFORE the panic report is printed, and that chains to the previously installed hook so the backtrace still surfaces, so that a panic while in raw mode never leaves the terminal unusable. +The system SHALL install a panic hook that disables mouse capture, leaves raw mode, and leaves the alternate screen BEFORE the panic report is printed, and that chains to the previously installed hook so the backtrace still surfaces, so that a panic while in raw mode never leaves the terminal unusable. The restoration and the chained report SHALL apply only to a panic on the UI thread, the thread that owns the terminal and runs the event loop. + +A panic on a worker thread SHALL NOT restore the terminal and SHALL NOT write its report onto the terminal the UI still owns. Instead, the panic SHALL be contained to that worker and surfaced through the worker's normal result: + +- a panicked directory listing SHALL end as a failed listing: the panel leaves its `Reading…` state and shows an error; +- a panicked file-operation job SHALL end as a cancelled job: the progress or prompt dialog closes, the affected panels re-read, and the end-of-job summary names the internal error; +- any other worker SHALL deliver its "no result" answer, so that no indicator stays pending. + +The application SHALL keep running after a worker panic. #### Scenario: Panic in raw mode restores the terminal first -- **WHEN** a panic occurs while the terminal is in raw mode on the alternate screen with mouse capture enabled +- **WHEN** a panic occurs on the UI thread while the terminal is in raw mode on the alternate screen with mouse capture enabled - **THEN** mouse capture is disabled, raw mode is disabled, and the alternate screen is left before any panic report is written #### Scenario: Original hook still runs @@ -103,10 +111,49 @@ The system SHALL install a panic hook that disables mouse capture, leaves raw mo - **WHEN** the panic hook completes its terminal restoration - **THEN** it delegates to the previously installed hook so the panic message and backtrace are still reported +#### Scenario: A worker panic leaves the terminal alone + +- **WHEN** a worker thread panics while the TUI is running in raw mode on the alternate screen +- **THEN** raw mode, the alternate screen, and mouse capture stay as they were, no panic text is written over the TUI, and the UI keeps responding to input + +#### Scenario: A panicked listing ends as a failed listing + +- **WHEN** the worker reading a panel's directory panics partway through +- **THEN** the panel stops showing `Reading… N`, shows an error for that directory, and can be re-read with Ctrl+R + +#### Scenario: A panicked job ends and closes its dialog + +- **WHEN** a file-operation job's worker panics while its progress dialog is shown +- **THEN** the progress dialog closes, the affected panels re-read, and an end-of-job summary names the internal error +- **AND** a new file operation can be started afterwards + ### Requirement: Resize handling with 60x16 hard floor and placeholder The system SHALL reflow the UI on terminal resize events, laying out the interface at any size at or above the 60x16 hard floor, and MUST draw a `screen.placeholder` "terminal too small" message instead whenever the terminal is below that floor, using a single size check that governs both normal and splash states. The placeholder message SHALL name the floor: "resize to at least 60x16". Between the floor and the 80x24 nominal size the interface renders in the degraded forms defined by the `responsive-layout` capability; at or above 80x24 it renders at full fidelity. +The placeholder SHALL be drawn over the active screen without discarding it. While the terminal is below the floor, the following SHALL persist unchanged and SHALL reappear when the terminal is enlarged to at or above the floor: + +- the panels and their tabs, cursor, selection, quick filter, and command-line text; +- the built-in editor: its buffer including unsaved edits, caret position, insert/overwrite mode, and modified state; +- the F3 viewer: its file, position, and text/hex and wrap modes; +- a file-operation setup dialog with its typed input; +- a running file-operation job, with its progress, overwrite-conflict, or error-recovery dialog; +- the end-of-job skipped-files summary; +- any open pull-down menu, dialog, or overlay. + +The startup splash is the only exception: it SHALL be dismissed when the terminal shrinks below the floor and SHALL NOT return when the terminal is enlarged. + +Worker results SHALL continue to be applied while the terminal is below the floor. These include directory-listing events, file-operation progress, conflict, error, and completion events, and replies to requests made before the shrink. A job that completes while the placeholder is shown therefore still re-reads the affected panels, and a conflict or error raised while it is shown waits for the user's answer. + +While the terminal is below the floor, input SHALL NOT reach the screen hidden under the placeholder: + +- Mouse input SHALL be ignored, except that while the quit-confirmation dialog is open over the placeholder its buttons SHALL stay clickable. +- F10 and Esc SHALL open the quit-confirmation dialog, which is drawn over the placeholder. The exception is when the built-in editor is the hidden screen: there F10 and Esc SHALL be ignored, so unsaved edits are never discarded without the editor's save-on-exit prompt. +- While the quit-confirmation dialog or the startup-warning dialog is open over the placeholder, that dialog's own keys SHALL work as usual. +- Every other key SHALL be ignored. + +While the terminal is below the floor, the screen under the placeholder does not own input, so these rules take precedence over that screen's own key handling, including Esc's cancel/dismiss meaning in a hidden menu or dialog under "Quit request keys and confirmation". + #### Scenario: Reflow at or above the floor - **WHEN** the terminal is resized to a size at or above 60x16 @@ -132,6 +179,66 @@ The system SHALL reflow the UI on terminal resize events, laying out the interfa - **WHEN** the terminal is enlarged from below the floor back to at or above 60x16 - **THEN** the normal layout is drawn again in place of the placeholder +#### Scenario: Unsaved editor buffer survives a shrink + +- **WHEN** the built-in editor has unsaved edits with the caret on line 12 in overwrite mode, and the terminal shrinks to 50x12 and is then enlarged to 80x24 +- **THEN** the editor is shown again with the same buffer contents, the caret on line 12, overwrite mode, and the modified indicator +- **AND** nothing was written to disk and no prompt was raised while the placeholder was shown + +#### Scenario: Viewer position and mode survive a shrink + +- **WHEN** the F3 viewer shows a file in hex mode scrolled to offset 0x4000, and the terminal shrinks below the floor and is then enlarged +- **THEN** the viewer is shown again on the same file, in hex mode, at offset 0x4000 + +#### Scenario: Viewer closes normally after a shrink + +- **WHEN** the terminal shrinks below the floor and is enlarged while the viewer is open, and the user then closes the viewer +- **THEN** the panels return and the viewer no longer holds the file open + +#### Scenario: Running job keeps reporting progress while undersized + +- **WHEN** a copy job is running with its progress dialog shown and the terminal shrinks below the floor +- **THEN** the placeholder is shown, the job keeps running, and its progress events keep updating the hidden progress dialog +- **AND** when the terminal is enlarged before the job ends, the progress dialog reappears showing the job's latest progress + +#### Scenario: Conflict raised while undersized waits for an answer + +- **WHEN** a running copy job reaches an overwrite conflict while the terminal is below the floor +- **THEN** the job waits for an answer and the conflict is not resolved by default +- **AND** when the terminal is enlarged the overwrite-conflict dialog is shown for that file, and answering it resumes the job + +#### Scenario: Job finishing while undersized still refreshes the panels + +- **WHEN** a copy job completes while the terminal is below the floor +- **THEN** the panels showing the source and destination directories re-read while the placeholder is still shown +- **AND** when the terminal is enlarged the panels show the copied files, or the end-of-job summary when items were skipped + +#### Scenario: Open dialog and typed text survive a shrink + +- **WHEN** the F5 destination dialog is open with `D:\backup` typed and the terminal shrinks below the floor and is then enlarged +- **THEN** the destination dialog is shown again containing `D:\backup` with the caret where it was + +#### Scenario: Typing over the hidden editor does nothing + +- **WHEN** the built-in editor is the hidden screen while the terminal is below the floor and the user types letters, presses Enter, F2, F10, or Esc +- **THEN** the buffer, caret, and file on disk are unchanged, no save runs, and no quit-confirmation or save-on-exit dialog opens + +#### Scenario: F10 over the placeholder asks to quit + +- **WHEN** the panels are the hidden screen while the terminal is below the floor and the user presses F10 +- **THEN** the quit-confirmation dialog opens over the placeholder +- **AND** cancelling it leaves the placeholder shown and the hidden panels unchanged + +#### Scenario: Mouse input is ignored while undersized + +- **WHEN** the terminal is below the floor, no quit-confirmation dialog is open, and the user clicks or turns the mouse wheel anywhere in it +- **THEN** no command reaches the hidden screen and nothing about it changes + +#### Scenario: Quit dialog buttons stay clickable over the placeholder + +- **WHEN** the quit-confirmation dialog is open over the placeholder and the user clicks its No button +- **THEN** the dialog closes, the placeholder stays shown, and the hidden screen is unchanged + ### Requirement: Quit request keys and confirmation The system SHALL open a quit-confirmation dialog — never exit directly — when the user requests quit via F10, Files → Quit, or Esc over the panels. Esc SHALL request quit whenever the panels own input (no pull-down menu and no modal dialog or overlay open), regardless of command-line content or an active quick filter or type-ahead mode; inside menus, dialogs, the viewer, and editor sub-prompts Esc SHALL retain its existing cancel/dismiss meaning. Ctrl+C SHALL NOT request quit in any context (over the panels it is the clipboard Files action — see `clipboard-export`; in the built-in editor it remains Copy). Within the quit-confirmation dialog, Esc SHALL cancel alongside the existing confirm/cancel keys; Ctrl+C SHALL have no effect there. Cancelling SHALL restore the prior context exactly — anything open stays open, and command-line text, quick-filter, and type-ahead state survive. Confirming while a file operation is running SHALL abort the job through its existing cancel semantics before the application exits. @@ -215,3 +322,386 @@ The system SHALL recognise `-h`, `-?`, and `--help` as the sole command-line arg - **WHEN** the application is invoked without `-h`, `-?`, or `--help` - **THEN** no usage text is printed and the TUI starts as normal +### 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 + +### Requirement: Optional diagnostic log + +The system SHALL support an opt-in diagnostic log file, chosen at launch by the `--log ` flag (also accepted as `--log=`) or by the `FILECOMMAND_LOG` environment variable. When both are given, the flag SHALL win. + +When a log path is in effect, the system SHALL append one line per event to that file. Each line SHALL begin with a UTC timestamp, followed by a short category and a message naming the operation and the affected path where there is one. The file SHALL never be truncated. At least the following SHALL be logged, most of which the application otherwise handles without telling the user: + +- failures to write `history.json` or `config.toml`; +- results that could not be delivered between a worker thread and the event loop; +- answers that could not be delivered to a running file operation; +- metadata that could not be restored on a copied file; +- every startup warning, including any that a later warning replaced in the startup-warning dialog. + +When neither the flag nor the variable is set, the system SHALL NOT create or write any log file. + +If the log file cannot be opened for appending, or the flag is given without a path, the session SHALL start without logging and SHALL raise the dismissable startup-warning dialog naming the path and the reason. A failure to write a log line SHALL never interrupt the session. + +The `--help` usage block SHALL list `--log ` and SHALL mention `FILECOMMAND_LOG`. The log SHALL be implemented without adding a dependency, and `filecommand-core` SHALL NOT write to it directly. + +#### Scenario: No log unless asked for + +- **WHEN** FileCommand runs without `--log` and with `FILECOMMAND_LOG` unset, and a `history.json` write fails during the session +- **THEN** no log file is created anywhere, and the session continues as before + +#### Scenario: A failed history write is logged with a timestamp + +- **WHEN** FileCommand is launched with `--log C:\temp\fc.log` and a `history.json` write fails because the profile directory became read-only +- **THEN** `C:\temp\fc.log` gains a line beginning with a UTC timestamp that names the history write and the path of `history.json`, together with the operating-system error +- **AND** no dialog or status-line message is shown for it + +#### Scenario: The environment variable enables logging + +- **WHEN** `FILECOMMAND_LOG` is set to `D:\logs\fc.log` and FileCommand is launched without `--log` +- **THEN** diagnostic lines are appended to `D:\logs\fc.log` + +#### Scenario: The flag wins over the environment variable + +- **WHEN** `FILECOMMAND_LOG` is `D:\logs\env.log` and FileCommand is launched with `--log=D:\logs\flag.log` +- **THEN** lines are appended to `D:\logs\flag.log` and `D:\logs\env.log` is not created or written + +#### Scenario: Existing log content is kept + +- **WHEN** the log file already holds lines from an earlier session +- **THEN** the new session's lines are appended after them + +#### Scenario: Unwritable log path warns and continues + +- **WHEN** FileCommand is launched with `--log` naming a file in a directory that does not exist +- **THEN** the TUI starts normally, the startup-warning dialog names that path and the reason it could not be opened, and nothing is logged for the session + +#### Scenario: Superseded startup warnings are still recorded + +- **WHEN** logging is enabled and two startup warnings occur, such as an unknown `--theme` and a malformed `usermenu.toml`, so the dialog shows only the last one +- **THEN** the log holds a line for each of the two warnings + +#### Scenario: Usage lists the log options + +- **WHEN** the user runs `filecommand --help` +- **THEN** the usage block lists `--log ` and mentions the `FILECOMMAND_LOG` environment variable + +### 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 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. 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, and no minute boundary passing for 30 seconds +- **THEN** no frame is drawn during those 30 seconds + +#### 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 + +### Requirement: Bursts of key input render once + +When several input events are already queued, the system SHALL apply them in order — mapping each key against the state produced by the previous one — and SHALL draw once after the burst instead of once per key. A burst SHALL end after 64 events, after about 8 ms of processing, when no further input is queued, or when a mouse event is reached; the system SHALL then draw before handling further input, so the screen never falls far behind. Applying a burst SHALL produce the same final state as applying the same keys one at a time. + +#### Scenario: Holding PgDn renders one frame per burst + +- **WHEN** five PgDn key events are queued at once in a 100,000-entry directory +- **THEN** all five page movements are applied and the screen is drawn once, showing the final position + +#### Scenario: Routing follows the state each key produces + +- **WHEN** the queued keys are F7 followed by the characters `new` and Enter while the panels are focused +- **THEN** F7 opens the Make-directory dialog, `new` is typed into its field, and Enter confirms it, exactly as if the keys had been pressed slowly + +#### Scenario: A paste delivered as key events renders once per burst + +- **WHEN** the terminal delivers a 60-character pasted path as 60 key events while the command line has focus +- **THEN** all 60 characters are inserted in order and the screen is drawn at most once for every 64 of them + +### Requirement: Ctrl+C during a suspended child run stops only the child + +While FileCommand is suspended and waiting for a child process it started, a Ctrl+C or Ctrl+Break typed at the console SHALL reach the child as usual and SHALL NOT terminate FileCommand. This covers a command run from the file-action menu's Run entry, a command from the F2 user menu, and the external editor launched by F4. + +After the child exits, FileCommand SHALL show its usual press-any-key prompt and then resume the TUI with its tabs, panels, command history, and session state intact. The child SHALL NOT inherit an instruction to ignore Ctrl+C from FileCommand, so a console program that stops on Ctrl+C still stops. + +Outside these suspended child runs, Ctrl+C handling SHALL be unchanged, as SHALL any console close, logoff, or shutdown event, which still ends FileCommand. + +#### Scenario: Ctrl+C stops a user-menu command and FileCommand survives + +- **WHEN** the user runs a user-menu entry `ping -t 127.0.0.1` and presses Ctrl+C while it runs +- **THEN** `ping` stops and FileCommand shows its press-any-key prompt +- **AND** after a key press the TUI resumes with the same tabs, directories, and cursor positions as before the command ran + +#### Scenario: Ctrl+C during a Run-entry command + +- **WHEN** the user activates Run on `server.exe`, a console program that stops on Ctrl+C, and presses Ctrl+C +- **THEN** `server.exe` stops, and FileCommand is still running and resumes after the prompt + +#### Scenario: Ctrl+C inside the external editor does not end FileCommand + +- **WHEN** the configured external editor is running in the suspended terminal and the user presses Ctrl+C +- **THEN** FileCommand does not exit, whether or not the editor itself treats Ctrl+C as a key or exits on it, and the panel re-read and resume happen as usual once the editor exits + +#### Scenario: The child is not made to ignore Ctrl+C + +- **WHEN** a child started through any of these paths installs no handler of its own and the user presses Ctrl+C +- **THEN** the child terminates as it would when run directly from a shell + +### Requirement: Quit waits for job cancellation cleanup + +When quit is confirmed while a file-operation job is running, the system SHALL do the following before the process exits: + +- request the job's cancellation; +- treat any overwrite-conflict or error-recovery prompt the job is waiting on as an abort, without requiring an answer; +- wait for the job to finish its cancellation cleanup, including removing a partially written destination file, for up to about 2 seconds. + +If the job has not finished within that bound, the application SHALL exit anyway. The wait SHALL apply to any job still running when quit is confirmed, whichever screen or dialog is showing at the time. When no job is running, quit SHALL exit without delay. + +#### Scenario: Quit during a large copy leaves no partial file + +- **WHEN** a copy of a 2 GB file is in progress and the user requests quit and confirms +- **THEN** the application exits only after the partially written destination file has been removed, and the destination holds no truncated copy of that file + +#### Scenario: Quit while a conflict prompt is waiting + +- **WHEN** a copy job is waiting on the overwrite-conflict dialog and the user requests quit and confirms +- **THEN** the job stops without overwriting the conflicting target and without processing any further items, and the application exits after the job has ended + +#### Scenario: The wait is bounded + +- **WHEN** quit is confirmed while the job is blocked in a write to a share that has stopped responding +- **THEN** the application exits after about 2 seconds rather than waiting for the write to return + +#### Scenario: No job means no wait + +- **WHEN** no file operation is running and the user confirms quit +- **THEN** the application exits immediately + +### Requirement: The reducer performs no filesystem I/O + +`core::update` SHALL NOT perform filesystem I/O of any kind, directly or through any helper it calls. That includes metadata or existence queries, directory reads, and file opens. When handling a command depends on the current state of the filesystem, `core::update` SHALL: + +- answer from the state it already holds where that state suffices, such as an entry already listed in a panel; +- otherwise return an effect requesting the check, tagged with a fresh request id; +- complete the command only when the reply command carrying that id is applied. + +A reply whose id is no longer the outstanding request SHALL be discarded without changing state. This makes the "Pure core update function" rule checkable by a unit test that runs with no filesystem at all. + +#### Scenario: A command-line target outside the listing produces a probe request, not a disk read + +- **WHEN** a unit test applies `cd sub` to `core::update` with the active panel at `C:\NORTON` and `sub` not among its listed entries +- **THEN** the returned effects contain a path-probe request for `C:\NORTON\sub` tagged with a request id, and the panel's `cwd` is still `C:\NORTON` +- **AND** `core::update` made no filesystem call, so the result is the same whether or not `C:\NORTON\sub` exists on the machine running the test + +#### Scenario: The probe reply completes the command + +- **WHEN** the path-probe reply for that request id arrives reporting a directory +- **THEN** applying it through `core::update` navigates the panel to `C:\NORTON\sub` exactly as a synchronous check would have + +#### Scenario: A superseded probe reply is discarded + +- **WHEN** a second `cd` is entered before the first probe's reply arrives, and the first reply then arrives +- **THEN** the first reply changes nothing: no navigation, no error message, and no dialog + +### Requirement: Slow storage never blocks the event loop + +These operations SHALL run on worker threads, never on the event-loop thread, so that a slow, removable, or unreachable volume cannot freeze input or redraw: + +- the command-line target check; +- opening a file in the F3 viewer; +- opening a file in the built-in editor; +- saving from the built-in editor; +- launching a file's associated application; +- writing the applied theme and the panel split to `config.toml`. + +While an editor or viewer open is outstanding, the status line of the panel it was requested from SHALL show `Opening …`, and the panels SHALL keep accepting input. A newer F3 or F4 request SHALL supersede an outstanding open. An open reply that arrives after the user has left the panels, or after a menu, dialog, or overlay has opened, SHALL be discarded, and a viewer reply's file handle SHALL be released. + +While a save is outstanding, the editor SHALL show a `Saving…` notice on the row directly above its F-key bar, leaving the header and F-key bar unchanged, and SHALL NOT accept edits, caret moves, or further save or quit commands until the save completes or fails. The screen SHALL keep redrawing meanwhile. + +Configuration writes SHALL be performed one at a time, in the order they were requested, by a single background writer, so that two rewrites of `config.toml` never interleave. A newer value for a setting SHALL replace an older value for the same setting that has not been written yet, so the value on disk after the writes complete is always the last one requested. Moving a write to the background writer SHALL NOT change how it is written: it SHALL remain the atomic write that theme-selection "Applied theme persists to configuration" and panel-split "Split persistence to configuration" require, made durable as configuration-files "Persisted configuration writes are durable" specifies where that requirement exists, and a failed write SHALL NOT interrupt the session. When the application exits, by quit or by any other exit path, the system SHALL first complete every configuration write still outstanding, waiting at most about 2 seconds. History persistence (`history.json`) is outside this rule. + +On Windows, the worker that launches the associated application SHALL initialize COM in a single-threaded apartment (`COINIT_APARTMENTTHREADED`) before calling the shell launch API and SHALL uninitialize it afterwards. A launch failure SHALL be reported on the status line of the panel the Open action was invoked on. + +#### Scenario: F4 on a slow share keeps the panels live + +- **WHEN** the user presses F4 on `notes.txt` in a panel showing `\\nas\share` and reading the file takes several seconds +- **THEN** the panels keep redrawing and moving the cursor in response to keys while the panel's status line shows `Opening notes.txt…` +- **AND** the built-in editor opens on `notes.txt` once the read completes + +#### Scenario: F3 on a file whose server stopped responding does not freeze the UI + +- **WHEN** the user presses F3 on a file on a share whose server has stopped responding +- **THEN** the UI keeps redrawing and accepting input while the open is outstanding +- **AND** when the open finally fails, the panel's status line shows the failure and the viewer does not open + +#### Scenario: Saving to a slow share shows progress and accepts no edits + +- **WHEN** the user presses F2 in the built-in editor on a file whose write takes several seconds +- **THEN** the editor shows `Saving…` above its F-key bar, the screen keeps redrawing, and typed characters are not inserted into the buffer +- **AND** when the write completes, `Saving…` disappears, the modified indicator clears, and typing edits the buffer again + +#### Scenario: A newer F3 supersedes an outstanding open + +- **WHEN** the user presses F3 on `slow.log`, moves to `fast.txt`, and presses F3 again before `slow.log` has opened +- **THEN** the viewer opens `fast.txt`, and the late answer for `slow.log` is discarded without replacing it + +#### Scenario: A late open does not pull the user out of a menu + +- **WHEN** an F4 open is outstanding and the user opens the F9 menu before it completes +- **THEN** the menu stays open, the editor does not open when the read completes, and the late result is discarded + +#### Scenario: Launching a slow handler leaves the panels interactive + +- **WHEN** the user activates Open on a document whose associated application takes several seconds to start +- **THEN** the panels keep redrawing and responding to keys during the launch +- **AND** if the launch fails, the failure is shown on the status line of the panel the Open action was invoked on, even if the user has switched panels since + +#### Scenario: COM is initialized on the launch worker + +- **WHEN** Open launches a file on Windows +- **THEN** the shell launch API is called on a worker thread that initialized COM in a single-threaded apartment beforehand, and that thread uninitializes COM after the call returns + +#### Scenario: Applying a theme on a slow profile directory keeps the UI live + +- **WHEN** the profile directory is on a network share that takes several seconds to answer and the user applies a theme from the theme picker +- **THEN** the new theme is shown and the UI keeps redrawing and accepting keys while `config.toml` is being rewritten in the background + +#### Scenario: Rapid split adjustments persist the final value + +- **WHEN** the split is at 50/50 on a 100-column terminal and the user presses Ctrl+Right eight times in quick succession, ending at a 66% split +- **THEN** the input is never held up by the writes, and once the writer has caught up `config.toml` contains `panel_split = 66` + +#### Scenario: A theme and a split change made together are both saved + +- **WHEN** the user applies a theme and, before that write finishes, adjusts the split +- **THEN** after both writes complete `config.toml` contains both the new `theme` and the new `panel_split` + +#### Scenario: A setting changed just before quit is saved + +- **WHEN** the user adjusts the split and immediately quits and confirms, on a responsive disk +- **THEN** the process exits only after `config.toml` holds the new `panel_split` value + +#### Scenario: An unresponsive profile directory cannot hold quit open + +- **WHEN** a configuration write is blocked on a profile directory that has stopped answering and the user confirms quit +- **THEN** the application exits after about 2 seconds, and `config.toml` is left with either its complete previous content or its complete new content + +### Requirement: Modal overlays are mutually exclusive by construction + +The system SHALL hold the state of the modal overlays in a single overlay slot in `filecommand-core`'s `State`, so that at most one of them is open at any time. The modal overlays are the F9 menu, the drive-select dialog, fuzzy jump, find file, the F2 user menu, the command-history dialog, the theme picker, the file-action menu, the Help window (with its nested About dialog), and the startup warning. Each overlay's handler SHALL receive that overlay's state directly from the slot, so no handler depends on a separately checked gate and none can run while its overlay is closed. + +The quit-confirmation dialog is not part of the slot. It SHALL remain a separate layer above every other context, as "Quit request keys and confirmation" requires. The file-operation dialogs, the viewer, and the editor SHALL remain phases. + +Which layer owns input SHALL be defined once, by a single precedence order in `filecommand-core`, and the reducer's command routing, the TUI's key routing, its mouse routing, and its rendering and hit-map construction SHALL all follow it. The order, highest first: +1. the quit confirmation; +2. a file-operation dialog; +3. the viewer; +4. the editor; +5. the open overlay; +6. the panels or the startup splash. + +A layer SHALL handle only the commands it claims; any other command, including worker replies, SHALL pass to the layers below it as it does today. While the terminal is below the 60x16 floor, input routing SHALL additionally follow "Resize handling with 60x16 hard floor and placeholder". + +This structure SHALL NOT change which overlay is shown, which keys or clicks it accepts, or how it renders, in any state the application can reach. + +#### Scenario: A menu item opens its dialog in place of the menu + +- **WHEN** the F9 menu is open and the user activates Commands → Find file +- **THEN** the menu closes and the find-file dialog opens +- **AND** only the find-file dialog is shown, and it receives the next key + +#### Scenario: The user menu's Themes entry replaces the user menu + +- **WHEN** the F2 user menu is open with its built-in Themes entry highlighted and the user presses Enter +- **THEN** the user menu closes and the theme picker opens in its place, with the active theme highlighted + +#### Scenario: Another overlay's shortcut is ignored while an overlay is open + +- **WHEN** the fuzzy-jump dialog is open and the user presses F9 or Alt+F1 +- **THEN** neither the menu nor the drive-select dialog opens +- **AND** the fuzzy-jump dialog stays open with its pattern and highlight unchanged + +#### Scenario: Unbound keys never reach the panels or the command line + +- **WHEN** the F2 user menu is open and the user types a printable character that the user menu does not bind +- **THEN** the key is ignored, so the command line and the panel cursor are unchanged + +#### Scenario: Late results of a dismissed search touch no other overlay + +- **WHEN** the user dismisses the find-file dialog with Esc while its search is still running, then opens the Help window +- **AND** further matches from the dismissed search arrive +- **THEN** the results are discarded, the Help window is unchanged, and no find-file dialog reappears + +#### Scenario: An overlay command with its overlay closed does nothing + +- **WHEN** a command that only an overlay binds (for example confirming fuzzy jump) reaches the reducer while that overlay is not open +- **THEN** the state is unchanged, no effect is returned, and nothing panics + +#### Scenario: The quit dialog stacks above an open overlay + +- **WHEN** the quit-confirmation dialog is open above an open F9 pull-down and the user cancels it +- **THEN** the pull-down is still open exactly as before, and it receives the next key + +#### Scenario: Worker replies pass beneath the quit dialog + +- **WHEN** a copy job is running, the quit-confirmation dialog is open above its progress dialog, and the job reports progress +- **THEN** the progress dialog underneath shows the new progress and the quit-confirmation dialog stays open + +#### Scenario: One precedence order everywhere + +- **WHEN** the routing of a key, the routing of a mouse event, and the drawing and hit map of a frame are each computed for the same state +- **THEN** all of them treat the same layer as the one that owns input, the layer defined by the single precedence order + diff --git a/openspec/specs/build-tooling/spec.md b/openspec/specs/build-tooling/spec.md new file mode 100644 index 0000000..c211193 --- /dev/null +++ b/openspec/specs/build-tooling/spec.md @@ -0,0 +1,135 @@ +# build-tooling Specification + +## Purpose +TBD - created by archiving change ci-and-build-tooling. Update Purpose after archive. +## Requirements +### Requirement: Continuous integration gates every pull request + +The repository SHALL contain a GitHub Actions workflow that runs on every pull request and on every push to `main`. A required job on `windows-latest` MUST run, against the committed `Cargo.lock`: +- `cargo fmt --all -- --check` +- `cargo clippy --workspace --all-targets -- -D warnings` +- `cargo test --workspace` +- `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings` + +The job MUST fail if any of these steps fails. A job on `ubuntu-latest` SHALL build and test the workspace on a best-effort basis and MUST be marked allowed-to-fail, so that its result is reported but never blocks a merge. The toolchain used by the required Windows job SHALL be pinned to a specific stable release in the workflow and changed only deliberately. The README SHALL list the commands the required job runs, so a contributor can reproduce it locally. + +#### Scenario: A formatting slip fails the pull request + +- **WHEN** a pull request changes a `.rs` file so that `cargo fmt --all -- --check` reports a diff +- **THEN** the required Windows job fails and the pull request shows a failing check + +#### Scenario: A new clippy warning fails the pull request + +- **WHEN** a pull request introduces code that produces any clippy warning on any target, tests included +- **THEN** the required Windows job fails + +#### Scenario: A failing test fails the pull request + +- **WHEN** any test in the workspace fails on `windows-latest` +- **THEN** the required Windows job fails + +#### Scenario: A broken doc link fails the pull request + +- **WHEN** a doc comment links to an item that does not exist or is private +- **THEN** `cargo doc` with `-D warnings` fails and the required Windows job fails + +#### Scenario: Linux results never block a merge + +- **WHEN** the `ubuntu-latest` job fails to build or test +- **THEN** its failure is visible on the pull request +- **AND** the pull request's required checks are unaffected + +#### Scenario: Clean baseline + +- **WHEN** the workflow first runs on the branch that introduces it +- **THEN** every step of the required Windows job passes, with no formatting diffs, clippy warnings, test failures, or rustdoc warnings + +### Requirement: Formatting configuration is committed + +The repository SHALL commit a `rustfmt.toml` at its root that reproduces the existing source style as closely as stable rustfmt options allow: `max_width = 150` and `use_small_heuristics = "Max"`. The whole workspace SHALL be formatted with it once, in a commit that contains only formatting changes, and that commit's hash SHALL be listed in a committed `.git-blame-ignore-revs` file. After that commit, `cargo fmt --all -- --check` MUST report no differences on `main`. + +#### Scenario: Format check is clean on main + +- **WHEN** `cargo fmt --all -- --check` runs on a fresh checkout of `main` +- **THEN** it reports no differences and exits successfully + +#### Scenario: The reformat commit is isolated + +- **WHEN** the reformat commit is inspected +- **THEN** it changes only whitespace, line breaks, and other rustfmt output, with no change to any token's meaning +- **AND** `git blame` configured with `.git-blame-ignore-revs` attributes lines to their pre-reformat authors + +#### Scenario: CRLF working trees pass + +- **WHEN** the repository is checked out on Windows with `core.autocrlf = true` and `cargo fmt --all -- --check` runs +- **THEN** it reports no differences caused by line endings + +### Requirement: Toolchain floor and release profile are declared + +The workspace SHALL declare its minimum supported Rust version as `rust-version` in `[workspace.package]`, inherited by both crates. The declared value MUST be a toolchain on which the workspace, including all targets, builds against the committed `Cargo.lock`, and CI MUST check this on every pull request. The workspace SHALL define `[profile.release]` with `lto = "thin"`, `codegen-units = 1`, and `strip = "symbols"`. The release profile MUST keep the `unwind` panic strategy, because terminal restoration and worker-thread panic isolation depend on unwinding. The README's build instructions SHALL name the minimum Rust version. + +#### Scenario: Older toolchain is rejected clearly + +- **WHEN** a contributor builds with a toolchain older than the declared `rust-version` +- **THEN** cargo stops with its standard message naming the required version, instead of failing with a compile error deep in the code + +#### Scenario: The floor is verified + +- **WHEN** a pull request uses a standard-library API newer than the declared `rust-version` +- **THEN** the CI job that checks the workspace on the declared toolchain fails + +#### Scenario: Release binary is optimized and stripped + +- **WHEN** `cargo build --release` produces `filecommand.exe` +- **THEN** it is built with thin LTO, a single codegen unit, and `strip = "symbols"` +- **AND** on targets whose binaries embed a symbol table (ELF, Mach-O), the release binary carries none + +#### Scenario: Panics still unwind in release builds + +- **WHEN** a release build panics on the main thread while the terminal is in raw mode on the alternate screen +- **THEN** the terminal is restored before the panic report is printed, exactly as in a debug build + +### Requirement: libgit2 is built with only the features local status needs + +The `git2` dependency SHALL be declared with `default-features = false`, enabling no network transport (SSH or HTTPS), because git-info reads only local repository state. The Linux build MUST NOT require OpenSSL development headers. Git-info behavior SHALL be unchanged: branch name and per-entry status on Windows and elsewhere. + +#### Scenario: No SSH or OpenSSL in the dependency graph + +- **WHEN** the resolved dependency graph of `filecommand-core` is inspected (for example with `cargo tree -p filecommand-core`) +- **THEN** `libssh2-sys`, `openssl-sys`, and `openssl-probe` do not appear in it + +#### Scenario: Linux builds without OpenSSL headers + +- **WHEN** the workspace is built on an Ubuntu machine with a C compiler but without the OpenSSL development package +- **THEN** the build does not fail for lack of OpenSSL + +#### Scenario: Git info still works + +- **WHEN** a panel shows a directory inside a git working tree with a modified, a staged, and an untracked file +- **THEN** the branch name and the three status markers are reported exactly as before + +### Requirement: Windows FFI uses windows-sys and every unsafe block documents its invariant + +All Windows API calls in both crates SHALL use the prototypes, constants, and struct definitions from the `windows-sys` crate, declared as a Windows-only dependency. The workspace MUST NOT declare its own Windows prototypes (`extern "system" { … }` declaration blocks) or `#[repr(C)]` copies of Windows structures. Rust callback functions that Windows invokes, defined with the `extern "system"` ABI (for example a console control handler or a copy-progress routine), are permitted; their signatures SHALL match the corresponding `windows-sys` function-pointer type. Every `unsafe` block in the workspace MUST be immediately preceded by a `// SAFETY:` comment that states the invariant making it sound, and the workspace SHALL enable `clippy::undocumented_unsafe_blocks` so that CI rejects an undocumented one. + +#### Scenario: No hand-declared Windows prototypes + +- **WHEN** the workspace sources are searched for `extern "system" { … }` declaration blocks or `#[repr(C)]` structs mirroring Windows types +- **THEN** none are found, and drive enumeration, volume information, free space, memory status, file identity, alternate-data-stream enumeration, and local time all call through `windows-sys` +- **AND** any `extern "system" fn` found is a callback definition passed to a Windows API, not a prototype + +#### Scenario: Non-Windows builds do not pull in windows-sys + +- **WHEN** the workspace is built for a non-Windows target +- **THEN** `windows-sys` is not compiled as a dependency of either crate + +#### Scenario: Undocumented unsafe fails CI + +- **WHEN** a pull request adds an `unsafe` block without a preceding `// SAFETY:` comment +- **THEN** clippy reports `undocumented_unsafe_blocks` and the required Windows job fails + +#### Scenario: Behavior is unchanged + +- **WHEN** the drive-select dialog, the Info panel, a copy that preserves alternate data streams, and a case-only rename run on Windows after the migration +- **THEN** they produce the same results as before it + diff --git a/openspec/specs/builtin-editor/spec.md b/openspec/specs/builtin-editor/spec.md index 7138539..12f2701 100644 --- a/openspec/specs/builtin-editor/spec.md +++ b/openspec/specs/builtin-editor/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m5-editor-and-modern-extras. Update Purpose af ## Requirements ### Requirement: Editor invocation and size cap -The system SHALL open the built-in editor on F4 for the file under the cursor when no external editor is configured, loading files smaller than 10 MB into an in-memory line buffer, and SHALL redirect files of 10 MB or larger to the F3 viewer with a notice rather than loading them into the editor. +The system SHALL open the built-in editor on F4 for the file under the cursor when no external editor is configured, loading files smaller than 10 MB into an in-memory line buffer, and SHALL redirect files of 10 MB or larger to the F3 viewer with a notice rather than loading them into the editor. The built-in editor SHALL edit only UTF-8 text. A file smaller than 10 MB SHALL NOT be loaded into the editor when it begins with a UTF-16 byte-order mark (`FF FE` or `FE FF`), contains a NUL (`0x00`) byte, or is not valid UTF-8. Such a file SHALL instead be opened in the F3 viewer with a notice explaining that the built-in editor only edits UTF-8 text, using the same redirect and notice mechanism as the size limit, and the file on disk SHALL NOT be modified. A file that begins with a UTF-8 byte-order mark (`EF BB BF`) and is otherwise valid UTF-8 without NUL bytes SHALL open in the editor. The byte-order mark SHALL NOT appear in the editable buffer. #### Scenario: Small file opens in the editor @@ -22,6 +22,32 @@ The system SHALL open the built-in editor on F4 for the file under the cursor wh - **WHEN** a `config.toml` external editor command is set and the user presses F4 on a file - **THEN** the built-in editor does not open and the external editor is launched instead +#### Scenario: Non-UTF-8 file redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on `setup.bat`, a 2 KB file containing the Windows-1252 byte `E9` (`é`), which is not valid UTF-8 +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text +- **AND** `setup.bat` on disk is byte-for-byte unchanged + +#### Scenario: UTF-16 file redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on a file whose first two bytes are `FF FE` +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text + +#### Scenario: File containing a NUL byte redirects to the viewer + +- **WHEN** no external editor is configured and the user presses F4 on a file smaller than 10 MB that contains a `00` byte +- **THEN** the built-in editor does not load the file, the file is opened in the F3 viewer, and a notice explains that the built-in editor only edits UTF-8 text + +#### Scenario: UTF-8 file with a byte-order mark is editable + +- **WHEN** no external editor is configured and the user presses F4 on a file that begins with `EF BB BF` followed by valid UTF-8 text `hello` +- **THEN** the built-in editor opens with `hello` as the first line, and the byte-order mark is not part of the buffer, so the caret at the start of the first line sits before `h` + +#### Scenario: External editor receives non-UTF-8 files unchanged + +- **WHEN** a `config.toml` external editor command is set and the user presses F4 on a UTF-16 file +- **THEN** the external editor is launched on that file and no encoding check or redirect takes place + ### Requirement: Insert and overwrite text entry The system SHALL support both insert and overwrite text-entry modes, toggled while editing, and SHALL indicate that overwrite mode is active in the header row. @@ -95,7 +121,7 @@ The system SHALL keep exactly one prior-state snapshot and provide a single-leve ### Requirement: Save in place with line-ending and encoding preservation -The system SHALL save the buffer in place on F2, writing UTF-8 text and preserving the file's original dominant line ending (CRLF or LF) detected on load, so a CRLF file stays CRLF and an LF file stays LF. +The system SHALL save the buffer in place on F2, writing UTF-8 text and preserving the file's original dominant line ending (CRLF or LF) detected on load, so a CRLF file stays CRLF and an LF file stays LF. When the file began with a UTF-8 byte-order mark on load, the saved file SHALL begin with the same byte-order mark. The save SHALL be atomic. The encoded content SHALL be written to a temporary file in the same directory as the target and flushed to stable storage, and only then SHALL that temporary file replace the original in a single rename-style operation, so at every moment the file at the target path holds either its complete previous content or its complete new content. If any step fails, the original file SHALL remain byte-for-byte intact, the temporary file SHALL be removed, the buffer SHALL remain modified, and the editor SHALL display the failure message until the next edit or successful save. The save SHALL preserve the original file's attributes (including hidden and system) and its creation time. On Windows it SHALL also preserve the file's alternate data streams and, where the volume supports access-control lists, its discretionary access-control list (DACL). The system SHALL NOT clear a file's read-only attribute in order to save it. Saving a read-only file SHALL fail with a message stating that the file is read-only, and the file SHALL be left untouched. #### Scenario: CRLF file stays CRLF on save @@ -112,6 +138,41 @@ The system SHALL save the buffer in place on F2, writing UTF-8 text and preservi - **WHEN** the buffer differs from the last saved state and the user presses F2 - **THEN** the file on disk is updated in place and the buffer is no longer considered modified +#### Scenario: UTF-8 byte-order mark is written back + +- **WHEN** a file that began with `EF BB BF` is edited and saved with F2 +- **THEN** the saved file begins with exactly one `EF BB BF` sequence, followed by the buffer's UTF-8 text + +#### Scenario: A failed save leaves the original intact + +- **WHEN** the user presses F2 and writing the new content fails partway, for example because the disk is full +- **THEN** the file at the target path still holds its complete previous content, no temporary file remains in its directory, the buffer is still marked modified, and the editor displays the failure message + +#### Scenario: Content is flushed before it replaces the original + +- **WHEN** the user presses F2 on a modified buffer +- **THEN** the new content is written to a temporary file in the target's directory and flushed to stable storage before that file replaces the original, and the original is never truncated or rewritten in place + +#### Scenario: Attributes and creation time survive a save + +- **WHEN** a hidden file with creation time T is edited and saved with F2 +- **THEN** after the save the file is still hidden and its creation time is still T + +#### Scenario: Alternate data streams survive a save on Windows + +- **WHEN** on Windows a file carrying a `Zone.Identifier` alternate data stream is edited and saved with F2 +- **THEN** after the save the file still carries that `Zone.Identifier` stream with its previous content + +#### Scenario: Read-only file is not saved + +- **WHEN** the file being edited has the read-only attribute and the user presses F2 +- **THEN** the file is not modified, its read-only attribute remains set, the buffer stays marked modified, and the editor displays a message stating that the file is read-only + +#### Scenario: Save failure message clears on the next edit + +- **WHEN** the editor is displaying a save failure message and the user types a character +- **THEN** the failure message is no longer displayed + ### Requirement: Modified indicator and save-on-exit prompt The system SHALL derive a modified indicator from whether the buffer differs from the last saved state, display ` *` after the file path in the header row while modified, and raise a save-on-exit confirmation when the user quits (F10) with unsaved changes. diff --git a/openspec/specs/command-line/spec.md b/openspec/specs/command-line/spec.md index f745619..96ff3dc 100644 --- a/openspec/specs/command-line/spec.md +++ b/openspec/specs/command-line/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m3-command-line-and-menus. Update Purpose afte ## 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 appended to the command-line buffer rather than the panel, matching classic NC behavior. +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. @@ -23,6 +23,18 @@ Because printable keys route to the command line while a panel is focused, the c - **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 @@ -46,7 +58,7 @@ Enter on an executable target (PATHEXT match or `.lnk`) SHALL NOT spawn the targ ### 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 SHALL be the mechanism that hands 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. +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 @@ -70,15 +82,19 @@ The system SHALL maintain a command history and, while the command-line buffer i ### 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, inserting at the command-line buffer. 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`. +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 into the command-line buffer +- **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 into the command-line buffer +- **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 ### Requirement: Panels on/off reveals terminal scrollback @@ -122,7 +138,19 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA ### Requirement: cd navigates the active panel or rejects a nonexistent target -`cd ` SHALL resolve `` against the active panel's current directory (supporting `.`, `..`, bare drive letters, UNC paths, and relative paths, exactly as today). Before navigating, the system SHALL verify the resolved target exists and is a directory. If it does, the active panel SHALL navigate to it exactly as today. If it does not — the target doesn't exist, or exists but is not a directory — the system SHALL reject the command: the active panel's `cwd` SHALL be left completely unchanged, no listing read SHALL be attempted, and the panel's error state SHALL be set to indicate the target could not be found. +`cd ` SHALL resolve `` against the active panel's current directory (supporting `.`, `..`, bare drive letters, UNC paths, and relative paths), producing the normalized absolute path that "cd, del and rmdir targets resolve to a normalized absolute path" defines. Before navigating, the system SHALL verify the resolved target exists and is a directory. If it does, the active panel SHALL navigate to it exactly as today. If it does not — the target doesn't exist, or exists but is not a directory — the system SHALL reject the command: the active panel's `cwd` SHALL be left completely unchanged, no listing read SHALL be attempted, and the panel's error state SHALL be set to indicate the target could not be found. + +The check is made in one of two ways: + +- When the resolved target is an entry already listed in the active panel, that listing SHALL answer the check immediately. +- Otherwise the check SHALL run asynchronously on that resolved path, off the event-loop thread, through the `\\?\` long-path abstraction, so that targets longer than 260 characters and UNC targets are checked correctly. While it is outstanding, input and redraw SHALL continue and the panel's status line SHALL show `Checking …`. + +An asynchronous answer SHALL be applied only if both of these hold: + +- it is still the most recent command-line check; +- the panel the command was entered for still shows the directory it showed when the command was entered. + +Otherwise it SHALL be discarded with no navigation and no error message. #### Scenario: cd navigates to a valid relative subdirectory - **WHEN** the active panel is at `C:\NORTON` containing subdirectory `sub`, and the command buffer is `cd sub` @@ -136,9 +164,39 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA - **WHEN** the active panel is at `C:\NORTON`, `readme.txt` is a file in it, and the command buffer is `cd readme.txt` - **THEN** the active panel's current directory remains `C:\NORTON` and the panel shows an error indicating the target is not a directory +#### Scenario: A listed subdirectory needs no filesystem check +- **WHEN** the active panel at `C:\NORTON` lists the subdirectory `sub` and the command buffer is `cd sub` +- **THEN** navigation to `C:\NORTON\sub` begins in response to the Enter itself, without waiting for any filesystem check + +#### Scenario: cd to an unresponsive UNC path does not freeze the UI +- **WHEN** the active panel is at `C:\NORTON` and the command buffer is `cd \\offline-server\share`, whose server does not respond +- **THEN** while the check is outstanding the UI keeps redrawing and accepting keys, the panel's status line shows `Checking \\offline-server\share…`, and the panel's current directory remains `C:\NORTON` +- **AND** when the check finally fails, the panel shows an error indicating the target was not found and no listing read was started + +#### Scenario: cd to a path longer than 260 characters +- **WHEN** the command buffer is `cd` followed by an existing directory whose absolute path exceeds 260 characters and which is not listed in the active panel +- **THEN** the check finds the directory and the active panel navigates to it, rather than reporting it as not found + +#### Scenario: A late answer after navigating elsewhere is discarded +- **WHEN** `cd \\slow-server\share` is outstanding and the user presses Enter on the listed directory `docs` before its check completes +- **THEN** the panel shows `docs`, and the late answer for `\\slow-server\share` neither navigates the panel nor sets an error + ### Requirement: del and rmdir route into the existing delete-confirmation flow -`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. +`del ` and `rmdir ` SHALL resolve `` against the active panel's current directory, producing the normalized absolute path that "cd, del and rmdir targets resolve to a normalized absolute path" defines. `.` 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. + +The existence and type check is made in one of two ways: + +- When the resolved target is an entry already listed in the active panel, that listing SHALL answer the check immediately. +- Otherwise the check SHALL run asynchronously on that resolved path, off the event-loop thread, through the `\\?\` long-path abstraction. While it is outstanding, input and redraw SHALL continue and the panel's status line SHALL show `Checking …`. + +An asynchronous answer SHALL be applied, opening the dialog or setting the error, only if all of these hold: + +- it is still the most recent command-line check; +- the panel the command was entered for still shows the directory it showed when the command was entered; +- the panels still own input, with no menu, dialog, or overlay open and no file operation in progress. + +Otherwise it SHALL be discarded and no dialog SHALL open. #### 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` @@ -164,3 +222,75 @@ When the user presses Enter with a non-empty command-line buffer, the system SHA - **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 +#### Scenario: del on an unlisted target on a slow share does not freeze the UI +- **WHEN** the command buffer is `del \\nas\share\old.log`, which is not listed in the active panel, and the server answers slowly +- **THEN** the UI keeps redrawing and accepting keys while the panel's status line shows `Checking \\nas\share\old.log…` +- **AND** once the check reports a file, the delete-confirmation dialog opens naming `old.log`, and nothing is deleted until it is accepted + +#### Scenario: A late answer does not open the dialog over a menu +- **WHEN** `rmdir \\nas\share\tmp` is outstanding and the user opens the F9 menu before its check completes +- **THEN** the menu stays open and the delete-confirmation dialog does not open when the check completes + +#### Scenario: del on a path longer than 260 characters +- **WHEN** the command buffer is `del` followed by an existing file whose absolute path exceeds 260 characters and which is not listed in the active panel +- **THEN** the delete-confirmation dialog opens naming that file, rather than the target being reported as not found + +### 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 + +### 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 + +### Requirement: cd, del and rmdir targets resolve to a normalized absolute path + +The path a `cd`, `del` or `rmdir` target resolves to SHALL be an absolute path with no `.` or `..` components, whenever the active panel's directory is absolute. The supported forms keep their meaning: `.`, `..`, a bare drive letter, a UNC path, an absolute path, and a path relative to the active panel's directory. Two forms are defined precisely: + +- A path containing `..` components, such as `..\x`, SHALL resolve to the directory it names after those components are removed, and SHALL NOT be kept as typed. +- A drive-relative path (`D:x`) SHALL resolve against the active panel's directory when that directory is on drive `D:`, and against `D:\` otherwise. It SHALL NOT depend on any process-wide current directory. + +These are the same rules the destination input dialog uses (operation-dialogs "Relative destinations resolve against the source panel directory"). The existence check, the navigation, and the delete-confirmation flow SHALL all use the resolved path. + +#### Scenario: cd with a parent-relative path lands on the normalized directory +- **WHEN** the active panel is at `C:\work\proj`, `C:\work\x` exists, and the command buffer is `cd ..\x` +- **THEN** the active panel navigates to `C:\work\x` and lists it successfully +- **AND** the panel's current directory contains no `..` component + +#### Scenario: cd with a drive-relative path resolves against the panel or the drive root +- **WHEN** the active panel is at `D:\work`, `D:\work\old` exists, and the command buffer is `cd D:old` +- **THEN** the active panel navigates to `D:\work\old` +- **AND** when the active panel is instead at `C:\work` and `D:\old` exists, the same command navigates to `D:\old` + +#### Scenario: del with a parent-relative path names the resolved file +- **WHEN** the active panel is at `C:\work\proj`, `C:\work\old.txt` is a file, and the command buffer is `del ..\old.txt` +- **THEN** the delete-confirmation dialog opens for `C:\work\old.txt`, and nothing is deleted until it is accepted + diff --git a/openspec/specs/configuration-files/spec.md b/openspec/specs/configuration-files/spec.md new file mode 100644 index 0000000..d1d0b28 --- /dev/null +++ b/openspec/specs/configuration-files/spec.md @@ -0,0 +1,209 @@ +# configuration-files Specification + +## Purpose +TBD - created by archiving change config-parsing-robustness. Update Purpose after archive. +## Requirements +### Requirement: Configuration files tolerate a UTF-8 BOM and inline comments + +The system SHALL ignore a leading UTF-8 byte-order mark (`EF BB BF`) when reading `config.toml`, `usermenu.toml`, and `history.json`, so the first line or key is read exactly as if no byte-order mark were present. + +In `config.toml` and `usermenu.toml`, a `#` that appears outside a quoted string SHALL begin a comment that extends to the end of the line, on key lines and on table-header lines, with one exception: in an unquoted `config.toml` value, a `#` SHALL begin a comment only when it is the first character of the value or is preceded by whitespace. The comment SHALL be excluded from the key's value. A `#` inside a quoted value SHALL be kept as part of the value. Comment handling SHALL NOT change how the characters of a quoted `config.toml` value are interpreted: its text between the quotes SHALL be taken verbatim, as before. Both files SHALL be tokenized by a single shared line tokenizer in `filecommand-core` that is unit-testable without a terminal. + +#### Scenario: BOM-prefixed config.toml reads its first key + +- **WHEN** `config.toml` begins with a UTF-8 byte-order mark immediately followed by the line `splash = false`, and the application starts +- **THEN** the splash is skipped, exactly as when the file has no byte-order mark + +#### Scenario: BOM-prefixed usermenu.toml loads its entries + +- **WHEN** `usermenu.toml` begins with a UTF-8 byte-order mark immediately followed by `[[entry]]`, a `label`, and a `command` line +- **THEN** that entry appears in the F2 user menu and the file is not treated as malformed + +#### Scenario: BOM-prefixed history.json loads + +- **WHEN** `history.json` begins with a UTF-8 byte-order mark and otherwise holds valid command and directory history +- **THEN** the command history and the fuzzy jump history load as if the byte-order mark were absent + +#### Scenario: Inline comment after a boolean value + +- **WHEN** `config.toml` contains `splash = false # start straight in the panels` +- **THEN** the splash is skipped and no warning is raised for that line + +#### Scenario: Inline comment after a quoted value + +- **WHEN** `config.toml` contains `theme = "purple-lights" # favourite` +- **THEN** the application starts with the `purple-lights` theme active + +#### Scenario: Hash inside a quoted value is kept + +- **WHEN** `config.toml` contains `editor = "C:\Tools\C# Editor\edit.exe"` +- **THEN** the configured external editor command is exactly `C:\Tools\C# Editor\edit.exe`, backslashes included + +#### Scenario: Hash inside an unquoted word is kept + +- **WHEN** `config.toml` contains `editor = C:\Tools\C#\edit.exe` +- **THEN** the configured external editor command is exactly `C:\Tools\C#\edit.exe` + +#### Scenario: Comment after a table header + +- **WHEN** `config.toml` contains the line `[mouse] # pointer settings` followed by `enabled = false` +- **THEN** mouse capture is disabled + +#### Scenario: Inline comment in usermenu.toml + +- **WHEN** an `[[entry]]` in `usermenu.toml` contains `label = "Build" # main build` and `command = "cargo build"` +- **THEN** the user menu lists an entry labelled `Build` that runs `cargo build`, and the file is not treated as malformed + +### 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`, `[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: 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 + +### Requirement: Persisted configuration writes are durable + +Every write the system makes to `config.toml` or `usermenu.toml` SHALL follow the same sequence. This covers the theme and panel-split rewrites, the first-run creation of the default `usermenu.toml`, and any later configuration write. The content SHALL be written to a uniquely named temporary file in the same directory and flushed to stable storage, and only then SHALL that file replace the target, so an interruption at any point leaves the target with either its complete previous content or its complete new content. If the write fails, the target SHALL be left unchanged, the temporary file SHALL be removed, and the session SHALL continue with the in-memory setting in effect. + +#### Scenario: Theme write is flushed before it replaces config.toml + +- **WHEN** the user applies a theme and `config.toml` is rewritten +- **THEN** the new content is written to a temporary file in the profile directory and flushed to stable storage before it replaces `config.toml` + +#### Scenario: Interrupted configuration write leaves a complete file + +- **WHEN** the process or the machine stops while `config.toml` is being rewritten +- **THEN** `config.toml` holds either its complete previous content or its complete new content, never a truncated or empty file + +#### Scenario: Failed configuration write leaves the file untouched + +- **WHEN** rewriting `config.toml` fails, for example because the disk is full +- **THEN** `config.toml` is unchanged, no temporary file remains in the profile directory, and the applied theme stays active for the session + +#### Scenario: First-run user menu is created durably + +- **WHEN** FileCommand starts with no `usermenu.toml` in the profile directory +- **THEN** the default `usermenu.toml` is written through a flushed temporary file that replaces the target, so a crash during first run can never leave a partial user-menu file + +### Requirement: Configuration rewrites keep settings in the top-level table + +When the system rewrites a top-level `config.toml` setting (`theme` or `panel_split`), it SHALL replace only an occurrence of that key that appears before the first table header, keeping that line's trailing comment. When no such occurrence exists, it SHALL insert the new line immediately before the first table header, or at the end of the file when the file has no table. A leading UTF-8 byte-order mark SHALL be kept, and every other line SHALL be left byte-for-byte unchanged. + +#### Scenario: New key is inserted above a trailing table + +- **WHEN** `config.toml` contains only `[mouse]` and `enabled = false`, and the user applies the `purple-lights` theme +- **THEN** the line `theme = "purple-lights"` is written above `[mouse]`, and after a restart the `purple-lights` theme is active and mouse capture is still disabled + +#### Scenario: Existing line is replaced with its comment kept + +- **WHEN** `config.toml` contains `theme = "nc-classic" # my pick` and the user applies `yellow-storm` +- **THEN** that line becomes `theme = "yellow-storm" # my pick` and every other line is unchanged + +#### Scenario: Byte-order mark is preserved by a rewrite + +- **WHEN** `config.toml` begins with a UTF-8 byte-order mark followed by `theme = "nc-classic"`, and the user applies `inverted` +- **THEN** the rewritten file still begins with the byte-order mark and contains exactly one `theme` line, `theme = "inverted"` + +#### Scenario: Split rewrite follows the same placement + +- **WHEN** `config.toml` contains only a `[mouse]` table and the user adjusts the panel split to 60% +- **THEN** `panel_split = 60` is written above `[mouse]`, and after a restart the panels open at that split + +### Requirement: History file strings decode without losing entries + +When reading `history.json`, the system SHALL decode a `\uXXXX` escape pair that forms a UTF-16 surrogate pair as the single character it encodes. It SHALL decode a lone surrogate escape, or a `\u` escape without four hexadecimal digits, as the replacement character U+FFFD and continue reading. Such an escape SHALL NOT cause any other command or directory entry to be dropped. The system SHALL recognize the `"commands"` and `"directories"` keys only as keys of the top-level object, never as text inside a string value. + +#### Scenario: Surrogate pair decodes to one character + +- **WHEN** a command in `history.json` is stored as `"echo \uD83D\uDE00"` +- **THEN** the loaded command history contains `echo 😀` + +#### Scenario: Lone surrogate does not drop later commands + +- **WHEN** `history.json` stores the commands `"cd a"`, `"cd b\uD83D"`, and `"cd c"` in that order +- **THEN** the loaded command history contains three entries: `cd a`, `cd b` followed by U+FFFD, and `cd c` + +#### Scenario: Key text inside a string is not mistaken for a key + +- **WHEN** the `"commands"` array in `history.json` contains the string `"directories"` followed by the string `"cd C:\\"`, and the file's top-level `"directories"` key holds two directory entries +- **THEN** both directory entries are loaded into the fuzzy jump history diff --git a/openspec/specs/file-action-menu/spec.md b/openspec/specs/file-action-menu/spec.md index 8e22b8b..c85cf7b 100644 --- a/openspec/specs/file-action-menu/spec.md +++ b/openspec/specs/file-action-menu/spec.md @@ -2,9 +2,7 @@ ## Purpose TBD - created by archiving change enter-file-action-menu. Update Purpose after archive. - ## Requirements - ### Requirement: Enter on a file opens the action menu When the command-line buffer is empty and the user presses Enter with the active panel's cursor on a file entry, the system SHALL open a modal file-action menu targeting that entry. The menu SHALL target the cursor entry only — it SHALL NOT consume or alter the multi-entry selection. Enter on a directory or the `..` entry SHALL continue to navigate, and Enter with a non-empty command-line buffer SHALL continue to run the typed command; neither is affected by this menu. Menu state SHALL live in `filecommand-core` with all mutations flowing through `core::update`. @@ -34,17 +32,17 @@ When the command-line buffer is empty and the user presses Enter with the active ### Requirement: Menu contents, ordering, and navigation -The file-action menu SHALL list the entries View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order, and SHALL additionally list Run as the first entry 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 (R resolves to Run when Run is listed, otherwise to Rename). Rendering SHALL use only ANSI-16 named color roles and CP437 glyphs. +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 View, Edit, Send to clipboard, Copy, Rename, Move, Delete in that order with View highlighted +- **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, View, Edit, Send to clipboard, Copy, Rename, Move, Delete with Run highlighted +- **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 @@ -57,6 +55,11 @@ The file-action menu SHALL list the entries View, Edit, Send to clipboard, Copy, - **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` @@ -64,7 +67,12 @@ The file-action menu SHALL list the entries View, Edit, Send to clipboard, Copy, ### 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: 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. +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 @@ -95,12 +103,17 @@ Each file-action menu entry SHALL route into the existing capability flow for th ### Requirement: In-place Rename -The Rename menu entry SHALL open an input dialog pre-filled with the target entry's current name, with the text cursor positioned in the field. 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. +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 text cursor in the field +- **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 @@ -119,18 +132,28 @@ The Rename menu entry SHALL open an input dialog pre-filled with the target entr ### 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. Enter-key invocation SHALL remain single-target and file-only as previously specified. The directory menu SHALL list its entries as Send to clipboard, Copy, Rename, Move, Delete in that order, matching the file-target menu's placement of Send to clipboard immediately after the (here-omitted) View/Edit entries. +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 Send to clipboard, Copy, Rename, Move, Delete in that order +- **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 @@ -154,3 +177,23 @@ No file-action menu entry SHALL mutate the filesystem directly upon activation: - **WHEN** the user activates Send to clipboard for `notes.txt` - **THEN** the filesystem is unchanged and only the clipboard contents differ + +### 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/specs/file-operations/spec.md b/openspec/specs/file-operations/spec.md index 0edd03a..90c9b23 100644 --- a/openspec/specs/file-operations/spec.md +++ b/openspec/specs/file-operations/spec.md @@ -30,6 +30,16 @@ Copy, move, delete, and make-directory operations SHALL each execute as a `Job` A move (F6 Rename/Move) whose source and destination reside on the same volume SHALL be performed as a single `rename` (instant, no data movement). A move across volumes SHALL be performed as a copy followed by deletion of the source, and the source SHALL be deleted only after the copy has been verified successful. Make-directory (F7) SHALL create the named directory at the destination. +A copied file SHALL count as verified only when all of the following hold: + +- its data has been flushed to stable storage; +- the destination's length equals the number of bytes copied; +- no write, flush, or close error was reported. + +Any such error, or a length mismatch, SHALL be treated as a failure of that file's copy. The failure SHALL be offered through the error-recovery dialog, and the source SHALL be kept. + +Removing a source after a verified copy SHALL also be offered through the error-recovery dialog when it fails. A source that cannot be removed SHALL NOT be silently ignored. When the user skips it, it SHALL be listed in the end-of-job skipped-files summary as copied but not removed. + #### Scenario: Same-volume move renames instead of copying - **WHEN** an entry is moved to a destination on the same volume - **THEN** the operation is a single `rename` and no byte-copy of the content occurs @@ -42,6 +52,16 @@ A move (F6 Rename/Move) whose source and destination reside on the same volume S - **WHEN** a cross-volume copy fails before completing/verifying - **THEN** the source is NOT deleted +#### Scenario: A flush failure keeps the source +- **WHEN** a cross-volume move has written all of `a.txt`'s data to the destination but flushing it to stable storage reports an error +- **THEN** the error-recovery dialog is shown for `a.txt` and the source `a.txt` is not deleted +- **AND** if the user chooses Skip, no file named `a.txt` and no partial file is left at the destination + +#### Scenario: A source that cannot be removed is reported +- **WHEN** a cross-volume move copies and verifies `a.txt` but removing the source `a.txt` fails because another program has it open +- **THEN** the error-recovery dialog is shown for `a.txt` +- **AND** if the user chooses Skip, the verified copy stays at the destination and the end-of-job summary lists `a.txt` as copied but not removed + --- ### Requirement: Identity-aware case-only rename @@ -60,7 +80,22 @@ The target-exists check used by rename/move SHALL compare file identity (volume ### Requirement: Copy preserves alternate data streams, attributes, and timestamps -A copy SHALL preserve alternate NTFS data streams, file attributes, and timestamps of the source on the copied file. Where a target already exists and carries a read-only attribute that would block an overwrite or delete, the operation SHALL clear that attribute as needed to proceed. +A copy, including the copy half of a cross-volume move, SHALL preserve on the copied file: + +- the source's named alternate NTFS data streams; +- the read-only, hidden, system, and archive attributes; +- the creation and last-write timestamps. + +On a platform or filesystem that lacks some of these, the copy SHALL preserve the ones it supports, and at minimum the last-write time and the read-only state. + +A copy SHALL NOT be required to preserve: + +- the source's security descriptor (ACLs and owner). The copy takes the permissions inherited at its destination. +- the last-access time. +- sparse or compression state. +- EFS encryption on a destination that cannot hold it. The copy is then written unencrypted. + +Where a target already exists and carries a read-only attribute that would block an overwrite or delete, the operation SHALL clear that attribute as needed to proceed. #### Scenario: Alternate data streams and metadata survive a copy - **WHEN** a file carrying an alternate data stream and specific timestamps/attributes is copied @@ -70,12 +105,26 @@ A copy SHALL preserve alternate NTFS data streams, file attributes, and timestam - **WHEN** an overwrite (or delete) targets a file whose read-only attribute would otherwise block the operation - **THEN** the read-only attribute is cleared so the operation can complete +#### Scenario: Hidden and system attributes survive a copy +- **WHEN** a file with the hidden, system, and archive attributes set is copied +- **THEN** the copy has the hidden, system, and archive attributes set, so it renders as hidden in the destination panel just as the source does + +#### Scenario: Creation time survives a copy +- **WHEN** a file created at 2001-02-03 04:05 and last written at 2002-03-04 05:06 is copied +- **THEN** the copy reports a creation time of 2001-02-03 04:05 and a last-write time of 2002-03-04 05:06 + +#### Scenario: A moved file keeps its metadata +- **WHEN** a read-only file carrying an alternate data stream is moved across volumes +- **THEN** the file at the destination has the same alternate data stream content, the read-only attribute, and the source's creation and last-write timestamps + --- ### Requirement: Reparse-point (symlink/junction) semantics Reparse points SHALL be handled distinctly from ordinary directories: deleting a reparse point SHALL remove the link itself and never the target's contents; copying a reparse point SHALL copy the link target's content by default. Recursive operations SHALL carry recursion-cycle protection via a visited file-identity set and SHALL NOT traverse into a junction that points inside the source tree being processed. +A move performed as copy-then-delete (a cross-volume move) SHALL treat every reparse point in the source — a selected link or one anywhere inside a selected directory tree — as a leaf for its source-removal half. The copy half copies the link target's content to the destination per the copy rule above. The source-removal half SHALL remove only the link itself, exactly as delete does, and SHALL NOT delete, empty, or change the attributes of any file or directory reached through the link. A link SHALL be removed only after every item copied through it was copied successfully. If any item beneath it was skipped or failed, the link SHALL be left in place. A link that cannot be removed SHALL be reported in the end-of-job skipped-files summary rather than ignored. + #### Scenario: Delete removes the link, not the target - **WHEN** a job deletes a junction or symlink - **THEN** the link is removed and the files under the link's target directory remain untouched @@ -88,6 +137,21 @@ Reparse points SHALL be handled distinctly from ordinary directories: deleting a - **WHEN** a recursive copy encounters a junction pointing to a directory already inside the source tree (present in the visited-identity set) - **THEN** the job does not traverse into it, avoiding an infinite recursion cycle +#### Scenario: Cross-volume move of a tree containing a junction to an outside directory leaves that directory's files intact +- **WHEN** `C:\work\proj` contains `file.txt` and a junction `shared` pointing to `C:\shared-libs` (which holds `lib.dll` and a subdirectory `x86`), and the user moves `proj` to `D:\backup` +- **THEN** `D:\backup\proj\file.txt`, `D:\backup\proj\shared\lib.dll`, and `D:\backup\proj\shared\x86` exist at the destination as copies of the content +- **AND** `C:\shared-libs`, `C:\shared-libs\lib.dll`, and `C:\shared-libs\x86` still exist with their original content and attributes +- **AND** the source `C:\work\proj`, including the junction `shared` itself, is removed + +#### Scenario: Cross-volume move of a selected junction removes only the link +- **WHEN** the user moves a selected junction `C:\work\link` (pointing to `C:\data`) to `D:\backup` +- **THEN** `D:\backup\link` receives a copy of the content of `C:\data` +- **AND** the junction `C:\work\link` is removed while `C:\data` and every file in it remain untouched + +#### Scenario: A link whose content was not fully copied is kept +- **WHEN** a cross-volume move copies through a junction and one file beneath it is skipped (by an overwrite-conflict Skip or an error-recovery Skip) +- **THEN** the junction is left in place in the source, the link target's files are untouched, and the skipped file is listed in the end-of-job skipped-files summary + --- ### Requirement: Long-path correctness @@ -106,7 +170,7 @@ All file-system access performed by a job SHALL route through the path abstracti ### 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. +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 @@ -124,3 +188,87 @@ When a job finishes — including a cancellation after partial progress — ever - **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 + +### Requirement: A copy never overwrites its own source + +When a planned copy's target resolves to the same file as its source, the copy SHALL NOT write to the target. This applies to a Copy and to the copy half of a move performed as copy-then-delete. The target SHALL count as the same file when either of these holds: + +- after following links, it has the same file identity as the source; +- the identity of either one cannot be determined, and the two normalized absolute paths are equal, compared case-insensitively on Windows. + +Such an item SHALL NOT raise the overwrite-conflict dialog. It SHALL be skipped and listed in the end-of-job skipped-files summary with a reason stating that the source and destination are the same file. A move in that situation SHALL NOT delete the source. + +#### Scenario: F5 into the directory the file is already in leaves it unchanged +- **WHEN** both panels show `C:\work`, the cursor is on `report.txt` (2,048 bytes), and the user presses F5 and accepts the pre-filled destination `C:\work` +- **THEN** `report.txt` still holds its original 2,048 bytes +- **AND** the end-of-job summary lists `report.txt` as skipped because the source and destination are the same file + +#### Scenario: The same file reached through a link is not overwritten +- **WHEN** `C:\alias` is a junction to `C:\work` and the user copies `C:\work\report.txt` to `C:\alias` +- **THEN** `report.txt` is not modified and is listed as skipped because the source and destination are the same file + +#### Scenario: A copy-then-delete move onto the same file keeps the source +- **WHEN** a move that runs as copy-then-delete reaches `report.txt` and its target is the same file as the source +- **THEN** `report.txt` is neither modified nor deleted +- **AND** the end-of-job summary lists `report.txt` as skipped because the source and destination are the same file + +### Requirement: Progress updates are rate-limited + +While a file-operation job runs, the system SHALL deliver progress updates to the UI at most about 20 times per second, however quickly the job processes bytes or files. The system SHALL always deliver the job's latest progress before it asks an overwrite-conflict or error question and before it reports completion or cancellation, so those moments never show stale file names or counts. Rate-limiting SHALL NOT delay cancellation: the job SHALL still observe the cancel signal at every file boundary and between chunk copies. + +#### Scenario: A fast copy does not flood the UI + +- **WHEN** a single 10 GB file is copied at about 2 GB/s +- **THEN** the UI receives no more than about 20 progress updates per second, rather than one per 256 KiB chunk + +#### Scenario: Deleting many small files does not flood the UI + +- **WHEN** a delete job removes 100,000 small files in a few seconds +- **THEN** the UI receives no more than about 20 progress updates per second + +#### Scenario: The final progress is always delivered + +- **WHEN** a copy job of 3 files totalling 6,000 bytes completes in less than 50 ms +- **THEN** the last progress update the UI receives before completion reports `files_done` = 3 and `bytes_done` = 6,000 + +#### Scenario: Progress is current when a conflict is asked + +- **WHEN** a copy job reports progress for having copied `a.txt` and, less than 50 ms later, asks an overwrite-conflict question for `b.txt` +- **THEN** the progress update for `a.txt` reaches the UI before the conflict question does + +--- + +### Requirement: Copies land via a temporary partial file + +A copy, including the copy half of a cross-volume move, SHALL write each file's data to a temporary file in the target's directory named `.~fc-partial`. If that name is already taken, a numbered variant `.~fc-partial-2`, `-3`, … SHALL be used. If the name would exceed the filename length limit, `` SHALL be shortened. The temporary file SHALL be created exclusively, so no existing file is ever overwritten or removed to make room for it. + +Only after the copy of that file has succeeded SHALL the temporary file be renamed to the target name. On Overwrite, that rename replaces the existing target. + +On every other outcome for that file, the temporary file SHALL be removed. That covers a copy or verification error, a rename error, a Skip, Skip All, or Abort chosen in the error-recovery dialog, and cancellation. + +As a result, an existing target chosen for Overwrite SHALL remain intact until its replacement is complete, and a failed copy SHALL never leave a truncated file under the target's name. + +#### Scenario: A successful copy appears under the target name with no partial file left +- **WHEN** `report.txt` is copied to `D:\backup` and the copy completes +- **THEN** `D:\backup\report.txt` holds the complete content and no `D:\backup\report.txt.~fc-partial` exists + +#### Scenario: A failed overwrite keeps the existing target +- **WHEN** the user chooses Overwrite for an existing `D:\backup\report.txt` and the copy then fails partway (for example, the disk becomes full) +- **THEN** `D:\backup\report.txt` still holds its previous, complete content +- **AND** no `D:\backup\report.txt.~fc-partial` remains once the user answers the error-recovery dialog + +#### Scenario: Skip after a failed copy leaves nothing behind +- **WHEN** copying `big.iso` to `D:\backup` (where no `big.iso` existed) fails partway and the user chooses Skip in the error-recovery dialog +- **THEN** neither `D:\backup\big.iso` nor `D:\backup\big.iso.~fc-partial` exists, and `big.iso` is listed in the end-of-job skipped-files summary + +#### Scenario: Cancel removes the partial file +- **WHEN** the user cancels a job while `big.iso` is partway through being copied to `D:\backup` +- **THEN** neither `D:\backup\big.iso` nor any `D:\backup\big.iso.~fc-partial*` file remains from that copy + +#### Scenario: An existing file with the temporary name is left alone +- **WHEN** `D:\backup` already contains a file named `report.txt.~fc-partial` and `report.txt` is copied there +- **THEN** that existing file is not modified or removed, the copy uses `report.txt.~fc-partial-2` as its temporary file, and `D:\backup\report.txt` receives the copy + diff --git a/openspec/specs/filesystem-error-handling/spec.md b/openspec/specs/filesystem-error-handling/spec.md index 389b886..add8376 100644 --- a/openspec/specs/filesystem-error-handling/spec.md +++ b/openspec/specs/filesystem-error-handling/spec.md @@ -74,3 +74,70 @@ When reading or re-reading a panel's directory fails (for example drive removed - **WHEN** a panel is in the read-error state and the user chooses drive change - **THEN** the panel navigates to the selected drive/path and exits the error state, without the app crashing on the prior failure +### Requirement: Error messages name the affected path + +A user-facing filesystem error SHALL identify the file or directory it concerns together with the reason for the failure. A bare operating-system message, such as "Access is denied. (os error 5)", SHALL NOT be shown on its own. + +The status-line messages that are built from an operating-system error SHALL name the item by its file or directory name; the panel's title already shows the directory it lives in. They SHALL take these forms: + +- a panel whose directory cannot be read SHALL show `Cannot read : ` in its read-error state; +- a failure to open a file in the F3 viewer or the built-in editor SHALL read `Cannot open : `; +- a failed built-in editor save SHALL read `Cannot save : `, where the reason is the operating system's message or the editor's own explanation of why it refused to save. + +Status-line messages that already name their target, such as the command line's `cd`, `del`, and `rmdir` rejections and the Open-with-associated-application failure, keep their existing text. + +The multi-line job dialogs SHALL show the full path: + +- the error-recovery dialog SHALL show the full path of the failing item above the operating-system message; +- each row of the end-of-job summary SHALL show the item's full path followed by its reason. + +#### Scenario: Access-denied listing names the directory + +- **WHEN** a panel navigates to `C:\Windows\CSC` and the read fails with access denied +- **THEN** the panel's read-error message reads `Cannot read CSC: Access is denied. (os error 5)`, and the panel title shows `C:\Windows\CSC` + +#### Scenario: Viewer open failure names the file + +- **WHEN** F3 is pressed on `locked.db` and the file cannot be opened because another process holds it exclusively +- **THEN** the panel's status line reads `Cannot open locked.db: ` followed by the operating system's sharing-violation message + +#### Scenario: Editor save failure names the file + +- **WHEN** F2 in the built-in editor fails to write `notes.txt` because the disk is full +- **THEN** the editor reports `Cannot save notes.txt: ` followed by the operating system's message, and the buffer stays modified + +#### Scenario: The error-recovery dialog shows the full path + +- **WHEN** a copy job fails to read `D:\data\reports\q3.xlsx` with a permission error +- **THEN** the error-recovery dialog shows `D:\data\reports\q3.xlsx` on one line and the operating system's message on the next + +#### Scenario: Summary rows show the full path and reason + +- **WHEN** a job ends having skipped `D:\data\old.log` because the target already exists +- **THEN** the end-of-job summary row reads `D:\data\old.log: target already exists` + +### Requirement: Real filesystem engine is integration-tested; fault injection is test-only + +The real filesystem implementation of the fs trait seam SHALL be covered by an automated integration-test suite. The suite SHALL run file-operation jobs through the real implementation against temporary directories on the real filesystem, and SHALL remove those directories afterwards. It SHALL cover at least: + +- copying a file that carries alternate data streams, attributes, and timestamps; +- a junction inside a tree that is moved; +- paths longer than 260 characters; +- overwriting a read-only target; +- a case-only rename; +- cleanup after a cancelled copy. + +Tests that depend on Windows- or NTFS-specific behavior SHALL be compiled and run only on Windows. The fake filesystem and its fault-injection API SHALL be compiled only for tests and SHALL NOT be part of the release binary. + +#### Scenario: The suite exercises the real implementation on temporary directories +- **WHEN** `cargo test --workspace` runs on Windows +- **THEN** the real-filesystem suite runs copy, move, delete, and rename jobs through the real implementation on temporary directories, asserts the resulting on-disk state, and leaves none of its temporary directories behind + +#### Scenario: Windows-specific tests are gated +- **WHEN** the test suite is built for a non-Windows target +- **THEN** the alternate-data-stream, attribute, junction, and long-path tests are not compiled, and the platform-neutral real-filesystem tests still run + +#### Scenario: The release binary carries no fault-injection double +- **WHEN** the workspace is built without tests (for example `cargo build --release`) +- **THEN** the fake filesystem and its fault-injection types are not compiled into `filecommand-core`, and no non-test code can refer to them + diff --git a/openspec/specs/find-file/spec.md b/openspec/specs/find-file/spec.md index bf81f09..ca383c5 100644 --- a/openspec/specs/find-file/spec.md +++ b/openspec/specs/find-file/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m5-editor-and-modern-extras. Update Purpose af ## 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 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. +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 @@ -17,6 +17,11 @@ The system SHALL open a find-file dialog when the user presses Alt+F7 or selects - **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 @@ -78,3 +83,22 @@ The system SHALL close the find-file dialog without changing the active panel's - **WHEN** the find-file dialog is open and the user presses Esc - **THEN** the dialog closes, the active panel remains at its prior directory and cursor position, and any running search is abandoned +### Requirement: Dismissed or superseded searches stop + +When a find-file search is no longer wanted — the dialog is dismissed with Esc, a result is confirmed while the walk is still running, or a new search replaces it — the system SHALL signal the subtree walk to stop. The walk SHALL check that signal before reading each directory and between the entries it examines, SHALL stop promptly once it is set, and SHALL send no further results. Results of a stopped or superseded walk SHALL never appear in any find-file dialog. + +#### Scenario: Esc stops the walk + +- **WHEN** a search started at `C:\` with the pattern `a` is still walking the subtree and the user presses Esc +- **THEN** the dialog closes and the walk stops before reading another directory, producing no further results + +#### Scenario: Confirming a result while the walk is running stops it + +- **WHEN** the user highlights a result and presses Enter while the walk is still running +- **THEN** the panel navigates to the result's directory and the walk stops + +#### Scenario: A new search after Esc runs alone + +- **WHEN** the user presses Esc during a running search, reopens the dialog with Alt+F7, and submits a different pattern +- **THEN** only the new search's walk is running and only its matches appear in the result list + diff --git a/openspec/specs/fuzzy-jump/spec.md b/openspec/specs/fuzzy-jump/spec.md index 52e7600..647e6f4 100644 --- a/openspec/specs/fuzzy-jump/spec.md +++ b/openspec/specs/fuzzy-jump/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m5-editor-and-modern-extras. Update Purpose af ## 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 into which the user types a fuzzy match pattern, and Esc SHALL close the dialog without changing the active panel's directory. +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 @@ -17,6 +17,11 @@ The system SHALL open the fuzzy jump dialog when the user presses Ctrl+J (the de - **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 @@ -43,7 +48,7 @@ The system SHALL filter the visited-directory list to entries whose path is a su ### Requirement: Frecency ranking -The system SHALL rank the visited-directory list by a frecency score combining recency and visit frequency, with more recently and more frequently visited directories ranked higher, so the most likely target appears near the top. The ranking computation SHALL live in `filecommand-core` and be deterministic under a pinned clock for testability. +The system SHALL rank the visited-directory list by a frecency score combining recency and visit frequency, with more recently and more frequently visited directories ranked higher, so the most likely target appears near the top. The ranking computation SHALL live in `filecommand-core` and be deterministic under a pinned clock for testability. Recency SHALL be measured from wall-clock time. Each directory's last-visit time SHALL be recorded and persisted as Unix-epoch milliseconds, so that recency comparisons stay meaningful across application restarts. Animation and other in-session timing SHALL NOT depend on the wall clock. History entries loaded from a `history.json` written in the earlier session-relative timestamp format SHALL be treated as last visited at the time the history was loaded. A persisted last-visit time later than the current wall-clock time SHALL be treated as the current time. #### Scenario: Frequency raises rank @@ -55,6 +60,21 @@ The system SHALL rank the visited-directory list by a frecency score combining r - **WHEN** two directories have equal visit frequency and one was visited more recently - **THEN** the more recently visited directory is ranked above the other +#### Scenario: Recency survives a restart + +- **WHEN** in an earlier session that ran for several days the user visited `C:\old` on the first day and `C:\new` one minute before exiting, both directories have equal visit counts, and FileCommand is relaunched a few minutes later +- **THEN** the fuzzy jump dialog ranks `C:\new` above `C:\old` + +#### Scenario: Legacy history file is migrated at load + +- **WHEN** FileCommand starts with a `history.json` written in the earlier session-relative timestamp format +- **THEN** every loaded directory is treated as last visited at load time, so those directories rank among themselves by visit count, and visits recorded afterwards are stamped with wall-clock time + +#### Scenario: Future last-visit time is clamped + +- **WHEN** `history.json` holds a directory whose last-visit time is later than the current wall-clock time, for example after the system clock was set back +- **THEN** that directory is treated as visited at the current time and does not keep an inflated recency as time passes + ### Requirement: Enter navigates the active panel The system SHALL, when the user presses Enter on a highlighted directory in the fuzzy jump dialog, close the dialog and navigate the active panel to that directory, leaving the opposite panel unchanged. If the chosen directory no longer exists or cannot be read, the active panel SHALL show its inline panel read-error state rather than crashing. @@ -73,6 +93,17 @@ The system SHALL, when the user presses Enter on a highlighted directory in the The system SHALL record each directory the user navigates into in the frecency history and persist that history in `history.json` in the platform config directory, written atomically and shared with command history. The system SHALL load this history on startup so the fuzzy jump dialog reflects directories visited in prior sessions, and SHALL fall back to an empty history when the file is absent or malformed rather than failing to launch. +The directory history SHALL hold at most 1,000 entries. When recording a visit to a directory not yet in a full history, and when loading or merging a history larger than the cap, the system SHALL remove the entries with the lowest frecency score until the cap is met. It SHALL never remove the directory being visited. + +Writes of `history.json` SHALL NOT be performed on the UI thread. Navigation SHALL hand the latest history to a background writer and return without waiting. The writer SHALL coalesce a burst of changes into one write, and SHALL write any unwritten change within 10 seconds of it being recorded. When the application quits, any change not yet written SHALL be written before the process exits. The exit SHALL wait for that final write for a bounded time of about 2 seconds, so that an unreachable profile directory cannot keep the process from exiting. + +Before each write, the system SHALL re-read `history.json` and merge it with its own history so that concurrently running instances do not discard each other's entries: + +- Directories SHALL be combined by path, keeping the larger visit count and the later last-visit time for a path present in both. +- Commands present only in the file SHALL be kept as older than this instance's commands, subject to the existing command-history cap. + +Each write SHALL go to a uniquely named temporary file in the same directory, which SHALL be flushed to stable storage before it replaces `history.json`, so an interrupted write leaves either the previous or the new complete file. + #### Scenario: Navigation records history - **WHEN** the user navigates the active panel into a directory @@ -88,3 +119,43 @@ The system SHALL record each directory the user navigates into in the frecency h - **WHEN** `history.json` is absent or cannot be parsed at startup - **THEN** the fuzzy jump history loads as empty and the application launches normally without error +#### Scenario: History is capped by frecency + +- **WHEN** the directory history holds 1,000 entries and the user navigates into a directory that is not in it +- **THEN** the entry with the lowest frecency score is removed, the new directory is recorded, and the history still holds 1,000 entries + +#### Scenario: Navigation does not wait for the history write + +- **WHEN** the user navigates the active panel into a directory +- **THEN** the navigation completes without the UI thread writing `history.json`, and the visit is written by the background writer afterwards + +#### Scenario: A burst of navigation is written once + +- **WHEN** the user navigates through 20 directories within one second and then pauses +- **THEN** `history.json` is written once after the pause, and that write contains all 20 visits + +#### Scenario: Quitting flushes unwritten history + +- **WHEN** the user navigates into `C:\work` and quits FileCommand before the pending change has been written +- **THEN** after the process exits, `history.json` contains the visit to `C:\work` + +#### Scenario: Two instances keep each other's history + +- **WHEN** two FileCommand instances share one profile directory, the first navigates into `C:\a`, the second navigates into `C:\b`, and both are then closed +- **THEN** `history.json` contains directory entries for both `C:\a` and `C:\b` + +#### Scenario: Merge keeps the larger count and the later visit + +- **WHEN** `history.json` records `C:\src` with visit count 9 last visited at time T1, and this instance's history records `C:\src` with visit count 4 last visited at a later time T2 +- **THEN** the next write records `C:\src` with visit count 9 and last-visit time T2 + +#### Scenario: Another instance's commands are kept + +- **WHEN** `history.json` contains the command `cd D:\build`, which this instance never ran, and this instance writes its history +- **THEN** the written command history still contains `cd D:\build`, ordered before this instance's own commands + +#### Scenario: Interrupted write leaves a complete file + +- **WHEN** the process is terminated while `history.json` is being written +- **THEN** `history.json` holds either its complete previous content or its complete new content, never a truncated or empty file + diff --git a/openspec/specs/git-info/spec.md b/openspec/specs/git-info/spec.md index 0f102cd..5374299 100644 --- a/openspec/specs/git-info/spec.md +++ b/openspec/specs/git-info/spec.md @@ -5,7 +5,7 @@ TBD - created by archiving change m5-editor-and-modern-extras. Update Purpose af ## Requirements ### Requirement: Background repository detection -The system SHALL detect the git repository enclosing a panel's current directory on a dedicated worker thread using `git2` (libgit2), and the single-threaded UI event loop SHALL NOT block on the repository detection or status query. Detection and status results SHALL be delivered to the reducer as job events, and a fresh query SHALL be issued when the panel navigates to a new directory or is re-read (Ctrl+R), since v1 performs no filesystem watching. +The system SHALL detect the git repository enclosing a panel's current directory on a dedicated worker thread using `git2` (libgit2), and the single-threaded UI event loop SHALL NOT block on the repository detection or status query. Detection and status results SHALL be delivered to the reducer as job events, and a fresh query SHALL be issued when the panel navigates to a new directory or is re-read (Ctrl+R), since v1 performs no filesystem watching — except for a directory inside a repository that is marked "no info" for the session, for which no query is issued (see "Silent absence on timeout and stale-result discarding"). #### Scenario: Directory inside a repository @@ -19,7 +19,7 @@ The system SHALL detect the git repository enclosing a panel's current directory #### Scenario: Query re-issued on navigation -- **WHEN** the panel changes directory or the user triggers a re-read (Ctrl+R) +- **WHEN** the panel changes directory or the user triggers a re-read (Ctrl+R), and the panel's new current directory is not inside a repository marked "no info" for the session - **THEN** a new git query is issued for the panel's new current directory rather than reusing a prior result ### Requirement: Branch-name border suffix on the active panel @@ -57,7 +57,7 @@ The system SHALL render a one-cell status-marker column immediately before file ### Requirement: Pathspec-scoped status queries -The system SHALL scope git status queries to the panel's current directory via pathspec rather than querying the repository as a whole, and SHALL NOT enumerate the contents of untracked directories. +The system SHALL scope git status queries to the panel's current directory via pathspec rather than querying the repository as a whole, and SHALL NOT enumerate the contents of untracked directories. The panel directory's path SHALL be matched literally: characters that pathspec patterns treat specially — `[`, `]`, `*`, `?`, `#`, spaces, and a leading `!` — in any directory name along that path SHALL NOT be interpreted as pattern syntax, so status is reported for a directory with such a name exactly as for any other directory, and the query stays scoped to that directory. #### Scenario: Status limited to panel directory @@ -69,6 +69,26 @@ The system SHALL scope git status queries to the panel's current directory via p - **WHEN** the panel directory contains an untracked subdirectory - **THEN** the subdirectory is marked `?` but the files inside it are not individually enumerated for status +#### Scenario: Directory names are matched literally + +- **WHEN** the panel is inside a repository directory named `foo[1]` that contains a modified tracked file `a.txt` and an untracked subdirectory `new` +- **THEN** `a.txt` shows `M` and `new` shows `?`, exactly as they would in a directory named `foo1` + +#### Scenario: Pattern characters in a nested directory name + +- **WHEN** the panel is inside the repository directory `src/foo[1]`, and a sibling directory `src/foo1` also contains modified files +- **THEN** the files of `src/foo[1]` show their markers and nothing from `src/foo1` is reported for the panel + +#### Scenario: A leading exclamation mark is not a negation + +- **WHEN** the panel is inside a repository directory named `!old` that contains a modified tracked file +- **THEN** that file shows `M` + +#### Scenario: A leading hash does not widen the query + +- **WHEN** the panel is inside a top-level repository directory named `#notes` +- **THEN** the status query is scoped to `#notes` rather than walking the whole repository, and its entries show their markers + ### Requirement: Single-reflow appearance with nothing reserved while pending The system SHALL show no git indicator of any kind while a git query is pending — no reserved marker column, no placeholder branch suffix, no spinner. When the query resolves, the branch-name suffix and the status-marker column SHALL appear together in a single reflow. @@ -85,7 +105,7 @@ The system SHALL show no git indicator of any kind while a git query is pending ### Requirement: Silent absence on timeout and stale-result discarding -The system SHALL degrade to "no git info" silently on timeout, discarding the stale result and marking the repository as "no info" for the remainder of the session while leaving the uncancellable worker call to finish. Results keyed to a directory or generation the panel has since moved past SHALL be dropped and SHALL NOT alter panel rendering. +The system SHALL degrade to "no git info" silently on timeout, discarding the stale result and marking the repository as "no info" for the remainder of the session while leaving the uncancellable worker call to finish. While a repository is marked "no info", navigating to, re-reading, or swapping a panel into any directory inside that repository's working tree SHALL NOT start another git query, and the panel SHALL show no branch suffix, no marker column, and no error for it; when the repository could not be located before the timeout, the queried directory and everything beneath it SHALL be marked instead. A timeout SHALL mark the repository even when the panel has already moved elsewhere. Each panel SHALL have at most one git query running at a time: a navigation while a query is running SHALL wait for that query to report and then query only the panel's most recent directory, a waiting request superseded by a newer navigation SHALL never be started, and a waiting request for a directory inside the repository that the running query has just timed out on SHALL never be started either. Results keyed to a directory or generation the panel has since moved past SHALL be dropped and SHALL NOT alter panel rendering. #### Scenario: Timeout degrades silently @@ -97,3 +117,38 @@ The system SHALL degrade to "no git info" silently on timeout, discarding the st - **WHEN** a git result arrives for a directory the panel has already navigated away from - **THEN** the result is discarded via its directory/generation key and the current panel rendering is unchanged +#### Scenario: Timed-out repository is not queried again + +- **WHEN** a git query for `D:\mono\src` in the repository rooted at `D:\mono` has timed out, and the user then navigates the panel to `D:\mono\docs` or presses Ctrl+R there +- **THEN** no git query is started, and the panel shows no branch suffix and no marker column + +#### Scenario: Swapping a panel into a timed-out repository + +- **WHEN** the repository at `D:\mono` is marked "no info" and the user presses Ctrl+U while the other panel shows `D:\mono\lib` with branch info it had resolved before the timeout +- **THEN** no git query is started for that panel after the swap, and it shows no branch suffix and no marker column + +#### Scenario: Other repositories are still queried + +- **WHEN** the repository at `D:\mono` is marked "no info" and the user navigates to `C:\work\app`, inside a different repository +- **THEN** a git query is started for `C:\work\app` and its branch suffix and markers appear when it resolves + +#### Scenario: Timeout recorded after the panel moved on + +- **WHEN** the user leaves `D:\mono\src` before its git query times out, and the timeout is reported afterwards +- **THEN** the repository at `D:\mono` is still marked "no info", and a later navigation into it starts no git query + +#### Scenario: Repository not located before the timeout + +- **WHEN** a git query for `\\server\share\proj` times out before the enclosing repository was even located +- **THEN** `\\server\share\proj` and every directory beneath it are treated as "no info" for the session + +#### Scenario: Rapid navigation does not pile up queries + +- **WHEN** a panel's git query is still running and the user navigates through three more directories in quick succession, and the running query then completes within its timeout +- **THEN** no second query runs for that panel while the first is running, and once the first reports, only the last of the three directories is queried + +#### Scenario: Waiting query inside the timed-out repository is not started + +- **WHEN** a panel's git query for `D:\mono\src` is still running, the user navigates to `D:\mono\docs` while it runs, and the running query then times out +- **THEN** no git query is started for `D:\mono\docs`, and the panel shows no branch suffix and no marker column there + diff --git a/openspec/specs/mouse-input/spec.md b/openspec/specs/mouse-input/spec.md index d54e9d3..11064db 100644 --- a/openspec/specs/mouse-input/spec.md +++ b/openspec/specs/mouse-input/spec.md @@ -171,3 +171,41 @@ A left-click on an enabled file-action-menu row SHALL activate that entry exactl - **WHEN** the file-action menu is open and the pointer moves over a row without a click - **THEN** the highlighted entry does not change +### Requirement: Click targets match the rendered geometry + +Every dialog and menu that accepts clicks SHALL compute its rectangle and the rectangles of its buttons or rows through one layout computation per frame, and its renderer and its hit test SHALL both use that computation. This covers: +- the delete confirmation; +- the drop-initiated destination dialog's button row; +- the quit confirmation; +- the progress dialog; +- the overwrite-conflict dialog; +- the error dialog; +- the skipped-items summary; +- the F9 menu bar and its pull-downs; +- the file-action menu. + +The hit map SHALL be recorded by the same dispatch that draws the frame. At every terminal size from the 60x16 floor upward, and after every resize, the cells a click maps to each button or row SHALL be exactly the cells where that button's label or that row is drawn. In a modal dialog a click on any other cell SHALL do nothing, as "Mouse is honoured only where the key would be" already requires; the F9 pull-downs and the file-action menu keep their existing handling of a click that hits none of their titles or rows. The overlay rectangle itself SHALL still come from the unified overlay geometry rule (responsive-layout "Unified overlay geometry"). + +#### Scenario: Delete confirmation buttons at the size floor + +- **WHEN** the delete confirmation for a file with a 200-character name is open at 60x16, with its question truncated with `…` +- **THEN** a click on any cell of the drawn `(Y)es` label confirms exactly as pressing Y, and a click on any cell of the drawn `(N)o` label cancels exactly as pressing N +- **AND** a click on the frame, on the question, or on the spaces between the two labels does nothing + +#### Scenario: Quit dialog hotkey cells + +- **WHEN** the quit-confirmation dialog is open, at 80x24 or at 60x16 +- **THEN** a click on the drawn `Y` of `(Y/N)` confirms quit and a click on the drawn `N` cancels it +- **AND** a click on any other cell of the dialog does nothing + +#### Scenario: Progress dialog Cancel follows a resize + +- **WHEN** a copy job's progress dialog is shown and the terminal is resized from 120x40 to 80x24 +- **THEN** a click on the cells where `[ Cancel ]` is now drawn cancels the job +- **AND** a click on a cell where the button was drawn before the resize, but no longer is, does nothing + +#### Scenario: Geometry agrees at every size + +- **WHEN** any listed dialog or menu is rendered at any terminal size between 60x16 and 200x60 +- **THEN** for each of its buttons or rows, the set of cells the hit map assigns to it equals the set of cells where its label or row is drawn + diff --git a/openspec/specs/operation-dialogs/spec.md b/openspec/specs/operation-dialogs/spec.md index a1cfa3a..741f8ed 100644 --- a/openspec/specs/operation-dialogs/spec.md +++ b/openspec/specs/operation-dialogs/spec.md @@ -5,13 +5,18 @@ TBD - created by archiving change m2-file-operations. Update Purpose after archi ## 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 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 text cursor is positioned in the field so the user can edit or accept it +- **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 @@ -49,7 +54,15 @@ When a drag-and-drop ends over a valid target, the system SHALL open the destina ### Requirement: Overwrite-conflict dialog -The system SHALL present, when a copy or move would overwrite an existing target, an overwrite-conflict dialog that displays the source's and the target's size and date and offers the choices Overwrite, Skip, Rename, Overwrite All, and Skip All. +The system SHALL present an overwrite-conflict dialog when a copy or move would overwrite an existing target. The dialog SHALL display the source's and the target's size and date and SHALL offer the choices Overwrite, Skip, Rename, Overwrite All, Skip All, and Cancel. Cancel SHALL be chosen with Esc, with the `C` hotkey, or by clicking the dialog's Cancel control. + +Cancel SHALL abort the job: + +- the conflicting target SHALL be left untouched and SHALL NOT be recorded as a skipped item; +- no further items SHALL be processed; +- the job SHALL end as cancelled through the same path as the progress dialog's Cancel, so the affected panels re-read and any items skipped earlier in the job are listed in the end-of-job skipped-files summary. + +While the Rename name field is open, Esc SHALL close the field and return to the dialog's choices without cancelling the job. #### Scenario: Source vs target details shown @@ -73,6 +86,22 @@ The system SHALL present, when a copy or move would overwrite an existing target - **WHEN** the overwrite-conflict dialog renders source and target dates in a snapshot test with time pinned through the injected `Clock`/formatting path - **THEN** the displayed dates are stable and reproducible across runs +#### Scenario: Esc cancels the job from the conflict dialog + +- **WHEN** a ten-file copy reaches a conflict on its third file, `report.txt`, and the user presses Esc +- **THEN** the existing `report.txt` at the destination is unchanged, files four to ten are not copied, and the job ends as cancelled +- **AND** the panels re-read, and no skipped-files summary lists `report.txt` + +#### Scenario: The Cancel control and hotkey match Esc + +- **WHEN** the overwrite-conflict dialog is shown and the user presses `C` or clicks the dialog's Cancel control +- **THEN** the job is cancelled exactly as if Esc had been pressed + +#### Scenario: Esc in the Rename field returns to the choices + +- **WHEN** the user has chosen Rename and is typing a new name, then presses Esc +- **THEN** the name field closes, the dialog shows its choices again, and the job is still waiting for an answer + ### Requirement: Progress dialog with byte gauge and Cancel The system SHALL display, while a copy/move/delete job runs, a progress dialog showing file counts, the current file path, and a byte-progress bar drawn with `█` filled and `░` empty block glyphs, with a Cancel control that requests cancellation of the running job. @@ -146,7 +175,7 @@ The system SHALL require confirmation before a Delete (triggered by F8 or the De ### Requirement: End-of-job skipped-files summary -The system SHALL display, when a job finishes and one or more items were skipped (via Skip, Skip All, or a latched skip policy from a conflict or error), a summary dialog listing the skipped items so the user knows what was not processed. +The system SHALL display, when a job finishes and one or more items were skipped (for example via Skip, Skip All, or a latched skip policy from a conflict or error), a summary dialog listing the skipped items so the user knows what was not processed. The system SHALL also display the summary when a job finishes having recorded one or more warnings (see "Metadata-restore failures are reported in the end-of-job summary"), even if nothing was skipped. A job that finishes with neither skipped items nor warnings SHALL show no summary. #### Scenario: Summary lists skipped items @@ -155,6 +184,137 @@ The system SHALL display, when a job finishes and one or more items were skipped #### Scenario: No summary when nothing was skipped -- **WHEN** a job completes with no items skipped +- **WHEN** a job completes with no items skipped and no warnings recorded - **THEN** no skipped-files summary dialog is shown +#### Scenario: Summary shown for warnings alone + +- **WHEN** a copy job completes with no items skipped but one copied file's modification time could not be restored +- **THEN** the end-of-job summary dialog is shown, titled by its warning count, and lists that warning + +### Requirement: Metadata-restore failures are reported in the end-of-job summary + +After a file's content has been copied successfully, any metadata the system restores on the copy that cannot be restored SHALL be recorded as a warning for that file, never discarded silently. This covers: + +- an alternate data stream that cannot be listed or copied; +- the modification time; +- the read-only attribute. + +When the destination volume does not support alternate data streams at all, the job SHALL record one warning instead of one per file. That warning SHALL name the destination and the number of files whose streams were not copied. + +A file with only warnings SHALL count as copied: it SHALL NOT be recorded as a skipped item, and it SHALL count toward the progress dialog's completed files. + +The end-of-job summary SHALL list warnings separately from skipped items. Each warning SHALL show the file's full path and what could not be restored, with the operating system's message. A summary that holds only warnings SHALL be titled by its warning count; one that holds both SHALL give both counts. When the summary is shown is defined by "End-of-job skipped-files summary". + +#### Scenario: Timestamp restore failure becomes a warning + +- **WHEN** a copy of `report.docx` succeeds but setting the destination's modification time fails +- **THEN** `report.docx` is counted as copied and is not listed as skipped +- **AND** the end-of-job summary is shown, titled with one warning, and lists `report.docx`'s full destination path with "modification time not restored" and the operating system's message + +#### Scenario: Alternate data stream copy failure becomes a warning + +- **WHEN** a copy of `photo.jpg` that carries a `Zone.Identifier` stream succeeds for the main content but copying the stream fails +- **THEN** the summary lists a warning naming `photo.jpg` and the `Zone.Identifier` stream with the operating system's message + +#### Scenario: A destination without stream support yields one warning + +- **WHEN** forty downloaded files, each carrying a `Zone.Identifier` stream, are copied to a FAT32 USB drive +- **THEN** all forty files are copied, and the summary shows a single warning naming the destination and stating that the streams of 40 files were not copied + +#### Scenario: Warnings and skipped items appear together + +- **WHEN** a job skips two conflicting files and records one read-only-attribute warning on a third +- **THEN** a single end-of-job summary lists the two skipped items and, separately, the one warning, and its title gives both counts + +#### Scenario: Clean job shows no summary + +- **WHEN** a job completes with no skipped items and every metadata restore succeeds +- **THEN** no end-of-job summary is shown + +### Requirement: Relative destinations resolve against the source panel directory + +A path the user confirms in the destination input dialog SHALL be interpreted relative to the **source panel's** current directory. This applies to the Copy (F5) and Rename/Move (F6) destination, however the dialog was opened (keyboard, file-action menu, or a drop), and to the make-directory (F7) name. The source panel is the panel whose entries the operation acts on; for a drop, it is the panel the drag started from. The path SHALL NOT be interpreted against the application's launch directory or any other process-wide current directory. + +The entered text SHALL resolve as follows: + +- A relative path is joined to the source panel's directory. +- `.` and `..` components are resolved. +- A bare drive letter (`D:`) means that drive's root. +- A drive-rooted path (`\x`) takes the source panel's drive. +- A drive-relative path (`D:x`) is taken relative to the source panel's directory when that directory is on drive `D:`, and relative to `D:\` otherwise. +- A UNC path or an absolute path is used unchanged. + +The resolved destination SHALL be an absolute path with no `.` or `..` components. The automatic panel re-read on completion SHALL match panels against this resolved path. If the entered text resolves to no directory (`..` at a drive root), the dialog SHALL stay open with its text unchanged and no operation SHALL start. + +#### Scenario: A relative name lands inside the source panel's directory +- **WHEN** the left panel shows `C:\work` with `report.txt` selected, FileCommand was launched from `C:\Users\me`, and the user presses F6, replaces the field with `backup`, and presses Enter +- **THEN** the move's destination is `C:\work\backup` +- **AND** nothing is moved into `C:\Users\me\backup` + +#### Scenario: A parent-relative path is resolved and normalized +- **WHEN** the source panel shows `C:\work\proj` and the user confirms the destination `..\archive` +- **THEN** the operation's destination is `C:\work\archive` +- **AND** if the other panel is showing `C:\work\archive`, it re-reads automatically when the operation completes + +#### Scenario: A bare drive letter means that drive's root +- **WHEN** the source panel shows `C:\work` and the user confirms the destination `D:` +- **THEN** the operation's destination is `D:\` + +#### Scenario: A drive-rooted path keeps the source panel's drive +- **WHEN** the source panel shows `C:\work` and the user confirms the destination `\archive` +- **THEN** the operation's destination is `C:\archive` + +#### Scenario: A drive-relative path resolves against the source panel or the drive root +- **WHEN** the source panel shows `D:\work` and the user confirms the destination `D:old` +- **THEN** the operation's destination is `D:\work\old` +- **AND** when the source panel instead shows `C:\work`, the same entry resolves to `D:\old` + +#### Scenario: UNC and absolute destinations are used unchanged +- **WHEN** the user confirms the destination `\\server\share\dest`, or `E:\backup` +- **THEN** the operation's destination is exactly `\\server\share\dest`, or `E:\backup`, regardless of the source panel's directory + +#### Scenario: An edited drop destination resolves against the drag's source panel +- **WHEN** entries dragged from the inactive right panel showing `D:\src` are dropped on the left panel, and the user replaces the drop dialog's field with `sub` and activates `[ Copy ]` +- **THEN** the copy's destination is `D:\src\sub` + +#### Scenario: A make-directory name with a parent path creates it where it points +- **WHEN** the active panel shows `C:\work\proj` and the user presses F7, types `..\shared`, and presses Enter +- **THEN** the directory `C:\work\shared` is created +- **AND** a panel showing `C:\work` re-reads when the operation completes + +#### Scenario: An unresolvable destination keeps the dialog open +- **WHEN** the source panel shows the drive root `C:\` and the user confirms the destination `..` +- **THEN** the dialog stays open with `..` in the field and no operation starts + +### Requirement: Cancellation is observed while a job waits for an answer + +While a job is paused waiting for an answer to an overwrite-conflict or error-recovery dialog, a cancellation request SHALL take effect without the dialog being answered. Quitting the application is one source of such a request. The job SHALL then: + +- stop waiting; +- treat the pending prompt as aborted, neither overwriting nor skipping the item in question; +- perform its normal cancellation cleanup; +- end as cancelled. + +If the dialog's answer can no longer reach the job, the job SHALL likewise end as cancelled rather than proceed with a default answer. Esc on the error-recovery dialog SHALL choose Abort. + +#### Scenario: A cancel request ends a job waiting on an error prompt + +- **WHEN** a job is paused on the error-recovery dialog for `locked.db` and a cancellation is requested without the dialog being answered +- **THEN** the job stops waiting promptly, does not retry or skip `locked.db`, processes no further items, and ends as cancelled + +#### Scenario: A cancel request ends a job waiting on a conflict prompt + +- **WHEN** a job is paused on the overwrite-conflict dialog and a cancellation is requested without the dialog being answered +- **THEN** the conflicting target is left untouched, no further items are processed, and the job ends as cancelled + +#### Scenario: A lost answer does not resume the job with a default + +- **WHEN** a job is paused on a conflict or error prompt and the channel that would deliver the answer is closed +- **THEN** the job ends as cancelled instead of continuing with a default Skip or Abort answer applied to that item and later items + +#### Scenario: Esc on the error-recovery dialog aborts + +- **WHEN** the error-recovery dialog is shown and the user presses Esc +- **THEN** the job stops and no further items are processed, exactly as if Abort had been selected + diff --git a/openspec/specs/panel-navigation/spec.md b/openspec/specs/panel-navigation/spec.md index da003e7..d753590 100644 --- a/openspec/specs/panel-navigation/spec.md +++ b/openspec/specs/panel-navigation/spec.md @@ -24,13 +24,23 @@ Each panel SHALL maintain its own state consisting of a current directory path, ### 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. 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. +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 @@ -58,18 +68,33 @@ The system SHALL render each panel in Full display mode with a double-line borde ### 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. 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. +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. 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◀` +- **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: 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 @@ -143,7 +168,7 @@ The system SHALL navigate the active panel to its parent directory when Ctrl+PgU ### Requirement: Streaming listing mini-status -While a directory listing is still being read, the mini-status line SHALL show `Reading… N` with a running, comma-grouped entry count that updates as chunks arrive, and SHALL revert to the normal name/size/date/time line when the read completes. Entries SHALL be inserted in sorted position as they arrive, and the panel SHALL remain interactive while the listing is incomplete, with the cursor held on the first row until the user moves it. +While a directory listing is still being read, the mini-status line SHALL show `Reading… N` with a running, comma-grouped entry count that updates as chunks arrive, and SHALL revert to the normal name/size/date/time line when the read completes. The one exception is a listing that skipped unreadable entries: on completion its mini-status SHALL show the skipped count instead, as "One unreadable entry does not fail a listing" defines. Entries SHALL be inserted in sorted position as they arrive, and the panel SHALL remain interactive while the listing is incomplete, with the cursor held on the first row until the user moves it. #### Scenario: Mini-status shows running count while reading @@ -152,7 +177,7 @@ While a directory listing is still being read, the mini-status line SHALL show ` #### Scenario: Mini-status reverts on completion -- **WHEN** a directory listing completes +- **WHEN** a directory listing completes and no entry was skipped as unreadable - **THEN** the mini-status line SHALL revert to showing the highlighted entry's name, size, date, and time #### Scenario: Cursor holds on first row during streaming @@ -165,6 +190,11 @@ While a directory listing is still being read, the mini-status line SHALL show ` - **WHEN** a directory listing is still streaming - **THEN** cursor-movement commands SHALL operate on the entries already received without waiting for the read to complete +#### Scenario: Completion after skipped entries shows the count + +- **WHEN** a directory listing completes after skipping 2 unreadable entries +- **THEN** the mini-status line reads `2 entries could not be read` instead of the highlighted entry's details + ### Requirement: Viewport scrolling keeps the cursor visible In Full display mode the panel body SHALL render a window of consecutive positions from the visible entry list, starting at the panel's scroll offset, and the cursor's position SHALL always lie within that window. The window SHALL move only when a cursor movement would otherwise place the cursor outside it, and SHALL then move the minimum distance that restores visibility — single-step movements shift the window by one line, `Home` pins the window to the top of the list, `End` pins it to the bottom, and jump-style cursor changes (paging, type-ahead jump, find-file's cursor settle) land with the cursor inside the window. @@ -196,7 +226,7 @@ In Full display mode the panel body SHALL render a window of consecutive positio ### 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), tab restore, and terminal resize. +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 @@ -223,6 +253,11 @@ Each panel's scroll offset SHALL live in `filecommand-core` panel state (with Tr - **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 + ### Requirement: Scrollbar indicator on overflow When (and only when) a panel's visible list is longer than its body window, the body's entry rows in the panel's right border column SHALL render a vertical scrollbar in place of the double-line `║` glyphs on those rows — this requirement takes precedence, on overflow, over the unbroken double-line border described by "Full display mode layout". The scrollbar SHALL use only CP437 glyphs — `░` for the track and `█` for the thumb — styled by the `panel.scrollbar` theme role, with thumb length proportional to the visible fraction (minimum one cell) and thumb position proportional to the scroll offset, touching the track's top exactly when the offset is 0 and its bottom exactly when the last position is visible. The scrollbar SHALL occupy only body entry rows: never the top border, tab-strip row, column-header row, or the bottom border and its mini-status. When the list fits the window, the right border SHALL render exactly as it does today, byte-identical. @@ -247,3 +282,196 @@ When (and only when) a panel's visible list is longer than its body window, the - **WHEN** the window sits partway through a long list - **THEN** the thumb sits strictly between the track's ends, at a row proportional to the offset +### 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 + +### 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 + +### Requirement: Superseded directory listings are discarded and cancelled + +Every directory listing the system starts for a panel SHALL be tagged with a fresh request id that becomes that panel's current listing request, and every streamed batch of entries, completion notice, and failure notice SHALL carry the id of the listing that produced it. The system SHALL apply a listing result only when its id matches the panel's current listing request and SHALL silently discard every other listing result, so that a listing superseded by a navigation, a re-read, a drive change, a file-operation refresh, or any other new listing of the same panel never adds entries to, completes, or fails the listing that replaced it. When a listing is superseded while it is still being read, the system SHALL signal its reader to stop; the reader SHALL stop at its next check and SHALL send no further entries, completion notice, or failure notice. + +#### Scenario: Backspace out of a slow directory mid-listing + +- **WHEN** the active panel enters `C:\big` and, while that listing is still streaming, the user presses Backspace on an empty command line to return to `C:\` +- **THEN** the panel shows only the entries of `C:\` — no entry from `C:\big`, not even `C:\big`'s `..` entry — and the `Reading… N` count covers only `C:\`'s entries +- **AND** the read of `C:\big` is signalled to stop + +#### Scenario: cd elsewhere mid-listing + +- **WHEN** the panel's listing of `D:\logs` is still streaming and the user runs `cd C:\Work` from the command line +- **THEN** only `C:\Work`'s entries appear in the panel, and none of `D:\logs`'s entries arrive later + +#### Scenario: Superseded completion does not complete the new listing + +- **WHEN** a superseded listing's completion notice arrives after the panel has started reading a different directory +- **THEN** the mini-status keeps showing `Reading… N` for the new listing until the new listing's own completion arrives + +#### Scenario: Superseded failure does not mark the new listing failed + +- **WHEN** the read of a directory the user has already left fails with access denied after the panel started reading another directory +- **THEN** no error is shown for the current directory and its listing continues normally + +#### Scenario: Two quick re-reads do not duplicate entries + +- **WHEN** the user presses Ctrl+R twice in quick succession on a directory with 500 entries +- **THEN** after both reads finish the panel lists each of the 500 entries exactly once + +#### Scenario: Superseded reader stops + +- **WHEN** a listing is superseded while its reader is still producing entries +- **THEN** the reader stops at its next check and sends no further entries, completion notice, or failure notice + +### Requirement: Directory links list as directories + +When a directory listing contains a link (a junction or symbolic link on Windows, a symbolic link elsewhere), the system SHALL classify the entry by following the link to its target: + +- A link whose target is a directory SHALL be listed, sorted, and rendered exactly as a directory: the directory style, `▶SUB-DIR◀` in the Size column, and Enter descends into it. +- A link whose target is a file SHALL be listed as a file, showing the target's size. +- A link whose target cannot be reached (dangling, or not followable) SHALL be listed as a file entry that Enter does not navigate into. It SHALL NOT cause the listing to fail. + +In every case, the entry's name, date and time, and hidden state SHALL be those of the link entry itself. The system SHALL record internally that the entry is a link. No link-specific marker is required in the entry row. + +Descending into a directory link SHALL make the link's own path (the containing directory joined with the link's name) the panel's current directory. Parent navigation from there SHALL return to the directory that contains the link. + +Deleting a directory link removes only the link (the scenario "Delete removes the link, not the target" under file-operations requirement "Reparse-point (symlink/junction) semantics"), so the delete confirmation for it SHALL NOT raise the second confirmation that a non-empty directory requires. + +#### Scenario: A junction to a directory lists as a directory +- **WHEN** a panel lists `C:\proj\node_modules`, which contains a junction `lodash` pointing to an existing directory +- **THEN** `lodash` is listed as a directory: it renders in the directory style with `▶SUB-DIR◀` in the Size column and sorts among the directories + +#### Scenario: Enter descends into a directory link +- **WHEN** the cursor is on the junction `lodash` in `C:\proj\node_modules` and Enter is pressed +- **THEN** the panel's current directory becomes `C:\proj\node_modules\lodash` and a listing of the link target's contents begins + +#### Scenario: Parent navigation from inside a link returns to the link's directory +- **WHEN** the panel entered `C:\proj\node_modules\lodash` through the junction and the user presses Ctrl+PgUp (or Enter on `..`) +- **THEN** the panel navigates to `C:\proj\node_modules`, not to the parent of the link's target + +#### Scenario: A directory symbolic link behaves like a junction +- **WHEN** a listed entry is a symbolic link whose target is a directory +- **THEN** it is listed as a directory and Enter descends into it + +#### Scenario: A link to a file lists as a file with the target's size +- **WHEN** a listed entry is a symbolic link to a 4,096-byte file +- **THEN** it is listed as a file and its Size column shows 4,096 + +#### Scenario: A dangling link does not fail the listing +- **WHEN** a directory contains a junction `old` whose target directory no longer exists, alongside other entries +- **THEN** the listing completes with every entry shown, `old` is listed as a file entry rather than a directory, and pressing Enter on `old` does not change the panel's directory + +#### Scenario: Deleting a directory link asks once +- **WHEN** the cursor is on the junction `lodash`, whose target directory contains files, and the user presses F8 and confirms +- **THEN** no second "non-empty directory" confirmation is shown, and the delete job starts for the link + +### Requirement: One unreadable entry does not fail a listing + +When the system reads a directory and the metadata of an individual entry cannot be read, it SHALL skip that entry, count it, and continue the listing. It SHALL NOT fail the whole listing because of it. A failure to enumerate the directory itself, rather than a single entry, SHALL still put the panel into the inline read-error state. + +When a listing completes after skipping one or more entries, the panel's mini-status line SHALL report how many were skipped: `1 entry could not be read`, or `N entries could not be read` with N comma-grouped. The message SHALL remain until that panel next lists a directory. It is shown in place of the highlighted entry's details that "Streaming listing mini-status" otherwise restores on completion. + +#### Scenario: One unreadable entry is skipped and reported +- **WHEN** a panel lists a directory of 50 entries and one entry's metadata cannot be read +- **THEN** the other 49 entries are listed and the panel is not in the read-error state +- **AND** when the listing completes the mini-status line reads `1 entry could not be read` + +#### Scenario: The message clears on the next listing +- **WHEN** the mini-status shows `2 entries could not be read` and the user presses Ctrl+R, and this time every entry is read +- **THEN** after the re-read completes the mini-status shows the highlighted entry's name, size, date, and time again + +#### Scenario: A directory that cannot be enumerated still shows the read-error state +- **WHEN** a panel attempts to list a directory and enumerating the directory itself fails (for example, access denied or the drive was removed) +- **THEN** the panel enters the inline read-error state exactly as before + +### Requirement: Large directories stream progressively and stay responsive + +The system SHALL show a directory's entries progressively while the directory is still being enumerated: the reader SHALL hand entries to the UI in batches bounded by an entry count and, while entries keep arriving, flushed at least about every 50 ms of enumeration time, so that the first entries and a growing `Reading… N` count appear before enumeration finishes, on local disks and slow network shares alike. Applying listing data SHALL NOT stall input handling or redrawing: each batch SHALL be merged into the sorted listing as a whole, in time proportional to the listing's size plus the cost of sorting the batch, never by inserting entries one at a time into the middle of the list; and the event loop SHALL apply at most about 8 ms or 64 messages of queued worker results before it processes pending input and redraws. Once the user has moved the cursor while a listing streams, arriving entries SHALL NOT move the cursor off the entry it is on, except where a same-directory re-read restores the cursor as "Same-directory re-read preserves cursor and scroll position" specifies. Name-based ordering and quick-filter matching SHALL use a lowercase name key computed once per entry when the entry is read, and the list of entries visible under the quick filter SHALL be recomputed only when the entries or the filter pattern change, so that, while no entries are selected, drawing a frame, hit-testing the mouse, and moving the cursor cost time proportional to the rows shown rather than to the directory's size. + +#### Scenario: First entries appear before enumeration finishes + +- **WHEN** a directory on a slow network share takes 20 seconds to enumerate and its entries arrive steadily throughout +- **THEN** entries and a growing `Reading… N` count are shown within about 50 ms of the first entries being read and keep growing while enumeration continues + +#### Scenario: Input stays responsive while a 100,000-entry directory streams in + +- **WHEN** a 100,000-entry directory is streaming in, many batches are already queued, and the user presses Down +- **THEN** the key is processed and the screen redrawn after at most about 8 ms or 64 queued worker messages, without waiting for the remaining batches to be applied + +#### Scenario: Streaming 100,000 entries costs about one sort + +- **WHEN** the release-build benchmark `bench_100k_ignored` streams 100,000 entries, in shuffled arrival order, into a panel in the reader's batch sizes +- **THEN** the total time spent applying the batches is at most three times the time of one bulk sort of the same 100,000 entries measured in the same run + +#### Scenario: Cursor stays on its entry while entries stream in + +- **WHEN** the user has moved the cursor to `report.txt` while the listing is still streaming, and a batch arrives containing entries that sort before `report.txt` +- **THEN** the cursor is still on `report.txt` after the batch is applied, and the entries remain in sorted order + +#### Scenario: Frame cost does not grow with directory size + +- **WHEN** the benchmark renders frames of a 100,000-entry panel and of a 1,000-entry panel with no entries selected, once with no quick filter and once with an active quick filter +- **THEN** a 100,000-entry frame takes no more than twice as long as a 1,000-entry frame under the same conditions + +#### Scenario: Orders are unchanged by precomputed keys + +- **WHEN** a listing is sorted by name, extension, time, or size, ascending or descending +- **THEN** the resulting order is identical to the case-insensitive orders and tie-breaks the panel produced before keys were precomputed, with `..` first diff --git a/openspec/specs/panel-tabs/spec.md b/openspec/specs/panel-tabs/spec.md index a00cbec..04cf551 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. 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. +A tab marked stale — by a completed file-operation job (see `file-operations` — "Automatic panel re-read on completion"), or by stopping being the active tab while its own listing was still being read (see "Switching or closing a tab mid-listing never mixes listings") — 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` @@ -119,10 +119,41 @@ A tab marked stale by a completed file-operation job (see `file-operations` — - **THEN** the newly-active tab re-reads its directory instead of showing its stale cached entries #### Scenario: Switching to a tab with no pending staleness is unchanged -- **WHEN** the user switches to a tab that was not affected by any completed job since it was last active +- **WHEN** the user switches to a tab that was not affected by any completed job since it was last active, and whose listing had finished being read when it was left - **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 +### Requirement: Switching or closing a tab mid-listing never mixes listings + +A tab's directory listing SHALL only ever add entries to that tab. When the active tab stops being active while its listing is still being read — by Alt+1..9 switching to another tab, or by Ctrl+W closing it — the system SHALL stop that read and SHALL NOT let any of its results reach the newly active tab. A tab switched away from in this state SHALL be marked stale and SHALL be refreshed with a fresh read when it next becomes active, through the same non-navigation refresh as "Stale background tab refresh on activation", instead of keeping or showing a partial listing. When the interrupted listing was a re-read of the tab's own directory, that refresh SHALL restore the cursor and scroll position the interrupted re-read was restoring, as panel-navigation "Same-directory re-read preserves cursor and scroll position" requires for a stale tab's activation refresh. When Ctrl+T opens a new tab while the active tab's listing is still being read, the listing SHALL continue to fill the new tab, which shows the same directory, and the original tab, now in the background, SHALL be marked stale. + +#### Scenario: Switching tabs mid-listing + +- **WHEN** tab 1 of the active panel is still streaming the listing of `C:\big` and the user presses Alt+2 to activate tab 2 at `C:\Work` +- **THEN** tab 2 shows only `C:\Work`'s entries, no entry of `C:\big` ever appears in it, and the read of `C:\big` is stopped + +#### Scenario: Returning to an interrupted tab re-reads it + +- **WHEN** the user later presses Alt+1 to return to the tab whose listing of `C:\big` was interrupted +- **THEN** the tab starts a fresh read of `C:\big` and shows `Reading… N` until that read completes, rather than a partial listing that never completes +- **AND** the refresh does not record a fuzzy-jump history visit or persist configuration + +#### Scenario: An interrupted re-read still keeps the user's place + +- **WHEN** the cursor is on `report.txt` partway down tab 1's complete listing, the user presses Ctrl+R, switches to tab 2 with Alt+2 before the re-read finishes, and later returns with Alt+1 +- **THEN** the refreshed listing of tab 1 puts the cursor back on `report.txt` with the scroll offset it had before Ctrl+R + +#### Scenario: Closing a tab mid-listing + +- **WHEN** the active tab is still streaming a listing and the user presses Ctrl+W +- **THEN** that read is stopped and the neighboring tab that becomes active receives none of its entries + +#### Scenario: New tab mid-listing keeps the listing + +- **WHEN** the active tab is still streaming the listing of `C:\big` and the user presses Ctrl+T +- **THEN** the new tab, showing `C:\big`, keeps receiving the listing until it completes +- **AND** activating the original tab later re-reads `C:\big` instead of showing the partial listing it held when Ctrl+T was pressed + diff --git a/openspec/specs/pulldown-menus/spec.md b/openspec/specs/pulldown-menus/spec.md index 61bba77..2341783 100644 --- a/openspec/specs/pulldown-menus/spec.md +++ b/openspec/specs/pulldown-menus/spec.md @@ -91,20 +91,65 @@ The system SHALL move the active menu one position with Left/Right arrows while ### Requirement: Menu contents -The system SHALL populate the five menus with their defined items: Left and Right SHALL each offer display mode, sort mode, filter, re-read, drive select, and new/close tab (mirroring each other); Files SHALL offer View, Edit, Copy, Rename/Move, Make directory, Delete, then a separated group Copy to clipboard, Copy path(s), Copy name(s), then Attributes, Select group, Deselect group, Invert, and Quit; Commands SHALL offer Find file, History, Swap panels, Panels on/off, Compare directories, Fuzzy jump, and Menu file edit; Options SHALL offer Configuration, Themes, Editor selection, and Save setup. Items whose backing feature is not yet available SHALL render as disabled entries rather than being omitted. +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, Attributes, Select group, Deselect group, Invert, and Quit +- **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 display mode, sort mode, filter, re-read, drive select, and new/close tab item sets +- **THEN** both list the same display-mode, sort-mode, Filter, Re-read, Drive select, New tab, and Close tab items, all enabled -#### Scenario: Not-yet-available feature renders disabled +#### Scenario: Commands and Options menus -- **WHEN** the user opens a menu containing an item whose feature is not yet implemented (e.g. Find file or Themes) -- **THEN** that item appears in the pull-down styled grey (white on cyan) and cannot be selected or activated +- **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 + +### 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 + +### 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/specs/quick-filter/spec.md b/openspec/specs/quick-filter/spec.md index dc7cd3a..e0ac783 100644 --- a/openspec/specs/quick-filter/spec.md +++ b/openspec/specs/quick-filter/spec.md @@ -45,6 +45,17 @@ While quick-filter mode is active, the system SHALL narrow the panel to only tho The system SHALL keep the panel cursor positioned on a currently visible (matching) entry while a quick filter is active. When the entry under the cursor is filtered out by a pattern change, the system SHALL move the cursor to the nearest remaining visible entry. +When the pattern leaves no entry visible, the panel SHALL have no current entry. This can happen only where the listing has no `..` parent entry, such as a drive root. While there is no current entry, every command that acts on the entry under the cursor SHALL do nothing. That covers: + +- Enter; +- F3 and F4; +- F5, F6, F8, and the Delete key when no entries are selected; +- Ins; +- opening the file-action menu; +- pasting the cursor entry's name or path to the command line. + +No command that targets the entry under the cursor SHALL act on an entry the filter hides. Copy, Move, and Delete of selected entries are governed by selection "Operations disclose selected entries hidden by the quick filter" instead. When the pattern changes so that at least one entry is visible again, the cursor SHALL move to the nearest visible entry. + #### Scenario: Cursor moves to a visible entry when its target is filtered out - **WHEN** the cursor is on an entry and the user extends the pattern so that entry no longer matches @@ -55,6 +66,26 @@ The system SHALL keep the panel cursor positioned on a currently visible (matchi - **WHEN** a quick filter is active and the user moves the cursor with the arrow keys - **THEN** the cursor traverses only the visible matching entries (and `..`), skipping filtered-out entries +#### Scenario: No visible entry at a drive root means no current entry + +- **WHEN** the active panel shows the root of drive `C:` (no `..` entry), the cursor is on `pagefile.sys`, and the user types a quick-filter pattern that matches no entry +- **THEN** the panel body is empty and the panel has no current entry + +#### Scenario: Cursor-targeted operations do nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry, nothing is selected, and the user presses F8, F5, F6, F3, F4, or Enter +- **THEN** no dialog, viewer, editor, or menu opens, and no file is affected + +#### Scenario: Ins does nothing while no entry is visible + +- **WHEN** a quick filter at a drive root matches no entry and the user presses Ins +- **THEN** the selection set is unchanged + +#### Scenario: Widening the pattern restores a current entry + +- **WHEN** a quick filter matches no entry and the user presses Backspace until the pattern matches `notes.txt` +- **THEN** the cursor is on a visible entry and cursor-targeted commands act on that entry again + ### Requirement: Clearing the quick filter The system SHALL exit quick-filter mode and clear the filter when the user presses the quick-filter activation key (Ctrl+P by default, honoring any remap) while a filter is active — the activation key toggles the filter — restoring the full (unfiltered) entry list and the normal mini-status display. Esc SHALL NOT exit the filter; over the panels it requests application quit, and cancelling that dialog leaves the filter active (application-shell "Quit request keys and confirmation"). The system SHALL preserve the current sort mode and selection set across activation and clearing of the filter; the quick filter narrows what is shown but does not alter the underlying listing. diff --git a/openspec/specs/selection/spec.md b/openspec/specs/selection/spec.md index 61ff5f5..2d893a8 100644 --- a/openspec/specs/selection/spec.md +++ b/openspec/specs/selection/spec.md @@ -126,3 +126,53 @@ The system SHALL key the selection set by entry identity (the original `OsString - **WHEN** entries are selected and the user navigates into a different directory in the panel - **THEN** the new directory's listing shows no selected entries +### Requirement: Operations disclose selected entries hidden by the quick filter + +When Copy (F5), Move (F6), or Delete (F8 or the Delete key) acts on the panel's selection, it SHALL act on every selected entry, including entries the active quick filter currently hides. This applies equally when the operation is started from a selection-scoped file-action menu or by dragging selected entries; the Enter-opened file-action menu stays single-target as file-action-menu specifies. When one or more of the selected entries the operation acts on are hidden by the quick filter, two dialogs SHALL state how many of them are hidden by the filter: the delete confirmation dialog, and the copy/move destination dialog whether keyboard- or drop-initiated. The statement SHALL appear in addition to the dialog's existing content. When no selected entry the operation acts on is hidden, both dialogs SHALL be unchanged. + +#### Scenario: Delete confirmation states the hidden count + +- **WHEN** five entries are selected, a quick filter hides two of them, and the user presses F8 +- **THEN** the confirmation dialog shows the count of 5 items, states that the deletion is permanent, and states that 2 of the 5 selected entries are hidden by the filter +- **AND** confirming deletes all five entries + +#### Scenario: Destination dialog states the hidden count + +- **WHEN** five entries are selected, a quick filter hides two of them, and the user presses F5 +- **THEN** the destination input dialog opens pre-filled with the opposite panel's path and states that 2 of the 5 selected entries are hidden by the filter +- **AND** accepting the destination copies all five entries + +#### Scenario: A single hidden selected entry is disclosed + +- **WHEN** exactly one entry, `old.log`, is selected, the quick filter hides it, and the user presses F8 +- **THEN** the confirmation dialog names `old.log` and states that the selected item is hidden by the filter + +#### Scenario: No hidden selection leaves the dialogs unchanged + +- **WHEN** entries are selected and none of them is hidden by a quick filter, and the user presses F5 or F8 +- **THEN** the destination or confirmation dialog appears exactly as it does without a quick filter + +### Requirement: Wildcard pattern matching for group select and deselect + +The wildcard patterns entered for `+` (select a group) and `-` (deselect a group) SHALL be matched against entry names by one shared matcher. In that matcher `*` matches any run of characters (including none), `?` matches exactly one character, and every other pattern character matches itself case-insensitively. A character SHALL be one Unicode scalar value of the name, regardless of how many bytes it occupies in any encoding. Matching SHALL take time at most proportional to the product of the pattern length and the name length, so no pattern can make the panel unresponsive. + +#### Scenario: Question mark matches one multi-byte character + +- **WHEN** the panel lists `é.txt` and `ab.txt`, and the user presses `+`, enters `?.txt`, and confirms +- **THEN** `é.txt` is selected and `ab.txt` is not + +#### Scenario: Deselect uses the same matching + +- **WHEN** `é.txt` is selected and the user presses `-`, enters `?.txt`, and confirms +- **THEN** `é.txt` is no longer selected + +#### Scenario: Matching ignores case + +- **WHEN** the panel lists `README.TXT` and the user selects the group `*.txt` +- **THEN** `README.TXT` is selected + +#### Scenario: Pathological pattern completes promptly + +- **WHEN** the panel lists 1,000 entries, each named 200 `a` characters followed by a distinct four-digit number, and the user selects the group `*a*a*a*a*a*a*a*a*a*a*b` +- **THEN** the dialog closes and the panel responds to the next key without a perceptible delay, and no entry is selected + diff --git a/openspec/specs/startup-splash/spec.md b/openspec/specs/startup-splash/spec.md index 835cc6e..06e5944 100644 --- a/openspec/specs/startup-splash/spec.md +++ b/openspec/specs/startup-splash/spec.md @@ -5,13 +5,13 @@ TBD - created by archiving change m1-shell. Update Purpose after archive. ## 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. 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. +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 a centered double-line box -- **AND** the box contains the product name, `Version `, the copyright line, and the tribute line +- **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 @@ -94,3 +94,32 @@ The system SHALL position the splash box on every terminal resize via the unifie - **THEN** the splash is replaced by the "terminal too small" placeholder - **AND** the splash does not return when the terminal is enlarged back to at or above 60×16 +### 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/specs/text-field-editing/spec.md b/openspec/specs/text-field-editing/spec.md new file mode 100644 index 0000000..b8b34f7 --- /dev/null +++ b/openspec/specs/text-field-editing/spec.md @@ -0,0 +1,91 @@ +# text-field-editing Specification + +## Purpose +TBD - created by archiving change line-editing-in-text-fields. Update Purpose after archive. +## 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 + +### Requirement: Terminal paste arrives as one paste + +On platforms where FileCommand's terminal input layer reports a bracketed paste as a single event — every supported platform except Windows — the system SHALL enable bracketed-paste mode while it owns the terminal, SHALL disable it before suspending for a shell command, the external editor, or the scrollback view and on every exit path including a panic, and SHALL re-enable it on resume. A reported paste received while a single-line text field owns the keyboard — the same field Ctrl+V would paste into, including the command line over the panels — SHALL be inserted at that field's caret as one paste operation, with line breaks replaced by a single space, exactly as "Paste into a text field" inserts clipboard text. A paste into the built-in editor's text SHALL be inserted at the caret as if typed, line breaks included; a paste into the editor's or the viewer's search or replace prompt SHALL be entered as if typed, with line breaks replaced by a single space. A paste received while no text input has focus SHALL be ignored. On Windows the system SHALL NOT enable bracketed-paste mode; a paste arrives as individual key events, which SHALL be handled as typed input, rendered as described in application-shell "Bursts of key input render once". The single-paste guarantees of this requirement, including flattening line breaks and ignoring pastes outside text input, therefore apply only off Windows. + +#### Scenario: Paste a path into the command line + +- **WHEN** the panels are focused and the terminal reports a paste of `C:\Program Files\App` +- **THEN** the command line contains `C:\Program Files\App` inserted at its caret in one operation, and the panel cursor, selection, and quick search are unaffected by the pasted characters + +#### Scenario: Multi-line paste is flattened and not executed + +- **WHEN** the command line has focus and the terminal reports a paste of two lines +- **THEN** the two lines are inserted joined by one space and no command is executed + +#### Scenario: Paste into a dialog field + +- **WHEN** the Copy destination field is focused with the caret at the end of `D:\` and the terminal reports a paste of `backup` +- **THEN** the field reads `D:\backup` with the caret at the end + +#### Scenario: Paste with no focused text input is ignored + +- **WHEN** the delete confirmation dialog is open and the terminal reports a paste of `y` +- **THEN** nothing is deleted and the dialog stays open + +#### Scenario: Child programs get a terminal without bracketed paste + +- **WHEN** off Windows, the user runs a command from the F2 user menu, which suspends FileCommand while it runs +- **THEN** bracketed-paste mode is disabled while the command runs and re-enabled when FileCommand resumes + +#### Scenario: On Windows a paste is typed input + +- **WHEN** FileCommand runs on Windows and the user pastes `C:\Program Files\App` while the command line has focus +- **THEN** bracketed-paste mode was never enabled, the characters are inserted as typed keys in order, and the screen is redrawn once per burst of keys rather than once per character + diff --git a/openspec/specs/theme-system/spec.md b/openspec/specs/theme-system/spec.md index cf244ca..53d0909 100644 --- a/openspec/specs/theme-system/spec.md +++ b/openspec/specs/theme-system/spec.md @@ -216,3 +216,19 @@ In the `purple-lights` theme, the `panel.file` role SHALL render with a dark gre - **WHEN** `purple-lights` is active and a directory entry, the cursor row, and a selected entry are drawn - **THEN** they render with the same colors as before this change +### 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/specs/ui-animations/spec.md b/openspec/specs/ui-animations/spec.md new file mode 100644 index 0000000..ca449db --- /dev/null +++ b/openspec/specs/ui-animations/spec.md @@ -0,0 +1,133 @@ +# ui-animations Specification + +## Purpose +TBD - created by archiving change ui-animations. Update Purpose after archive. + +## 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/specs/user-menu/spec.md b/openspec/specs/user-menu/spec.md index a847883..d6238a5 100644 --- a/openspec/specs/user-menu/spec.md +++ b/openspec/specs/user-menu/spec.md @@ -97,3 +97,15 @@ The system SHALL, when the user activates the built-in `Themes` entry, close the - **WHEN** the theme-selection dialog was opened from the F2 user menu and the user presses Esc - **THEN** the dialog closes leaving theme and configuration untouched, focus returns to the active panel, and the user menu is not reopened +### 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/specs/viewer/spec.md b/openspec/specs/viewer/spec.md index 005c7cf..ba8bacd 100644 --- a/openspec/specs/viewer/spec.md +++ b/openspec/specs/viewer/spec.md @@ -5,7 +5,9 @@ TBD - created by archiving change m4-viewer-and-external-editor. Update Purpose ## Requirements ### Requirement: Instant open of arbitrarily large files -The viewer SHALL open any file, including multi-GB files, with first paint independent of file size. It MUST memory-map the file (falling back to positioned chunk reads when mapping is unavailable) and MUST NOT build a full line index or perform any whole-file scan on open. Only the bytes required for the visible screen SHALL be read to produce the first frame. The mapped length SHALL be treated as a snapshot taken at open, and all navigation offsets MUST be clamped to that length. +The viewer SHALL open any file, including multi-GB files, with first paint independent of file size. It MUST read the file through bounded positioned reads on an open handle, on every platform, and MUST NOT memory-map the file. It MUST NOT build a full line index or perform any whole-file scan on open. Only the bytes required for the visible screen SHALL be read to produce the first frame. The file length SHALL be treated as a snapshot taken at open, and all navigation offsets MUST be clamped to that length. + +The viewer MUST NOT prevent other programs from changing the file while it is shown. On Windows it SHALL open the file with read, write, and delete sharing (`FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE`), so other programs can keep appending to, truncating, deleting, or renaming the file. A file that is truncated or otherwise shrinks while it is shown MUST NOT crash or hang the application: a read beyond the file's new end SHALL return only the bytes that still exist, and the viewer SHALL keep rendering and navigating within the snapshot length. When the viewer closes, it and any F7 search still running for it SHALL release the file. #### Scenario: First frame reads only the visible window @@ -13,16 +15,39 @@ The viewer SHALL open any file, including multi-GB files, with first paint indep - **THEN** the viewer paints the first screen without reading the whole file and without constructing a line index - **AND** only the byte range needed for the visible rows is read from the file -#### Scenario: Mapping unavailable falls back to chunk reads +#### Scenario: Network and special files are read the same way -- **WHEN** the file cannot be memory-mapped (e.g. a network path or special file) -- **THEN** the viewer serves the visible window via positioned chunk reads instead of panicking -- **AND** the rendered content is identical to the mmap path for the same byte range +- **WHEN** F3 opens a file on a network path, or a zero-length file +- **THEN** the viewer serves the visible window through the same positioned reads it uses for every other file, without panicking +- **AND** the rendered content is identical to what any other read of that byte range returns #### Scenario: Offsets clamped to the snapshot length - **WHEN** navigation would move the top anchor beyond the length captured at open -- **THEN** the top offset is clamped to the file length and the view does not read past the mapped snapshot +- **THEN** the top offset is clamped to the file length and the view does not read past the snapshot length + +#### Scenario: Another program keeps writing to the viewed file + +- **WHEN** a log file is open in the viewer and the program writing it appends more lines +- **THEN** the writes succeed +- **AND** the viewer keeps showing the file up to the length captured at open + +#### Scenario: Viewed file can be truncated, rotated, or deleted + +- **WHEN** a file is open in the viewer and another program truncates it, renames it away, or deletes it +- **THEN** that operation succeeds on every platform, including Windows + +#### Scenario: Truncation while viewing never crashes + +- **WHEN** a 10 MB file is open in the viewer and another program truncates it to 1 KB +- **AND** the user scrolls or jumps to an offset past the new end +- **THEN** the application keeps running, rows past the new end render empty, and navigation stays within the snapshot length + +#### Scenario: Closing the viewer releases the file + +- **WHEN** the user closes the viewer, including while an F7 search is still scanning the file +- **THEN** the viewer's handle is closed and the search stops scanning and closes its handle +- **AND** no FileCommand thread holds the file open afterwards ### Requirement: Text and hex modes with F4 mode toggle @@ -129,3 +154,17 @@ The viewer SHALL render as a frame-less full-screen view replacing the panels, w - **WHEN** the user presses F10 in the viewer - **THEN** the viewer closes and focus returns to the panels +### Requirement: Search stops when superseded or when the viewer closes + +The system SHALL stop an F7 search that is still scanning when the viewer closes or when the user starts another F7 search in the same viewer. The search SHALL check for this before reading each chunk, SHALL stop at the next chunk boundary once signalled, SHALL release its own handle on the file, and SHALL deliver no result. A superseded search's result SHALL never move the view or set the match highlight. + +#### Scenario: Closing the viewer stops a running search + +- **WHEN** an F7 search for a pattern that does not occur is scanning a multi-GB file and the user presses F10 +- **THEN** the viewer closes, the search stops within one chunk read, and the search no longer holds the file open + +#### Scenario: A newer search supersedes a running one + +- **WHEN** F7 search A is still scanning and the user starts F7 search B in the same viewer +- **THEN** search A stops at its next chunk boundary and only search B's result moves the view and highlights a match + diff --git a/openspec/specs/windows-installer/spec.md b/openspec/specs/windows-installer/spec.md index 48c618c..b553baf 100644 --- a/openspec/specs/windows-installer/spec.md +++ b/openspec/specs/windows-installer/spec.md @@ -108,3 +108,53 @@ The repository SHALL contain the complete installer source and a build script th - **WHEN** the build script runs without the WiX toolset installed - **THEN** it stops with a message naming the missing prerequisite and how to install it +### Requirement: Bundle warns when the other installation scope is present + +Before planning an install, the Burn bundle SHALL detect whether the MSI of the other scope is already installed, identifying it by that package's stable UpgradeCode: +- a run that will install per-user SHALL look for the per-machine package; +- a run that will install per-machine SHALL look for the per-user package of the account running setup. + +When the other scope is present on an interactive run, the bundle's install page SHALL show a warning before the user starts the install. The warning SHALL name the scope that is already installed, SHALL state that both copies will be on PATH, and SHALL state which copy runs when the user types `filecommand`, which is always the per-machine copy, because the machine PATH precedes the user PATH. The install SHALL remain allowed, with no additional prompt and no change to the Install action. Quiet and passive runs SHALL NOT be blocked or prompted because of the other scope, and the detection result SHALL appear in the bundle's log. + +#### Scenario: Per-user setup over an existing per-machine install + +- **WHEN** FileCommand is installed per-machine and a user runs `FileCommandSetup.exe` interactively with the default per-user scope +- **THEN** the install page warns that FileCommand is already installed for all users, and that the all-users copy will start when `filecommand` is typed +- **AND** clicking Install completes a per-user install as it does today + +#### Scenario: Per-machine setup over an existing per-user install + +- **WHEN** FileCommand is installed per-user for the current account and the same account runs `FileCommandSetup.exe InstallScope=perMachine` interactively from an elevated context +- **THEN** the install page warns that FileCommand is already installed for this account, and that the new all-users copy will start when `filecommand` is typed +- **AND** the per-machine install proceeds when the user clicks Install + +#### Scenario: No warning without the other scope + +- **WHEN** neither scope is installed, or only the scope being installed or upgraded is present +- **THEN** the install page shows no scope warning + +#### Scenario: Silent installs are unaffected + +- **WHEN** `FileCommandSetup.exe /quiet` runs on a machine that already has the per-machine install +- **THEN** the per-user install completes with no UI and no prompt, exactly as before +- **AND** the bundle log records that the per-machine package was detected + +### Requirement: Settings removal is documented + +The installer documentation SHALL describe what uninstall leaves behind and how to remove it. It SHALL state that uninstalling either scope keeps the user's settings files (`config.toml`, `history.json`, `usermenu.toml`). It SHALL name their default location (`%APPDATA%\FileCommand`) and note that every account that ran FileCommand has its own copy. It SHALL give commands that delete that directory. It SHALL also describe the two non-default locations: +- portable mode, where the files and the `portable` marker live beside the executable in the install directory and remain after uninstall; +- `--config-dir`, where the files live in a user-chosen directory the installer never touches. + +The installer itself SHALL NOT delete settings. + +#### Scenario: A user looks up how to remove everything + +- **WHEN** a user reads the uninstall section of `installer/README.md` +- **THEN** it states that uninstall keeps the settings, names `%APPDATA%\FileCommand`, and gives a PowerShell and a cmd command that remove it +- **AND** it explains the per-account copies of a per-machine install, and the portable-mode and `--config-dir` locations + +#### Scenario: Uninstall still keeps settings + +- **WHEN** either scope is uninstalled after the user has run FileCommand +- **THEN** `%APPDATA%\FileCommand` and its files are still present, and a later reinstall uses them + diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3121924 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 150 +use_small_heuristics = "Max"