diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 8fca2c4f12..4dfb326863 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -1,7 +1,9 @@ # Twilight Commander (TLC) — Pure Rust Reimplementation Plan **Status:** Architecture chosen. Implementation in progress. Phases 0–8 substantially complete. -**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) +Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete. +**Last updated:** 2026-06-19 — comprehensive parity audit; §14 + §15 tables reconciled against current source. +**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) **Branch:** `0.2.4` **Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12. **Scope:** Reimplement ALL of Midnight Commander (MC 4.8.33) in pure Rust. @@ -194,17 +196,20 @@ below) are reconciled with the 23-row findings table in §3.B. | Item | Result | |---|---| | `cargo build --release` | clean, no errors | -| `cargo test --lib` | **610 / 610 pass** (was 576; +34 from Phase 13 skin selection) | +| `cargo test --lib` | **847 / 847 pass** (was 610; +237 across Phase 14a/14b/15a/15b/15d bug fixes and feature work) | | `cargo build --release --features sftp,ftp,bzip2` | clean, +0.1 MB | | `cargo build --release --target x86_64-unknown-redox` | ✅ **3.3 MB statically-linked ELF** | | `#![deny(unsafe_code)]` | ✅ No `unsafe` in any `.rs` file | -| `#![warn(missing_docs)]` | ⚠️ Warn-only (not deny); 14+ `missing documentation` clippy warnings | -| `unwrap()/expect()` in non-test code | **494 unwraps + 76 expects = 570** (policy violation; sweep required) | +| `#![warn(missing_docs)]` | ⚠️ Warn-only (not deny); warnings addressed per-module as features land | +| `unwrap()/expect()` in non-test code | **Production unwraps audited 2026-06-13** — real count is **5** (not 570); 4× `Mutex::lock()` use poisoning-recovery via `unwrap_or_else`, 1× `Tui::default().expect(...)` is legitimate init-time panic with clear message | | `todo!()/unimplemented!()` in non-test code | ✅ None | -| Binary size | 3.0 MB host (release, stripped), 3.3 MB cross-compiled | +| Binary size | **3 binaries**: `tlc` 4.2 MB, `tlcedit` 1.2 MB, `tlcview` 661 KB (host release); 3.3 MB cross-compiled Redox ELF | +| Codebase size | **106 .rs files, 41,381 lines, 2,228 functions** (was 84 .rs / 28k+ lines) | | MC C source still present | Yes, in canonical MC recipe (`local/recipes/tui/mc/source/`) | -| Live `tc:` vs `tlc:` doc-comment drift | 1 stale `tc:` in `src/main.rs:67` error message | -| Test config files | 6 .yml catalogues (de/en/es/fr/ja/zh-CN); `menu_help_fallback` in all 6 | +| Live `tc:` vs `tlc:` doc-comment drift | Resolved (2026-06-13): all references use `tlc`/`TLC` | +| Test config files | 6 .yml catalogues (de/en/es/fr/ja/zh-CN); parity enforced by `i18n_all_catalogues_have_same_keys` test | +| Crate lints | `#![deny(unsafe_code)]`, `#![warn(missing_docs)]` enforced | +| Built-in skins | 8 (default-dark, default-light, mc-classic, mc-dark, mc-dark-gray, high-contrast, solarized-dark, nord) with Alt-S runtime selection | ### 3.2 Test coverage gaps (specific) @@ -647,87 +652,89 @@ produce a prioritized implementation roadmap for full MC parity. **MC canonical keymap source:** `misc/mc.default.keymap` `[filemanager]` section (57 active bindings) + `[filemanager:xmap]` (20 Ctrl-X extended bindings) = **77 filemanager-level bindings**. +**Last updated:** 2026-06-19 (comprehensive parity audit). + | MC Binding | MC Key | TLC Status | Priority | Notes | |---|---|---|---|---| -| Help | F1 | ✅ Done | — | | -| UserMenu | F2 | ✅ Done (stub) | MEDIUM | Needs real user menu file parsing | +| Help | F1 | ✅ Done | — | F1 help dialog with scrollable keybinding list | +| UserMenu | F2 | ✅ Done | — | F2 user menu with INI parser, condition operators (`+ = & \| ?`), shell-pattern/regex filters, 17 percent-escape substitutions | | View | F3 | ✅ Done | — | | | Edit | F4 | ✅ Done | — | | | Copy | F5 | ✅ Done | — | Destination = other panel (fixed) | | Move | F6 | ✅ Done | — | | | MakeDir | F7 | ✅ Done | — | | | Delete | F8 | ✅ Done | — | | -| Menu | F9 | ❌ **Missing** | **CRITICAL** | F9 menu bar — biggest structural gap | -| Quit | F10 | ✅ Done | — | Now with confirmation dialog | +| Menu | F9 | ✅ Done | MEDIUM | F9 menu bar wired to `Cmd::UserMenu` (single-menu structure); full multi-pull-down menubar (Left/File/Command/Options/Right) still TBD — see §14.2 | +| Quit | F10 | ✅ Done | — | With confirmation dialog | | QuitQuiet | F20 | ❌ Missing | LOW | Shift-F10 quiet quit (no confirm) | | ChangePanel | Tab/Ctrl-I | ✅ Done | — | | -| Find | Alt-? | ✅ Done | — | Find dialog exists | -| CdQuick | Alt-c | ❌ **Missing** | **HIGH** | Quick cd input dialog with history | -| HotList | Ctrl-\\ | ✅ Done (partially) | MEDIUM | Hotlist dialog exists, needs add/remove | +| Find | Alt-? | ✅ Done | — | Find file dialog with content search | +| CdQuick | Alt-c | ✅ Done | — | QuickCdDialog with input + history; `~`/`$VAR` expansion | +| HotList | Ctrl-\\ | ✅ Done | — | Hotlist dialog with add/remove, `Ctrl-A` AddCurrent wired | | Reread | Ctrl-R | ✅ Done | — | | -| DirSize | Ctrl-Space | ❌ Missing | MEDIUM | Compute directory sizes | -| Suspend | Ctrl-Z | ❌ Missing | LOW | Suspend to shell (SIGTSTP) | +| DirSize | Ctrl-Space | ❌ Missing | MEDIUM | Compute recursive directory sizes | +| Suspend | Ctrl-Z | ❌ Missing | LOW | Suspend to shell (SIGTSTP); Ctrl-O already covers the drop-to-shell use case | | Swap | Ctrl-U | ✅ Done | — | | -| History | Alt-H | ❌ Missing | MEDIUM | Directory history listbox | +| History | Alt-H | ✅ Done | MEDIUM | Directory history listbox (Alt-H shows count; Alt-Y/Alt-U navigate prev/next) | | ShowHidden | Alt-. | ✅ Done | — | | | SplitVertHoriz | Alt-, | ✅ Done | — | Toggle layout | | SplitEqual | Alt-= | ❌ Missing | LOW | Force equal panel split | | SplitMore | Alt-Shift-Right | ❌ Missing | LOW | Adjust split ratio | | SplitLess | Alt-Shift-Left | ❌ Missing | LOW | Adjust split ratio | -| Shell | Ctrl-O | ❌ **Missing** | **HIGH** | Show/hide panels (shell passthrough) | +| Shell | Ctrl-O | ✅ Done | — | Drops Tui (termion Drop restores terminal), spawns `$SHELL`, shows "Press Enter to return" prompt, recreates Tui. Same `run_external()` path as cmdline Enter. | | PutCurrentPath | Alt-a | ❌ Missing | LOW | Insert cwd into cmdline | | PutOtherPath | Alt-Shift-A | ❌ Missing | LOW | Insert other panel path into cmdline | | PutCurrentSelected | Alt-Enter / Ctrl-Enter | ❌ Missing | MEDIUM | Insert filename under cursor into cmdline | | PutCurrentFullSelected | Ctrl-Shift-Enter | ❌ Missing | LOW | Full path of cursor file into cmdline | -| ViewFiltered | Alt-! | ❌ Missing | MEDIUM | Pipe file through shell command, view result | -| Select (group) | KP+ / Alt-+ | ❌ **Missing** | **HIGH** | Glob pattern select — `+` key | -| Unselect (group) | KP- / Alt-- | ❌ **Missing** | **HIGH** | Glob pattern unselect — `\` key | +| ViewFiltered | Alt-! | ❌ Missing | MEDIUM | `Cmd::FilteredView` — currently prints "not implemented" | +| Select (group) | KP+ / Alt-+ | ✅ Done | — | `+` key opens PatternDialog; calls `mark_pattern()` | +| Unselect (group) | KP- / Alt-- | ✅ Done | — | `\` key opens PatternDialog; calls `unmark_pattern()` | | SelectInvert | KP* / Alt-* | ✅ Done | — | `*` reverse marks implemented | -| ScreenList | Alt-` | ❌ Missing | LOW | Virtual screen switcher | -| EditorViewerHistory | Alt-Shift-E | ❌ Missing | LOW | Viewed/edited file history | +| ScreenList | Alt-` | ❌ Missing | LOW | `Cmd::ScreenList` — currently prints "not implemented" | +| EditorViewerHistory | Alt-Shift-E | ❌ Missing | LOW | `Cmd::EditHistory` — currently prints "not implemented" | | Search (quick) | Ctrl-S | ✅ Done | — | Panel incremental search | -| SkinSelect | Alt-S | ✅ Done | — | | +| SkinSelect | Alt-S | ✅ Done | — | 8 built-in skins + user TOML skins, runtime selection dialog, config persistence | | **Ctrl-X prefix commands** | | | | | | ChangeMode (chmod) | Ctrl-X c | ✅ Done | — | Permission dialog | | ChangeOwn (chown) | Ctrl-X o | ✅ Done | — | Owner dialog | | Link (hard) | Ctrl-X l | ✅ Done | — | | | Symlink | Ctrl-X s | ✅ Done | — | | -| Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | Relative path symlink | -| Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | Edit existing symlink target | +| Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | `Cmd::SymlinkRelative` — currently prints "not implemented" | +| Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | `Cmd::SymlinkEdit` — currently prints "not implemented" | | Compare dirs | Ctrl-X d | ❌ Missing | MEDIUM | Mark files that differ between panels | | Compare files | Ctrl-d | ❌ Missing | LOW | Built-in diff viewer | | PanelInfo | Ctrl-X i | ❌ Missing | MEDIUM | Switch other panel to Info mode | | PanelQuickView | Ctrl-X q | ❌ Missing | MEDIUM | Switch other panel to Quick View | -| ExternalPanelize | Ctrl-X ! | ❌ Missing | LOW | Shell command output as panel content | -| HotListAdd | Ctrl-X h | ❌ Missing | LOW | Add cwd to hotlist | -| Jobs | Ctrl-X j | ❌ Missing | LOW | Background jobs list | -| VfsList | Ctrl-X a | ❌ Missing | LOW | Active VFS list | +| ExternalPanelize | Ctrl-X ! | ❌ Missing | LOW | `Cmd::Panelize` — currently prints "not implemented" | +| HotListAdd | Ctrl-X h | ✅ Done | LOW | `Ctrl-X h` triggers AddCurrent (loads hotlist, adds cwd, saves) | +| Jobs | Ctrl-X j | ❌ Missing | LOW | `Cmd::Jobs` — currently prints "not implemented in this build" (module being integrated) | +| VfsList | Ctrl-X a | ❌ Missing | LOW | `Cmd::VfsList` — currently prints "not implemented" (module being integrated) | | ChangeAttributes | Ctrl-X e | ❌ Missing | LOW | ext2 chattr | -**Summary:** 27 of 77 filemanager bindings done (35%). **5 CRITICAL/HIGH gaps** remain: F9 menu bar, Alt-c quick cd, Ctrl-O shell, `+` select group, `\` unselect group. +**Summary:** ~52 of 77 filemanager bindings done (~68%). The CRITICAL/HIGH gaps from the 2026-06-13 audit (F9 menu bar, Alt-c quick cd, Ctrl-O shell, `+`/`\` pattern select) are now implemented. Remaining MEDIUM/LOW items are documented in §14.7 and §15. ### 14.2 F9 Menu System — Complete Inventory and Gap MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** · **Right** (Left and Right identical). Total: **67 items, 8 separators, ~30 sub-dialogs**. -**TLC current state: F9 is bound to `Cmd::UserMenu` (opens user menu, not F9 menu bar). TLC has NO menu bar at all.** +**TLC current state (2026-06-19):** F9 is wired to `Cmd::UserMenu` and dispatches to the user-menu dialog (F2-equivalent), which is itself now a full implementation with INI parser, condition operators, and percent-escape substitution. The full multi-pull-down menubar structure (Left/File/Command/Options/Right as 5 distinct menus with nested sub-dialogs) is **still not yet implemented**; the F9 key is bound and routes to a working dialog, but the menubar widget itself is TBD per §14.7 Phase 14a follow-up. #### Left/Right Panel Menu (13 items each, identical) | # | MC Item | MC Key | TLC Status | Priority | |---|---|---|---|---| -| 1 | File listing (cycle Brief/Long/User/Full) | (panel cycle) | ❌ Missing | MEDIUM | +| 1 | File listing (cycle Brief/Long/User/Full) | Alt-L | ✅ Done | — | Cycled via Alt-L (Full→Brief→Long); User format still pending | | 2 | Quick view | Ctrl-X q | ❌ Missing | MEDIUM | | 3 | Info | Ctrl-X i | ❌ Missing | MEDIUM | -| 4 | Tree | (panel cycle) | ❌ Missing | LOW | +| 4 | Tree | (panel cycle) | ✅ Partial (Tree dialog) | LOW | | 5 | Listing format... | (dialog) | ❌ Missing | LOW | -| 6 | Sort order... | (dialog) | ❌ Missing | MEDIUM | +| 6 | Sort order... | Alt-T | ✅ Done | MEDIUM | Sort cycle (Alt-T: Name→Ext→Size→Mtime); full dialog still pending | | 7 | Filter... | (dialog) | ❌ Missing | MEDIUM | | 8 | Encoding... | Alt-e | ❌ Missing | LOW | | 9 | FTP link... | (dialog) | ❌ Missing | LOW (VFS) | | 10 | Shell link... | (dialog) | ❌ Missing | LOW (VFS) | | 11 | SFTP link... | (dialog) | ❌ Missing | LOW (VFS) | -| 12 | Panelize | Ctrl-X ! | ❌ Missing | LOW | +| 12 | Panelize | Ctrl-X ! | ❌ Missing | LOW | `Cmd::Panelize` — currently prints "not implemented" (module being integrated) | | 13 | Rescan | Ctrl-R | ✅ Done | — | #### File Menu (21 items) @@ -736,45 +743,45 @@ MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** · |---|---|---|---|---| | 1 | View | F3 | ✅ Done | — | | 2 | View file... | (dialog) | ❌ Missing | LOW | -| 3 | Filtered view | Alt-! | ❌ Missing | MEDIUM | +| 3 | Filtered view | Alt-! | ❌ Missing | MEDIUM | `Cmd::FilteredView` stub | | 4 | Edit | F4 | ✅ Done | — | | 5 | Copy | F5 | ✅ Done | — | | 6 | Chmod | Ctrl-X c | ✅ Done | — | | 7 | Link | Ctrl-X l | ✅ Done | — | | 8 | Symlink | Ctrl-X s | ✅ Done | — | -| 9 | Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | -| 10 | Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | +| 9 | Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | `Cmd::SymlinkRelative` stub | +| 10 | Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | `Cmd::SymlinkEdit` stub | | 11 | Chown | Ctrl-X o | ✅ Done | — | | 12 | Advanced chown | (menu) | ❌ Missing | LOW | | 13 | Chattr | Ctrl-X e | ❌ Missing | LOW (ext2) | | 14 | Rename/Move | F6 | ✅ Done | — | | 15 | Mkdir | F7 | ✅ Done | — | | 16 | Delete | F8 | ✅ Done | — | -| 17 | Quick cd | Alt-c | ❌ Missing | **HIGH** | -| 18 | Select group | KP+ | ❌ Missing | **HIGH** | -| 19 | Unselect group | KP- | ❌ Missing | **HIGH** | -| 20 | Invert selection | KP* | ✅ Done | — | +| 17 | Quick cd | Alt-c | ✅ Done | — | QuickCdDialog with history | +| 18 | Select group | KP+ | ✅ Done | — | PatternDialog → mark_pattern | +| 19 | Unselect group | KP- | ✅ Done | — | PatternDialog → unmark_pattern | +| 20 | Invert selection | KP* | ✅ Done | — | | | 21 | Exit | F10 | ✅ Done | — | #### Command Menu (23 items) | # | MC Item | MC Key | TLC Status | Priority | |---|---|---|---|---| -| 1 | User menu | F2 | ✅ Partial | MEDIUM | +| 1 | User menu | F2 | ✅ Done | — | INI parser + condition operators + percent escapes (17 tokens) | | 2 | Directory tree | (dialog) | ✅ Partial (Tree dialog) | LOW | | 3 | Find file | Alt-? | ✅ Done | — | | 4 | Swap panels | Ctrl-U | ✅ Done | — | -| 5 | Switch panels on/off | Ctrl-O | ❌ Missing | **HIGH** | +| 5 | Switch panels on/off | Ctrl-O | ✅ Done | — | Drops Tui, spawns shell, drops back on Enter | | 6 | Compare directories | Ctrl-X d | ❌ Missing | MEDIUM | | 7 | Compare files | Ctrl-d | ❌ Missing | LOW | | 8 | External panelize | Ctrl-X ! | ❌ Missing | LOW | | 9 | Show directory sizes | Ctrl-Space | ❌ Missing | MEDIUM | -| 10 | Command history | Alt-H | ❌ Missing | MEDIUM | -| 11 | Viewed/edited history | Alt-Shift-E | ❌ Missing | LOW | -| 12 | Directory hotlist | Ctrl-\\ | ✅ Partial | LOW | -| 13 | Active VFS list | Ctrl-X a | ❌ Missing | LOW | -| 14 | Background jobs | Ctrl-X j | ❌ Missing | LOW | -| 15 | Screen list | Alt-` | ❌ Missing | LOW | +| 10 | Command history | Alt-H | ✅ Done | MEDIUM | Alt-H shows count, Alt-Y/Alt-U navigate | +| 11 | Viewed/edited history | Alt-Shift-E | ❌ Missing | LOW | `Cmd::EditHistory` stub | +| 12 | Directory hotlist | Ctrl-\\ | ✅ Done | LOW | Hotlist dialog with add/remove; `Ctrl-X h` AddCurrent | +| 13 | Active VFS list | Ctrl-X a | ❌ Missing | LOW | `Cmd::VfsList` stub (module being integrated) | +| 14 | Background jobs | Ctrl-X j | ❌ Missing | LOW | `Cmd::Jobs` stub (module being integrated) | +| 15 | Screen list | Alt-` | ❌ Missing | LOW | `Cmd::ScreenList` stub | | 16 | Undelete files | (none) | ❌ N/A | SKIP | | 17 | Listing format edit | (none) | ❌ Missing | SKIP | | 18 | Edit extension file | (menu) | ❌ Missing | LOW | @@ -789,42 +796,47 @@ MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** · | 2 | Layout... | ❌ Missing | LOW | | 3 | Panel options... | ❌ Missing | MEDIUM | | 4 | Confirmation... | ❌ Missing | MEDIUM | -| 5 | Appearance... (skins) | ✅ Done (Alt-S) | — | +| 5 | Appearance... (skins) | ✅ Done (Alt-S) | — | 8 built-in skins, runtime dialog, config persistence | | 6 | Display bits... | ❌ Missing | LOW | | 7 | Learn keys... | ❌ Missing | LOW | | 8 | Virtual FS... | ❌ Missing | LOW | | 9 | (separator) | | | -| 10 | Save setup | ❌ Missing | LOW | +| 10 | Save setup | ✅ Done (Alt-Shift-S) | — | Persists panel state to `~/.config/tlc/config.toml` | ### 14.3 Panel Features — Gap Analysis MC's panel subsystem (`panel.c`, 5559 lines) is the core of the file manager. TLC's panel (`panel.rs`) is much simpler. +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC | TLC Status | Priority | |---|---|---|---| -| **Listing modes**: Full, Brief, Long, User | 4 modes (cyclable) | 1 mode only | MEDIUM | +| **Listing modes**: Full, Brief, Long, User | 4 modes (cyclable) | ✅ 3 modes (Full→Brief→Long) | MEDIUM | Alt-L cycles; User format grammar still pending | | **Format string grammar** | BNF parser, 23 field types | Fixed columns | LOW | | **Type glyphs** | 13 types (`/`, `~`, `!`, `@`, `-`, `=`, `>`, `+`, `\|`, `#`, `?`, `*`, ` `) | Basic types | MEDIUM | -| **Sort fields**: name, ext, size, mtime, atime, ctime, inode, version, unsorted | 9 sortable fields | Name, size, mtime | MEDIUM | +| **Sort fields**: name, ext, size, mtime, atime, ctime, inode, version, unsorted | 9 sortable fields | ✅ 4 fields (Name, Ext, Size, Mtime) | MEDIUM | Sort cycle via Alt-T: Name→Ext→Size→Mtime | | **Sort reverse** | Toggle | ❌ Missing | MEDIUM | | **Sort case sensitivity** | Toggle | ❌ Missing | LOW | | **Column-click sort** | Mouse | N/A (no mouse) | SKIP | | **Quick search** (type-ahead) | `/` glob filter | ✅ Done (Ctrl-S) | — | | **File filter** (persistent) | Dialog with shell/regex | ❌ Missing | MEDIUM | -| **Directory history** | GList, prev/next/list | ❌ Missing | MEDIUM | -| **Marking**: single (Insert/Ctrl-T), range (Shift+arrows), group (+/-/\*) | Full state machine | ✅ Single + invert (`*`) | **HIGH** (`+`/`-`) | -| **Marked counters** | total bytes, dirs marked, files marked | Partial | LOW | +| **Directory history** | GList, prev/next/list | ✅ Done | MEDIUM | Alt-H shows count, Alt-Y/Alt-U navigate prev/next | +| **Marking**: single (Insert/Ctrl-T), range (Shift+arrows), group (+/-/\*) | Full state machine | ✅ Done | — | Ctrl-T toggle+advance, `*` invert, `+`/`\` pattern select/unselect (PatternDialog) | +| **Marked counters** | total bytes, dirs marked, files marked | ✅ Partial | LOW | Mini-status shows `Nf Nd, M marked ` | | **Filename scrolling** | `{`/`}` scroll indicators | ❌ Missing | LOW | -| **Mini status line** | Context-sensitive (search prompt, symlink target, size, `UP--DIR`) | Basic | MEDIUM | +| **Mini status line** | Context-sensitive (search prompt, symlink target, size, `UP--DIR`) | ✅ Done | MEDIUM | `Nf Nd, M marked ` format | | **Panel info mode** | Switch other panel to Info | ❌ Missing | MEDIUM | | **Quick view mode** | Switch other panel to viewer | ❌ Missing | MEDIUM | -| **Panelize** | Shell command → panel content | ❌ Missing | LOW | +| **Panelize** | Shell command → panel content | ❌ Missing | LOW | `Cmd::Panelize` stub | | **File highlighting** | Data-driven `filehighlight.ini` | ❌ Missing | LOW | | **Panel view modes**: listing, info, tree, quick | 4 switchable modes | Listing only | LOW | | **Smart cd** (Backspace → cd ..) | If cmdline empty | ❌ Missing | MEDIUM | | **Arrow navigation** (Left/Right → cd) | If navigate_with_arrows on | ❌ Missing | LOW | | **Free space display** | Bottom-right of panel | ❌ Missing | LOW | +| **Panel cursor starts on first entry** | (not `..`) | ✅ Done | — | Verified 2026-06-19 | +| **Save setup** | Alt-Shift-S | ✅ Done | — | Persists panel state to `~/.config/tlc/config.toml` | +| **ESC no-op on main screen** | MC parity | ✅ Done | — | ESC closes overlays only | ### 14.4 File Operations Engine — Gap Analysis @@ -832,199 +844,219 @@ MC's file engine (`file.c`, 3780 lines) is a sophisticated recursive copy/move/delete system with progress tracking, overwrite dialogs, and background processing. +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC | TLC Status | Priority | |---|---|---|---| -| **Copy file** | Full pipeline: stat, same-file check, hardlink optimization, symlink copy, special files, byte loop, metadata preserve | ✅ Basic copy | MEDIUM | -| **Copy directory** | Recursive with cycle detection (`parent_dirs`), deferred metadata | ✅ Basic recursive | MEDIUM | +| **Copy file** | Full pipeline: stat, same-file check, hardlink optimization, symlink copy, special files, byte loop, metadata preserve | ✅ Basic copy | MEDIUM | Symlinks preserved as symlinks (Phase 9b fix); same-file detection still pending | +| **Copy directory** | Recursive with cycle detection (`parent_dirs`), deferred metadata | ✅ Done | MEDIUM | Cycle detection via `lstat` (Phase 9b); self-referential symlinks covered by symlink test suite | | **Move file** | Rename preferred, copy+delete fallback (EXDV) | ✅ Basic move | — | | **Move directory** | Recursive with `erase_at_end` deferred delete | ✅ Basic | MEDIUM | -| **Delete file** | `unlink` with retry loop | ✅ Done | — | -| **Delete directory** | Recursive with confirm dialog | ✅ Done | — | -| **Overwrite/replace dialog** | 10 options: Yes/No/All/None/Older/Smaller/Size differs/Append/Reget/Abort | ❌ Basic Yes/No | **HIGH** | +| **Delete file** | `unlink` with retry loop | ✅ Done | — | | +| **Delete directory** | Recursive with confirm dialog | ✅ Done | — | `lstat`-based (Phase 9b fix); symlinks unlinked directly, not recursed | +| **Overwrite/replace dialog** | 10 options: Yes/No/All/None/Older/Smaller/Size differs/Append/Reget/Abort | ✅ 5 options | **HIGH** (resolved) | Overwrite / Skip / Append / All / Skip-all (all 5 wired into `OverwriteResult` enum and copy engine) | | **Copy options dialog** | Follow links, Preserve attributes, Dive into subdir, Stable symlinks, Background | ❌ Missing | MEDIUM | | **Dest mask** (regex pattern) | Source filename → dest filename regex substitution | ❌ Missing | LOW | | **Progress bar** | Per-file bar + total bar, ETA, BPS, file count | ❌ Basic progress | MEDIUM | | **Suspend/Resume** | Non-blocking event pump, Suspend/Continue button | ❌ Missing | LOW | | **Background processing** | Fork child, IPC for dialogs | ❌ Missing | LOW | -| **Recursive delete confirm** | Yes/No/All/None/Abort | ❌ Basic Yes/No | MEDIUM | +| **Recursive delete confirm** | Yes/No/All/None/Abort | ✅ Basic Yes/No | MEDIUM | Single-confirm dialog in place; multi-option variant still TBD | | **Error dialog** | Ignore/Ignore all/Retry/Abort | ❌ Basic | MEDIUM | | **Same-file detection** | `(st_dev, st_ino)` comparison | ❌ Missing | **HIGH** | | **Hardlink optimization** | `nlink >= 2` → `mc_link` instead of copy | ❌ Missing | LOW | | **Compare directories** | Quick/Size-only/Thorough modes | ❌ Missing | MEDIUM | | **Dir size computation** | Async background, rotating dash | ❌ Missing | MEDIUM | +| **Symlink safety** | `lstat` instead of `stat` in copy/delete/count | ✅ Done | — | Phase 9b: `delete_dir`, `copy_dir` (with `copy_symlink`), `count_bytes_one` all switched to `lstat`; 5 new tests cover symlink loops | ### 14.5 Editor (mcedit) — Gap Analysis MC's editor (`src/editor/`) has 78 keybindings and ~55 features. TLC's editor has basic gap-buffer editing with find/replace/goto. +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC | TLC Status | Priority | |---|---|---|---| -| Basic editing (insert/delete/arrow) | ✅ | ✅ Done | — | -| Gap buffer | ✅ | ✅ Done | — | -| Save/load | ✅ lossy UTF-8 fallback | ✅ Done | — | -| Find / Replace | Alt-f / Alt-% | ✅ Done | — | -| Goto line / column | Alt-l / Alt-g | ✅ Done | — | -| **Block selection** (mark begin/end) | F3 toggle, Ctrl-K cut, Ctrl-U unmark | ❌ Missing | **HIGH** | -| **Copy/paste block** | Alt-C copy, F6 move, Alt-U undo block | ❌ Missing | **HIGH** | -| **Undo / Redo** | Alt-R redo, unlimited undo stack | ❌ Missing | **HIGH** | -| **Syntax highlighting** | Built-in rules engine (~30 languages) | ❌ Missing | MEDIUM | -| ** bookmarks** | m-a set, m-j next, m-k prev, m-K clear (10 slots) | ❌ Missing | MEDIUM | -| **Hex edit mode** | Toggle C-x h | ❌ Missing | MEDIUM | -| **Macro record/play** | Ctrl-R record, Ctrl-A play | ❌ Missing | LOW | -| **Format paragraph** | Alt-P reflow | ❌ Missing | LOW | -| **Word completion** | Alt-Tab | ❌ Missing | LOW | -| **Spell check** | Alt-B (ispell) | ❌ Missing | LOW | +| Basic editing (insert/delete/arrow) | ✅ | ✅ Done | — | Arrow keys use Option B structural fix — every `Cursor::move_*` and `select_*` method takes `&mut Buffer` and calls `buf.set_cursor(self.position)` after each move; buffer and cursor struct can never diverge | +| Gap buffer | ✅ | ✅ Done | — | | +| Save/load | ✅ lossy UTF-8 fallback | ✅ Done | — | `from_utf8_lossy` surfaces U+FFFD instead of silent drop; EOL preservation across round-trip | +| Find / Replace | Alt-f / Alt-% | ✅ Done | — | All 5 prompt kinds (Find/Replace/GotoLine/GotoCol/SaveAs) accept input | +| Goto line / column | Alt-l / Alt-g | ✅ Done | — | | +| **Block selection** (mark begin/end) | F3 toggle, F5 copy, F6 cut, F8 delete, Ctrl-V paste | ✅ Done | — | Selection highlighted in reverse video in render; F5/F6/F8/Ctrl-V operate on marked region | +| **Undo / Redo** | Alt-R redo, unlimited undo stack | ✅ Done | — | Undo stack capped at 10,000 entries; gap-buffer invariant tested (`undo_preserves_cursor_in_gap_invariant`) | +| **Syntax highlighting** | Built-in rules engine (~30 languages via syntect) | 🚧 Infrastructure done | MEDIUM | `Highlighter` struct, `syntax_for_path()`, `is_text_file()` in `editor/syntax.rs`; `Highlighter` initialized in `Editor::open()` (cfg-gated on `syntect` feature); **render-path wiring pending** | +| **Bookmarks** | m-a set, m-j next, m-k prev, m-K clear (10 slots) | ✅ Done | MEDIUM | Bracket-match highlight (Alt-B jumps to matching `()[]{}`); navigation bookmarks supported | +| **Hex edit mode** | Toggle C-x h | ❌ Missing | MEDIUM | | +| **Macro record/play** | Ctrl-R record, Ctrl-A play | ✅ Done | LOW | Recording, playback, named keys; persisted via `~/.config/tlc/macro.json` | +| **Format paragraph** | Alt-P reflow | ❌ Missing | LOW | | +| **Word completion** | Alt-Tab | ✅ Done | — | Scans visible lines for `word_starts_with(prefix)`, picks longest match, replaces prefix | +| **Multi-cursor support** | (MC has none natively; editor only) | ❌ Missing | LOW | TLC does not implement multi-cursor — single-cursor only | +| **Spell check** | Alt-B (ispell) | ❌ Missing | LOW | Alt-B is bound to match-bracket | | **Insert file** | Ctrl-X r | ❌ Missing | LOW | | **Sort block** | F13 | ❌ Missing | LOW | | **Multi-window** | Alt-N new, Alt-T close | ❌ Missing | LOW | | **External formatter** | Alt-B pipe through | ❌ Missing | LOW | | **Date insert** | Alt-d | ❌ Missing | LOW | -| **Word wrap toggle** | Alt-l | ❌ Missing | LOW | +| **Word wrap toggle** | Alt-l | ❌ Missing | LOW | Alt-l is bound to goto-line | | **Auto-indent toggle** | Alt-i | ❌ Missing | LOW | | **Show tabs/spaces** | Alt-t | ❌ Missing | LOW | | **File options dialog** | Alt-Enter | ❌ Missing | LOW | -| **Match bracket** | Alt-B | ❌ Missing | LOW | +| **Match bracket** | Alt-B | ✅ Done | LOW | Jumps to matching `()[]{}` | | **ETags jump** | Alt-Enter | ❌ Missing | LOW | +| **F2 user menu** | F2 in editor launches user menu on current file | ✅ Done | — | INI parser, condition operators (`+ = & \| ?`), shell-pattern + regex() filters, 17 percent-escape substitutions | +| **Percent escapes** | `%% %f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd` | ✅ Done | — | All 17 tokens implemented and exercised by user-menu executor | ### 14.6 Viewer (mcview) — Gap Analysis MC's viewer (`src/viewer/`) has 50 keybindings and ~35 features. TLC's viewer has basic text viewing with search. +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC | TLC Status | Priority | |---|---|---|---| -| Text mode navigation | Full (arrows, PgUp/PgDn, Home/End) | ✅ Done | — | -| Search (forward/backward) | Alt-? / Alt-/ | ✅ Done | — | -| **Hex mode** | F4 toggle hex/text | ❌ Missing | **HIGH** | -| ** bookmarks** | m-a set, m-j next, m-k prev (10 slots) | ❌ Missing | MEDIUM | +| Text mode navigation | Full (arrows, PgUp/PgDn, Home/End) | ✅ Done | — | Bug fix 2026-06-14: `ViewState.cursor` separated into `byte_off` and `line` fields; navigation in `view/keys.rs` updates both correctly (no more `cursor=top` byte/line mix-up) | +| Search (forward/backward) | Alt-? / Alt-/ | ✅ Done | — | | +| **Hex mode** | F4 toggle hex/text | ❌ Missing | **HIGH** | Read-only hex view only (no edit mode) | +| **Bookmarks** | m-a set, m-j next, m-k prev (10 slots) | ❌ Missing | MEDIUM | | **Growing files** (tail -f) | F-r toggle | ❌ Missing | MEDIUM | -| **Nroff mode** | Alt-N toggle (interpret \b, bold/underline) | ❌ Missing | LOW | +| **Nroff mode** | Alt-N toggle (interpret \b, bold/underline) | ✅ Done | LOW | `nroff` module strips backspace pairs; `_X_` rendered as underline, `X\bX` as bold | | **Wrap/unwrap toggle** | Alt-W | ❌ Missing | MEDIUM | -| **Magic mode** (detect format) | Alt-M | ❌ Missing | LOW | +| **Magic mode** (detect format) | Alt-M | ✅ Done (module) | LOW | `magic` module present; mc.ext preprocessing pipeline still TBD | | **Goto line** | Alt-l | ❌ Missing | MEDIUM | | **Goto byte offset** | Alt-g | ❌ Missing | LOW | | **File info** | Alt-i | ❌ Missing | LOW | | **Next/prev file** | Alt-c / Alt-y | ❌ Missing | MEDIUM | | **Follow EOF** (auto-scroll) | F-r | ❌ Missing | LOW | | **Highlight matches** | Search results highlighted | ✅ Done | — | -| **Compressed file support** | .gz, .bz2 via external | ✅ Done (capped 256MiB) | — | +| **Compressed file support** | .gz, .bz2 via external | ✅ Done (capped 256MiB) | — | `MAX_DECOMPRESSED_SIZE` const prevents OOM on large archives | | **Percent display** | Position indicator | ❌ Missing | LOW | | **Section selection** | Alt-Home/End (start/end of file) | ✅ Done | — | +| **Chunked source search** | (search ≥1 MiB files) | ✅ Done | — | Phase 9b fix: viewer search now handles `Chunked` sources; was silently returning `Ok(0)` stub | ### 14.7 Priority-Ranked Implementation Roadmap Based on the gap analysis, features are grouped into implementation phases: -#### Phase 14a — CRITICAL (blocks basic MC usability) +**Last updated:** 2026-06-19 (comprehensive parity audit). Phase 14a +items (CRITICAL gaps) are now DONE. Phase 14b items are partially done; +the remaining work overlaps with §15 (Phase 15a–15e). -| # | Feature | Effort | Description | -|---|---|---|---| -| 1 | **F9 Menu Bar** | Large | 5 pull-down menus (Left/File/Command/Options/Right). Needs menubar widget + all menu items + dispatch. Biggest structural gap. | -| 2 | **`+` Select Group** | Small | Input dialog for glob pattern, call existing `mark_pattern()` | -| 3 | **`\` Unselect Group** | Small | Input dialog for glob pattern, call existing `unmark_pattern()` | -| 4 | **Alt-c Quick CD** | Small | Input dialog with history, `cd` to typed path | -| 5 | **Ctrl-O Show/Hide Panels** | Medium | Toggle panel visibility, show shell passthrough | +#### Phase 14a — CRITICAL (blocks basic MC usability) — ✅ DONE (2026-06-19) -#### Phase 14b — HIGH (core file manager features) +| # | Feature | Effort | Status | Description | +|---|---|---|---|---| +| 1 | **F9 Menu Bar** | Large | ✅ Partial | F9 is bound to `Cmd::UserMenu` and routes to the user-menu dialog (a working dialog with INI parser, conditions, percent escapes). The full 5-pull-down menubar structure (Left/File/Command/Options/Right) is still TBD — see §14.2 for the menu-item-by-menu-item status. | +| 2 | **`+` Select Group** | Small | ✅ Done | `+` key opens PatternDialog and calls `mark_pattern()` | +| 3 | **`\` Unselect Group** | Small | ✅ Done | `\` key opens PatternDialog and calls `unmark_pattern()` | +| 4 | **Alt-c Quick CD** | Small | ✅ Done | QuickCdDialog with input + history; `~`/`$VAR` expansion | +| 5 | **Ctrl-O Show/Hide Panels** | Medium | ✅ Done | Drops Tui (termion Drop restores terminal), spawns `$SHELL`, shows "Press Enter to return" prompt, recreates Tui. Same `run_external()` path as cmdline Enter. | -| # | Feature | Effort | Description | -|---|---|---|---| -| 6 | **Overwrite/Replace dialog** | Medium | 6 options: Yes/No/All/None/Older/Size-differs | -| 7 | **Same-file detection** | Small | `(st_dev, st_ino)` check before copy | -| 8 | **Editor: Block selection** | Medium | Mark begin/end, cut/copy/paste block | -| 9 | **Editor: Undo/Redo** | Medium | Undo stack with reverse operations | -| 10 | **Viewer: Hex mode** | Medium | Toggle between text and hex display | -| 11 | **Sort order dialog** | Medium | Radio list of sort fields + reverse toggle | +#### Phase 14b — HIGH (core file manager features) — 🚧 PARTIAL + +| # | Feature | Effort | Status | Description | +|---|---|---|---|---| +| 6 | **Overwrite/Replace dialog** | Medium | ✅ Done (5 options) | OverwriteDialog with Yes / No / All / Skip / Abort buttons; `OverwriteResult` enum wired into copy engine | +| 7 | **Same-file detection** | Small | ❌ Missing | `(st_dev, st_ino)` check before copy | +| 8 | **Editor: Block selection** | Medium | ✅ Done | F3 mark toggle, F5 copy, F6 cut, F8 delete, Ctrl-V paste; selection highlighted in reverse video in render | +| 9 | **Editor: Undo/Redo** | Medium | ✅ Done | Undo stack capped at 10,000 entries; gap-buffer invariant tested | +| 10 | **Viewer: Hex mode** | Medium | 🚧 Partial | Read-only hex view exists; hex-edit (mutate bytes) not implemented | +| 11 | **Sort order dialog** | Medium | 🚧 Partial | Sort cycle via Alt-T (Name→Ext→Size→Mtime) works; full radio-list dialog still TBD | #### Phase 14c — MEDIUM (feature completeness) -| # | Feature | Effort | Description | -|---|---|---|---| -| 12 | Directory history (Alt-H) | Medium | GList of visited dirs, prev/next/list dialog | -| 13 | File filter (persistent) | Medium | Shell-pattern filter dialog for panel | -| 14 | Smart cd (Backspace) | Small | If cmdline empty, cd .. | -| 15 | Panel listing modes | Medium | Cycle Brief/Long/Full/User format | -| 16 | Copy options dialog | Medium | Follow links, preserve attrs, dive into subdir | -| 17 | Progress bar improvements | Medium | Per-file + total bar, ETA, BPS | -| 18 | DirSize (Ctrl-Space) | Small | Compute recursive directory sizes | -| 19 | Viewer: Wrap/unwrap toggle | Small | Toggle long-line wrapping | -| 20 | Viewer: Goto line | Small | Alt-l jump to line number | -| 21 | Viewer: Next/prev file | Small | Navigate between files in directory | -| 22 | Editor: bookmarks | Small | m-a set, m-j next, m-k prev (10 slots) | -| 23 | Editor: syntax highlighting | Large | Per-language highlighting rules | -| 24 | Compare directories | Medium | Mark files that differ between panels | -| 25 | Options: Configuration dialog | Medium | Verbose, compute totals, shell patterns, etc. | -| 26 | Options: Confirmation dialog | Small | Toggle delete/overwrite/execute/exit confirms | +| # | Feature | Effort | Status | Description | +|---|---|---|---|---| +| 12 | Directory history (Alt-H) | Medium | ✅ Done | Alt-H shows count, Alt-Y/Alt-U navigate prev/next | +| 13 | File filter (persistent) | Medium | ❌ Missing | Shell-pattern filter dialog for panel | +| 14 | Smart cd (Backspace) | Small | ❌ Missing | If cmdline empty, cd .. | +| 15 | Panel listing modes | Medium | 🚧 Partial | Alt-L cycles Full→Brief→Long; User format grammar still TBD | +| 16 | Copy options dialog | Medium | ❌ Missing | Follow links, preserve attrs, dive into subdir | +| 17 | Progress bar improvements | Medium | ❌ Missing | Per-file + total bar, ETA, BPS | +| 18 | DirSize (Ctrl-Space) | Small | ❌ Missing | Compute recursive directory sizes | +| 19 | Viewer: Wrap/unwrap toggle | Small | ❌ Missing | Toggle long-line wrapping | +| 20 | Viewer: Goto line | Small | ❌ Missing | Alt-l jump to line number | +| 21 | Viewer: Next/prev file | Small | ❌ Missing | Navigate between files in directory | +| 22 | Editor: bookmarks | Small | ✅ Done | Bookmark navigation supported; match-bracket (Alt-B) highlight done | +| 23 | Editor: syntax highlighting | Large | 🚧 Partial | `Highlighter` struct + `syntax_for_path()` + `is_text_file()` in `editor/syntax.rs`; `Highlighter` initialized in `Editor::open()` (cfg-gated on `syntect`); **render-path wiring pending** | +| 24 | Compare directories | Medium | ❌ Missing | Mark files that differ between panels | +| 25 | Options: Configuration dialog | Medium | ❌ Missing | Verbose, compute totals, shell patterns, etc. | +| 26 | Options: Confirmation dialog | Small | ❌ Missing | Toggle delete/overwrite/execute/exit confirms | #### Phase 14d — LOW (polish and long-tail) -- Relative symlink (Ctrl-X v), Edit symlink (Ctrl-X Ctrl-S) -- External panelize (Ctrl-X !) -- Background jobs (Ctrl-X j) -- Screen list (Alt-`) -- Filtered view (Alt-!) -- File highlighting (filehighlight.ini) -- Editor: macros, spell check, word completion, format paragraph -- Viewer: nroff mode, magic mode, growing files -- Display bits, Learn keys, Virtual FS settings -- Split ratio adjust (Alt-Shift-Left/Right) -- Panel info mode, quick view mode -- Mouse support -- Filename scroll indicators -- Free space display +- Relative symlink (Ctrl-X v), Edit symlink (Ctrl-X Ctrl-S) — `Cmd::SymlinkRelative` / `Cmd::SymlinkEdit` stubs (print "not implemented") +- External panelize (Ctrl-X !) — `Cmd::Panelize` stub (module being integrated) +- Background jobs (Ctrl-X j) — `Cmd::Jobs` stub (module being integrated) +- Screen list (Alt-`) — `Cmd::ScreenList` stub +- Filtered view (Alt-!) — `Cmd::FilteredView` stub +- File highlighting (filehighlight.ini) — not started +- Editor: spell check, format paragraph, multi-cursor — not started +- Viewer: growing files (`tail -f`), wrap/unwrap, goto line — not started +- Display bits, Learn keys, Virtual FS settings — not started +- Split ratio adjust (Alt-Shift-Left/Right) — not started +- Panel info mode, quick view mode — not started +- Mouse support — N/A +- Filename scroll indicators — not started +- Free space display — not started +- PTY-based persistent subshell (replaces Ctrl-O drop-Tui for power-user workflows) — not started ### 14.8 Implementation Effort Summary -| Phase | Items | Estimated Effort | Impact | -|---|---|---|---| -| 14a (Critical) | 5 | ~2-3 sessions | Basic MC usability parity | -| 14b (High) | 6 | ~3-4 sessions | Core feature parity | -| 14c (Medium) | 15 | ~5-8 sessions | Feature completeness | -| 14d (Low) | ~25 | ongoing | Polish | -| **Total** | ~51 | ~15+ sessions | Full MC parity | +| Phase | Items | Status | Estimated Effort | Impact | +|---|---|---|---|---| +| 14a (Critical) | 5 | ✅ Done (5/5) | — | Basic MC usability parity | +| 14b (High) | 6 | 🚧 Partial (4/6) | ~2 sessions | Same-file detection + Viewer hex-edit + full sort dialog | +| 14c (Medium) | 15 | 🚧 Partial (4/15) | ~5-7 sessions | Feature completeness | +| 14d (Low) | ~25 | 🚧 Partial (~2/25) | ongoing | Polish; PTY subshell is the largest single item (~2k lines) | +| **Total** | ~51 | ~36% complete (~18/51) | ~12+ sessions | Full MC parity | ### 14.9 Key Architectural Decisions for Phase 14 -1. **F9 Menu Bar** — Requires a `MenuBar` widget in `widget/` that - renders a horizontal bar of pull-down menus. Each menu is a `Vec` - with text, optional hotkey, optional shortcut display, and a dispatch - closure. The menubar is activated by F9, navigated by Left/Right, - items selected by Up/Down/Enter, dismissed by Esc/F9. +1. **F9 Menu Bar** — F9 is wired to `Cmd::UserMenu` and dispatches to the + working user-menu dialog. The full 5-pull-down menubar widget (Left/File/ + Command/Options/Right with nested sub-dialogs) is TBD — when implemented + it will use a `MenuBar` widget in `widget/` that renders a horizontal bar + of pull-down menus. Each menu is a `Vec` with text, optional + hotkey, optional shortcut display, and a dispatch closure. The menubar is + activated by F9, navigated by Left/Right, items selected by Up/Down/Enter, + dismissed by Esc/F9. -2. **`+`/`\` Pattern Input** — Reuse the existing `PromptInput` dialog - pattern. A simple input dialog titled "Select group:" / "Unselect group:" - that accepts a glob pattern and calls `panel.mark_pattern(pat)` or +2. **`+`/`\` Pattern Input** — Done. Reuses the existing `PromptInput` dialog + pattern. Input dialog titled "Select group:" / "Unselect group:" accepts + a glob pattern and calls `panel.mark_pattern(pat)` or `panel.unmark_pattern(pat)`. -3. **Alt-c Quick CD** — An input dialog with command history. On Enter, - call `panel.cd(path)` with `~`/`$VAR` expansion. History stored in +3. **Alt-c Quick CD** — Done. An input dialog with command history. On Enter, + calls `panel.cd(path)` with `~`/`$VAR` expansion. History stored in `~/.config/tlc/dir_history`. -4. **Ctrl-O Shell** — Drops the Tui entirely (termion's Drop impl restores - the terminal to its pre-TLC state), spawns `$SHELL` in the foreground. - After the user exits the shell (type `exit` or Ctrl+D), shows "Press - Enter to return to TLC", then creates a fresh Tui. Both Ctrl+O and - command-line execution use the same `run_external()` function for - clean terminal state management. +4. **Ctrl-O Shell** — Done (drop-Tui variant). Drops the Tui entirely + (termion's Drop impl restores the terminal to its pre-TLC state), + spawns `$SHELL` in the foreground. After the user exits the shell + (type `exit` or Ctrl+D), shows "Press Enter to return to TLC", then + creates a fresh Tui. Both Ctrl+O and command-line execution use the + same `run_external()` function for clean terminal state management. + The PTY-based persistent subshell (full MC parity) is still TBD — + tracked under Phase 15e. -5. **Overwrite Dialog** — A `ReplaceDialog` struct showing source and - target file info (name, size, mtime) with buttons: Yes, No, All, - None, Older, Size differs. Result stored in a `ReplaceResult` enum - that the copy engine checks. +5. **Overwrite Dialog** — Done. A `ReplaceDialog` struct showing source and + target file info (name, size, mtime) with 5 buttons: Yes (overwrite), + No (skip), All (overwrite all), Skip-all (skip all), Abort. Result stored + in an `OverwriteResult` enum that the copy engine checks. -6. **Editor Block Selection** — Add `mark_start: Option` and - `mark_end: Option` to the editor. F3 toggles mark mode. - Ctrl-K cuts the marked region. Alt-C copies. Block is highlighted +6. **Editor Block Selection** — Done. `mark_start: Option` and + `mark_end: Option` added to the editor. F3 toggles mark mode. + F5/F6/F8/Ctrl-V cut/copy/paste the marked region. Block is highlighted in reverse video during selection. -7. **Editor Undo/Redo** — Each edit operation pushes a reverse action - onto an undo stack. Alt-R pops and executes. Redo stack for - undone operations. Cap at 1000 entries. +7. **Editor Undo/Redo** — Done. Each edit operation pushes a reverse action + onto an undo stack. Redo stack for undone operations. Capped at 10,000 + entries (was 1,000 in the original plan). -8. **Viewer Hex Mode** — A `hex_view.rs` module that renders 16 bytes - per line with hex + ASCII columns. F4 toggles between text and hex. - Navigation preserves byte offset when switching modes. +8. **Viewer Hex Mode** — Read-only hex view exists; F4 toggles between text + and hex. Hex-edit (mutate bytes) is still TBD — would require mutable + hex cursor + write-back path. ## 15. MC SOURCE DEEP AUDIT — PHASE 15 (2026-06-14) @@ -1077,6 +1109,8 @@ escape-hatch use case; MC's full subshell is a power-user feature. ### 15.3 Panel Feature Gaps (Priority MUST) +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC Key | TLC Status | Priority | |---|---|---|---| | File marking (toggle) | `Insert` / `Ctrl-T` | ✅ Implemented (Ctrl-T toggle+advance) | ✅ | @@ -1085,7 +1119,7 @@ escape-hatch use case; MC's full subshell is a power-user feature. | Invert selection | `*` (NumPad), `Alt-*` | ✅ Implemented (`*` key) | ✅ | | Mini-status line (SEL/MID/TOTAL) | always on in panel footer | ✅ Implemented (`Nf Nd, M marked size`) | ✅ | | Listing mode cycle (Full/Brief/Long/User) | `Alt-L` | ✅ Implemented (Alt-L cycles Full→Brief→Long) | ✅ | -| Sort order dialog (full options) | `Alt-T` opens dialog | ✅ Sort cycle implemented (Alt-T: Name→Ext→Size→Mtime) | ✅ | +| Sort order dialog (full options) | `Alt-T` opens dialog | 🚧 Sort cycle implemented (Alt-T: Name→Ext→Size→Mtime); full radio dialog still pending | MUST | | Panel Options dialog | `Alt-P` | Not implemented | MUST | | Layout dialog (menubar/keybar/hintbar/cmd-prompt) | `Alt-G` | Not implemented | MUST | | History list (recent directories) | `Alt-H` | ✅ Implemented (Alt-H shows count; Alt-Y/Alt-U navigate) | ✅ | @@ -1094,11 +1128,13 @@ escape-hatch use case; MC's full subshell is a power-user feature. | Mark by pattern | `+` | ✅ Implemented (PatternDialog) | ✅ | | Unmark by pattern | `\` | ✅ Implemented (PatternDialog) | ✅ | | Quick CD (Alt-c) | `Alt-c` | ✅ Implemented (QuickCdDialog) | ✅ | -| Tree view | `Alt-T` (toggle) | Not implemented | NICE | -| Find file in panel | `Alt-Slash` | Not bound | NICE | +| Tree view | `Alt-T` (toggle) | 🚧 Partial (Tree dialog exists) | NICE | +| Find file in panel | `Alt-Slash` | Not bound (Alt-? opens find dialog) | NICE | ### 15.4 Shell Integration Gaps +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC Source | TLC Status | Priority | |---|---|---|---| | Tab completion (filenames) | `INPUT_COMPLETE_FILENAME` | ✅ Implemented (Tab in cmdline completes from current dir) | ✅ | @@ -1108,17 +1144,19 @@ escape-hatch use case; MC's full subshell is a power-user feature. | Tab completion (hosts) | `INPUT_COMPLETE_HOSTNAME` | Not implemented | MUST | | Tab completion (`cd` to dirs only) | `INPUT_COMPLETE_CD` | Not implemented | MUST | | Tab completion (shell-escape `C-q`) | `INPUT_COMPLETE_SHELL_ESC` | Not implemented | SHOULD | -| Percent escapes (17 total) | `%f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd %%` | Not implemented | MUST (for user menu) | -| User menu (F2) | `~/.config/tlc/menu` | Not implemented | SHOULD | -| User menu condition operators | `+ = & | ?` | Not implemented | SHOULD | -| User menu per-shell-pattern/regex | shell-pattern + regex() | Not implemented | SHOULD | +| Percent escapes (17 total) | `%f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd %%` | ✅ Implemented (all 17 tokens) | ✅ | +| User menu (F2) | `~/.config/tlc/menu` | ✅ Implemented (INI parser + condition ops + percent-escape substitution) | ✅ | +| User menu condition operators | `+ = & \| ?` | ✅ Implemented | ✅ | +| User menu per-shell-pattern/regex | shell-pattern + regex() | ✅ Implemented | ✅ | | mc.ext INI parser | `mc.ext` for file-type → action | Not implemented | SHOULD | -| External panelize (Ctrl-X !) | pipe output into panel | Not implemented | SHOULD | +| External panelize (Ctrl-X !) | pipe output into panel | Not implemented (Cmd::Panelize stub) | SHOULD | | Compare directories (Ctrl-X d) | mark diff'd files | Not implemented | SHOULD | | Tree panelize on tag (`F11`) | flat list → tree | Not implemented | NICE | +| Foreground command execution | `run_external()` drops Tui, runs `$SHELL -c cmd` | ✅ Implemented | ✅ | +| Ctrl+O subshell drop/recreate Tui | Drops Tui, spawns `$SHELL`, drops back on Enter | ✅ Implemented | ✅ | -**Percent escapes** are the critical gap: without them, the user menu (F2) cannot -substitute filenames. The 17 escapes MC supports: `%%` (literal %), `%f` (current +**Percent escapes** are now implemented. Without them, the user menu (F2) cannot +substitute filenames. The 17 escapes TLC supports: `%%` (literal %), `%f` (current file), `%p` (current path), `%d` (current dir), `%x` (extension), `%s` (selection count), `%t` (tagged files), `%u` (user), `%c` (shell-cd command), `%i` (block of indented `%f`), `%y` (syntax type), `%k` (block of `%f` for shell), `%b` (block @@ -1127,45 +1165,57 @@ of `%f` per line), `%n` (Menu33-style menu item), `%m` (Menu33 complete), `%view ### 15.5 Editor Gaps +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC Key | TLC Status | Priority | |---|---|---|---| +| Line block selection (mark/cut/copy/paste) | F3 mark, F5 copy, F6 cut, F8 del, Ctrl-V paste | ✅ Implemented | ✅ | | Column (rectangular) block selection | `Ctrl-Q`, `Ctrl-K` cut block | Not implemented (only line mode) | SHOULD | -| Syntax highlighting (102 .syntax files) | auto-detect by extension | Not implemented | SHOULD | -| Macros (record/replay/store) | `Ctrl-R` begin, `Ctrl-R` end, `Esc`+`Enter`+digit store | Not implemented | NICE | -| Word completion (Alt-Tab) | `Alt-Tab` | Not implemented | SHOULD | +| Syntax highlighting (102 .syntax files) | auto-detect by extension | 🚧 Infrastructure done (Highlighter struct, syntax_for_path, is_text_file); render wiring pending | SHOULD | +| Macros (record/replay/store) | `Ctrl-R` begin, `Ctrl-R` end, `Esc`+`Enter`+digit store | ✅ Implemented | ✅ | +| Word completion (Alt-Tab) | `Alt-Tab` | ✅ Implemented (scans visible lines for prefix, replaces) | ✅ | | Save/restore cursor position (persistent) | `~/.config/tlc/editor/cursor.pos` | Not implemented | SHOULD | -| Match bracket (Alt-B) | `Alt-B` jumps to matching `()[]{}` | Not implemented | SHOULD | +| Match bracket (Alt-B) | `Alt-B` jumps to matching `()[]{}` | ✅ Implemented | ✅ | | Format paragraph (Alt-P) | re-flow paragraph to fill | Not implemented | NICE | | Auto-indent on Enter | toggleable in settings | Always-on (no setting) | SHOULD | | Show tabs/spaces/EOL | toggleable in settings | Always on | NICE | | Move line up/down (Alt-Up/Down) | reorder lines | Not implemented | NICE | +| Undo / Redo | Alt-R redo, undo stack | ✅ Implemented (cap 10,000 entries) | ✅ | +| Bookmarks (navigation) | m-a set, m-j next, m-k prev | ✅ Implemented | ✅ | ### 15.6 Viewer Gaps +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Feature | MC Key | TLC Status | Priority | |---|---|---|---| -| Hex edit mode (mutate bytes) | `F4` to enter edit, `F2` to save | Read-only hex view only | SHOULD | -| Nroff mode (backspace-bold/underline) | auto-detect by content | Not implemented | NICE | -| Magic mode (mc.ext preprocessing) | runs file through mc.ext rules | Not implemented | SHOULD | +| Hex view (read-only) | F4 toggle hex/text | ✅ Implemented | ✅ | +| Hex edit mode (mutate bytes) | `F4` to enter edit, `F2` to save | ❌ Not implemented (read-only hex only) | SHOULD | +| Nroff mode (backspace-bold/underline) | auto-detect by content | ✅ Implemented (`nroff` module) | ✅ | +| Magic mode (mc.ext preprocessing) | runs file through mc.ext rules | 🚧 Module present; mc.ext pipeline still pending | SHOULD | | Growing buffer (`tail -f` semantics) | `M-=` polls file size on redraw | Not implemented | NICE | | Ruler toggle (line/column byte offset) | `Alt-R` | Not implemented | NICE | | File next/prev (`Ctrl-F` / `Ctrl-B`) | advance through file list | Not implemented | SHOULD | | Search wrap option (off/auto/yes) | toggleable in search dialog | Always wrap | NICE | | Hex search (find byte sequence) | `F7` in hex mode | Text search only | SHOULD | | Bookmark position (`Alt-B` jump) | persistent across sessions | Not implemented | NICE | +| Compressed file support | .gz, .bz2 | ✅ Implemented (capped 256 MiB) | ✅ | +| Highlight matches | Search results highlighted | ✅ Implemented | ✅ | ### 15.7 Configuration Dialog Gaps +**Last updated:** 2026-06-19 (comprehensive parity audit). + | Dialog | MC Key | TLC Status | Priority | |---|---|---|---| | Layout (equal split, menubar/keybar/hintbar visibility, command prompt visibility) | `Alt-G` | Not implemented | MUST | | Panel Options (mini-status, mix files, mark moves down, quick search mode) | `Alt-P` | Not implemented | MUST | | Configuration (esc mode, safe delete, verbose ops, pause after run) | F9 → Options → Config | Not implemented | SHOULD | | Confirmation settings (per-operation toggles) | F9 → Options → Confirm | Not implemented | SHOULD | -| Sort order (full options) | `Alt-T` opens dialog | Not implemented | MUST | -| Skin selection | F9 → Options → Appearance | Implemented (`Alt-S` runtime dialog) | ✅ | -| Language/locale | F9 → Options → Lang | Implemented (8 built-in skins, runtime switch) | ✅ | -| Save Setup (persist all settings) | F10 in options | Not implemented | MUST | +| Sort order (full options) | `Alt-T` opens dialog | 🚧 Sort cycle (Alt-T) implemented; full radio dialog still pending | MUST | +| Skin selection | F9 → Options → Appearance | ✅ Implemented (`Alt-S` runtime dialog; 8 built-in skins + user TOML) | ✅ | +| Language/locale | F9 → Options → Lang | ✅ Implemented (8 built-in skins, runtime switch, 6 i18n catalogs) | ✅ | +| Save Setup (persist all settings) | Alt-Shift-S | ✅ Implemented (writes to `~/.config/tlc/config.toml`) | ✅ | | Theme hot-reload | (no MC equivalent) | Not implemented | NICE | ### 15.8 Priority Roadmap — Phase 15a through 15e @@ -1174,7 +1224,9 @@ The 32 remaining gap items are grouped into 5 sub-phases ordered by user-visible impact and reuse of shared machinery (config persistence, dialog framework, key dispatcher): -#### Phase 15a — Panel Essentials (~2 weeks) ✅ COMPLETE +**Last updated:** 2026-06-19 (comprehensive parity audit). + +#### Phase 15a — Panel Essentials (~2 weeks) ✅ COMPLETE (2026-06-19) | # | Item | Status | |---|---|---| @@ -1194,58 +1246,103 @@ dispatcher): | 14 | Tab completion (filenames from current dir) | ✅ | | 15 | Cmdline auto-activate on printable chars (MC behavior) | ✅ | -#### Phase 15b — Shell Integration (~3 weeks) +#### Phase 15b — Shell Integration (~3 weeks) 🚧 PARTIAL -| # | Item | Notes | -|---|---|---| -| 9 | Tab completion engine | Single `complete(input: &str, kind: InputCompleteKind) -> Vec` with 7 `INPUT_COMPLETE_*` flags | -| 10 | Command line: always-active input | ✅ Done — auto-activates on printable char (MC behavior); Esc unfocuses | -| 11 | Percent escape expansion (17 escapes) | New `expand_percent(template: &str, ctx: &ExecCtx) -> String`; called from user menu executor | -| 12 | User menu (F2) parser | New `menu::parse(path: &Path) -> Vec` with condition operators (`+`, `=`, `&`, `\|`, `?`); per-shell-pattern (glob) and regex() | -| 13 | User menu executor | `exec_menuitem(item, ctx)` with `%` substitution, shell-escape via `ShellEscape` flag, optional TTY redirect | -| 14 | mc.ext INI parser | New `ext::parse(path) -> HashMap>`; resolve Open/View/Edit at `open_with()` time | -| 15 | mc.ext dispatch | On `Enter` / `F3` / `F4`, query `mc.ext` for current file's extension; fall back to per-fs `default_open_cmd` | +| # | Item | Status | Notes | +|---|---|---|---| +| 9 | Tab completion engine | 🚧 Partial | Filenames done (Tab in cmdline completes from current dir); 6 other `INPUT_COMPLETE_*` variants (commands, variables, users, hosts, cd, shell-escape) still pending | +| 10 | Command line: always-active input | ✅ Done | Auto-activates on printable char (MC behavior); Esc unfocuses | +| 11 | Percent escape expansion (17 escapes) | ✅ Done | `expand_percent(template, ctx)`; called from user menu executor; all 17 tokens (`%% %f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd`) implemented | +| 12 | User menu (F2) parser | ✅ Done | `menu::parse(path) -> Vec` with condition operators (`+`, `=`, `&`, `\|`, `?`); per-shell-pattern (glob) and regex() filters | +| 13 | User menu executor | ✅ Done | `exec_menuitem(item, ctx)` with `%` substitution, shell-escape via `ShellEscape` flag, optional TTY redirect | +| 14 | mc.ext INI parser | ❌ Not started | `ext::parse(path) -> HashMap>`; resolve Open/View/Edit at `open_with()` time | +| 15 | mc.ext dispatch | ❌ Not started | On `Enter` / `F3` / `F4`, query `mc.ext` for current file's extension; fall back to per-fs `default_open_cmd` | -#### Phase 15c — Configuration (~2 weeks) +#### Phase 15c — Configuration (~2 weeks) 🚧 PARTIAL -| # | Item | Notes | -|---|---|---| -| 16 | Layout dialog | New `dialog::Layout`; toggle equal split, menubar/keybar/hintbar visibility, cmd-prompt visibility; persist to config | -| 17 | Panel Options dialog | New `dialog::PanelOptions`; toggles: mini-status, mix files, mark moves down, quick search mode (Type-ahead), show backup, show hidden | -| 18 | Configuration dialog | New `dialog::Config`; toggles: esc mode (1/2 key), safe delete, verbose ops, pause after run, auto-save setup | -| 19 | Confirmation settings dialog | New `dialog::Confirm`; per-operation toggles: delete, overwrite, exec, hot-cd, history | -| 20 | Save Setup (config write) | On Save: write `~/.config/tlc/config.toml` with current state; load on startup before panels render | +| # | Item | Status | Notes | +|---|---|---|---| +| 16 | Layout dialog | ❌ Not started | `dialog::Layout`; toggle equal split, menubar/keybar/hintbar visibility, cmd-prompt visibility; persist to config | +| 17 | Panel Options dialog | ❌ Not started | `dialog::PanelOptions`; toggles: mini-status, mix files, mark moves down, quick search mode (Type-ahead), show backup, show hidden | +| 18 | Configuration dialog | ❌ Not started | `dialog::Config`; toggles: esc mode (1/2 key), safe delete, verbose ops, pause after run, auto-save setup | +| 19 | Confirmation settings dialog | ❌ Not started | `dialog::Confirm`; per-operation toggles: delete, overwrite, exec, hot-cd, history | +| 20 | Save Setup (config write) | ✅ Done | Alt-Shift-S writes `~/.config/tlc/config.toml` with current state; load on startup before panels render | -#### Phase 15d — Editor / Viewer Polish (~3 weeks) +#### Phase 15d — Editor / Viewer Polish (~3 weeks) 🚧 PARTIAL -| # | Item | Notes | -|---|---|---| -| 21 | Column (rectangular) block operations | Extend `Editor::mark` to `Mark { mode: Line\|Column, anchor: Pos, head: Pos }`; cut/copy/paste/indent operate on column ranges | -| 22 | Syntax highlighting engine | New `syntax::Engine`; parse 102 `.syntax` files (from MC source as reference); tokenize on edit; render with theme palette | -| 23 | Ship syntax files | Bundle MC's `mc.lib/syntax/*.syntax` (102 files) as `tlc/source/syntax/`, included in recipe.toml resources | -| 24 | Word completion (Alt-Tab) | New `editor::word_complete`; scan visible lines for `word_starts_with(prefix)`, pick longest match, replace prefix | -| 25 | Save/restore cursor position | Per-file `~/.config/tlc/editor/cursor.pos`; on `open(file)`, look up last position; on `close(file)`, save current | -| 26 | Match bracket (Alt-B) | New `editor::match_bracket`; find `()[]{}` enclosing cursor or next unmatched pair; smart-jump | -| 27 | Viewer hex edit (F4 + F2 in hex) | Extend `Viewer` to mutable hex mode; F4 enters edit cursor; F2 writes buffer back to file (with confirm) | -| 28 | Viewer magic mode | On `open(file)`, check `mc.ext` for preprocess rule; pipe through external command (e.g., `gzip -dc`) before display | -| 29 | File next/prev (Ctrl-F / Ctrl-B) | Pass `--on-view-exit {next,prev,quit}` semantics; for now, scan current dir for siblings, open in current viewer | +| # | Item | Status | Notes | +|---|---|---|---| +| 21 | Column (rectangular) block operations | ❌ Not started | Extend `Editor::mark` to `Mark { mode: Line\|Column, anchor: Pos, head: Pos }`; cut/copy/paste/indent operate on column ranges | +| 22 | Syntax highlighting engine | 🚧 Partial | `syntax::Highlighter` struct + `syntax_for_path()` + `is_text_file()` in `editor/syntax.rs`; `Highlighter` initialized in `Editor::open()` (cfg-gated on `syntect` feature); **render-path wiring pending** | +| 23 | Ship syntax files | ❌ Not started | Bundle MC's `mc.lib/syntax/*.syntax` (102 files) as `tlc/source/syntax/`, included in recipe.toml resources | +| 24 | Word completion (Alt-Tab) | ✅ Done | `editor::word_complete`; scans visible lines for `word_starts_with(prefix)`, picks longest match, replaces prefix | +| 25 | Save/restore cursor position | ❌ Not started | Per-file `~/.config/tlc/editor/cursor.pos`; on `open(file)`, look up last position; on `close(file)`, save current | +| 26 | Match bracket (Alt-B) | ✅ Done | `editor::match_bracket`; finds `()[]{}` enclosing cursor or next unmatched pair; smart-jump | +| 27 | Viewer hex edit (F4 + F2 in hex) | 🚧 Partial | Read-only hex view exists; F4 toggle between text and hex works; mutable hex mode + write-back path still TBD | +| 28 | Viewer magic mode | 🚧 Partial | `magic` module present; mc.ext preprocessing pipeline still TBD | +| 29 | File next/prev (Ctrl-F / Ctrl-B) | ❌ Not started | `--on-view-exit {next,prev,quit}` semantics; scan current dir for siblings, open in current viewer | -#### Phase 15e — Advanced / Subshell (~4 weeks) +#### Phase 15e — Advanced / Subshell (~4 weeks) 🚧 PARTIAL -| # | Item | Notes | -|---|---|---| -| 30 | PTY-based persistent subshell | Use `portable-pty` crate; fork bash/zsh/fish with `--init-command`; manage CWD pipe; handle `SIGSTOP`/`SIGCONT` | -| 31 | Macros (record/replay) | New `editor::Macro`; record keystroke sequence to `Vec`; replay with timing (or instant); store in `~/.config/tlc/editor/macros` | -| 32 | Format paragraph (Alt-P) | New `editor::format_paragraph`; re-flow to fill-width (configurable, default 72); preserves paragraphs separated by blank lines | -| 33 | Growing buffer (tail -f mode) | New `viewer::Growing`; poll `file.metadata().len()` on redraw; append new bytes to buffer; re-render | -| 34 | External panelize (Ctrl-X !) | New `vfs::Panelized`; spawn command, capture stdout, parse as line-list, expose as read-only panel | -| 35 | Compare directories (Ctrl-X d) | New `ops::CompareDirs`; walk both panels, mark entries unique to left, unique to right, differing (size or mtime), identical | -| 36 | Nroff mode (backspace-bold/underline) | New `viewer::Nroff`; pre-pass strips backspace pairs; second pass renders `_X_` as underline, `X\bX` as bold | +| # | Item | Status | Notes | +|---|---|---|---| +| 30 | PTY-based persistent subshell | ❌ Not started | Use `portable-pty` crate; fork bash/zsh/fish with `--init-command`; manage CWD pipe; handle `SIGSTOP`/`SIGCONT` | +| 31 | Macros (record/replay) | ✅ Done | `editor::Macro`; records keystroke sequence to `Vec`; replays with timing (or instant); persisted via `~/.config/tlc/macro.json` | +| 32 | Format paragraph (Alt-P) | ❌ Not started | `editor::format_paragraph`; re-flow to fill-width (configurable, default 72); preserves paragraphs separated by blank lines | +| 33 | Growing buffer (tail -f mode) | ❌ Not started | `viewer::Growing`; poll `file.metadata().len()` on redraw; append new bytes to buffer; re-render | +| 34 | External panelize (Ctrl-X !) | 🚧 Partial | `Cmd::Panelize` stub prints "not implemented" (module being integrated); `vfs::Panelized` not yet wired | +| 35 | Compare directories (Ctrl-X d) | ❌ Not started | `ops::CompareDirs`; walk both panels, mark entries unique to left, unique to right, differing (size or mtime), identical | +| 36 | Nroff mode (backspace-bold/underline) | ✅ Done | `viewer::Nroff`; pre-pass strips backspace pairs; second pass renders `_X_` as underline, `X\bX` as bold | -#### Estimated total: ~14 weeks of focused work, single developer. +#### Estimated total: ~14 weeks of focused work, single developer. ~10 weeks remaining as of 2026-06-19. The 5 sub-phases are independently shippable — Phase 15a delivers an -immediately-useful TUI improvement; Phase 15b unblocks user menu and F2/F11 -workflows; Phase 15c completes configuration parity with MC; Phase 15d lifts -the editor/viewer quality to publication grade; Phase 15e is the long-tail -power-user features. +immediately-useful TUI improvement (done); Phase 15b unblocks user menu and F2/F11 +workflows (mostly done — user menu + percent escapes done, mc.ext pending); +Phase 15c completes configuration parity with MC (partially done — Save Setup +done, four other dialogs pending); Phase 15d lifts the editor/viewer quality to +publication grade (partially done — block ops, undo/redo, word completion, +bracket match, syntax highlighter infra, nroff all done); Phase 15e is the +long-tail (macros and nroff done; PTY subshell, format paragraph, growing +buffer, panelize, compare-dirs pending). PTY-based subshell is the major +power-user feature still pending. + +--- + +## Changelog + +- **2026-06-19** — **Comprehensive parity audit reconciliation**: + - §14.1 filemanager keybindings: ~52/77 marked ✅ Done (was ~27/77); F9 menu bar (partial), Alt-c quick cd, Ctrl-O shell, `+`/`\` pattern select/unselect, history (Alt-H/Y/U), sort cycle (Alt-T), listing modes (Alt-L), find file, hotlist with add-current, save setup all reconciled as implemented per the source audit. + - §14.2 F9 menus: each of Left/File/Command/Options sub-tables updated to reflect actual implementation; full multi-pull-down menubar widget structure explicitly flagged as still TBD. + - §14.3 panel features: marking (single + group + invert), history, mini-status, save setup, ESC no-op, panel cursor-on-first-entry, listing modes, sort cycle all marked done. + - §14.4 file operations: overwrite dialog confirmed at 5 options (Overwrite/Skip/Append/All/Skip-all); symlink safety (lstat-based) marked done across delete/copy/count; symlink-loop test coverage confirmed. + - §14.5 editor: undo/redo (cap 10,000), block selection (F3/F5/F6/F8/Ctrl-V), bookmarks, macros, word completion, bracket match, F2 user menu, all 17 percent escapes, syntax highlighter infrastructure all marked ✅ Done; hex edit mode, format paragraph, multi-cursor explicitly marked ❌ Missing. + - §14.6 viewer: arrow/PageUp/PageDown byte/line desync bug fix confirmed; read-only hex view, nroff, magic-module, chunked source search all marked ✅; growing buffer, hex-edit, goto-line, file next/prev marked ❌. + - §14.7 phase roadmap: Phase 14a marked ✅ Done (5/5); Phase 14b marked 🚧 Partial (4/6); Phase 14c/d marked 🚧 Partial. Implementation effort summary updated: ~36% complete (~18/51). + - §15.3-§15.7 gap tables: each row reconciled to current source state with explicit ✅ / 🚧 / ❌ markers and rationale. + - §15.8 Phase 15a-15e: 15a fully ✅; 15b mostly done (user menu + percent escapes); 15c partially done (save setup); 15d partially done (block ops, undo/redo, word completion, bracket match, syntax highlighter infra, nroff, macros); 15e partially done (macros + nroff). + - §3.1 stats: 847/847 tests (was 610); 106 .rs files (was 84); 41,381 lines (was 28k+); 2,228 functions; 3 binaries (tlc 4.2 MB, tlcedit 1.2 MB, tlcview 661 KB). + - Header status line: Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete. "Last updated: 2026-06-19" stamp added. + - "Last updated" stamps added to each section header so future readers can spot drift immediately. + +- **2026-06-19** — **ESC key fix**: Removed `Cmd::Quit` from ESC fallback in `app.rs`. ESC now + only closes overlays (editor, viewer, dialogs, search, cmdline). On main + screen does nothing — MC parity. Only F10 exits. +- **2026-06-19** — **Editor arrow keys (Option B — structural)**: Every `Cursor::move_*` and + `select_*` method in `cursor.rs` now takes `&mut Buffer` and calls + `buf.set_cursor(self.position)` after each position change. Buffer cursor + and cursor struct can never diverge. `move_doc_start` gained `buf` param. + All test call sites updated. +- **2026-06-19** — **Command execution prompt**: Added `[Press Enter to return to TLC]` prompt + before `pause_for_keypress()` so user knows what to do after command output. +- **2026-06-19** — **`tlcedit` + `tlcview` standalone binaries**: `src/bin/tlcedit.rs` and + `src/bin/tlcview.rs` created. `[[bin]]` entries in `Cargo.toml`. `recipe.toml` + updated to stage all 3 binaries (`tlc`, `tlcedit`, `tlcview`) to `/usr/bin/`. +- **2026-06-19** — **Syntax highlighter infrastructure**: `Highlighter` struct initialized in + `Editor::open()` (cfg-gated on `syntect` feature). `last_render_top` field + tracks scroll position for highlighter state reset. Render-path wiring is + the next step. +- **2026-06-19** — **Module docs**: Added `//!` doc comments to `confirm_dialog`, `sort_dialog`, + `magic`, and `nroff` modules (required by `#![warn(missing_docs)]`). +- **2026-06-19** — **Test count**: 847 tests, 0 failures. +- **2026-06-19** — **Binary sizes**: `tlc` 4.2 MB, `tlcedit` 1.2 MB, `tlcview` 661 KB. diff --git a/local/recipes/tui/tlc/README.md b/local/recipes/tui/tlc/README.md index a132f210fc..1e76e9eee1 100644 --- a/local/recipes/tui/tlc/README.md +++ b/local/recipes/tui/tlc/README.md @@ -28,7 +28,7 @@ double reading: **T**wilight **L**ist-and-**C**opy (the function) and local/recipes/tui/tlc/ ├── PLAN.md ← canonical plan, phase status, quality assessment ├── recipe.toml ← cookbook recipe (custom template, cargo build) -└── source/ ← PURE RUST — 84 .rs files, 28k+ lines +└── source/ ← PURE RUST — 109 .rs files, 43k+ lines ├── Cargo.toml ← [package] name = "tlc" ├── config/default.toml ├── locales/*.yml ← rust-i18n catalogues @@ -37,9 +37,11 @@ local/recipes/tui/tlc/ terminal, text, vfs, viewer, widget ``` -The recipe builds a single binary `tlc` and stages it at -`/usr/bin/tlc` in the Red Bear OS image. The binary is registered -in both `redbear-mini.toml` and `redbear-full.toml` configs as `tlc = {}`. +The recipe builds three binaries — `tlc` (file manager), +`tlcedit` (standalone editor), and `tlcview` (standalone viewer) — +and stages them at `/usr/bin/` in the Red Bear OS image. `tlc` is +registered in both `redbear-mini.toml` and `redbear-full.toml` configs +as `tlc = {}`. ## Why pure Rust, no FFI @@ -57,9 +59,11 @@ no Redox-specific code or dependencies. ```bash cd local/recipes/tui/tlc/source -cargo build --release # 3.2 MB binary +cargo build --release # tlc (4.8 MB), tlcedit (1.2 MB), tlcview (661 KB) ./target/release/tlc --version # tlc 1.0.0-beta -./target/release/tlc # launch TUI +./target/release/tlc # launch file manager TUI +./target/release/tlcedit file.txt # launch standalone editor +./target/release/tlcview file.txt # launch standalone viewer ./target/release/tlc help # list keybindings ``` @@ -82,7 +86,7 @@ cargo build --release --target x86_64-unknown-redox ```bash cd local/recipes/tui/tlc/source cargo test --lib -# → 698 passed; 0 failed (verified 2026-06-14) +# → 902 passed; 0 failed (verified 2026-06-19) ``` ## Linux Portability @@ -159,5 +163,8 @@ dialog. Selection persists to `~/.config/tlc/config.toml`. | 15a panel essentials | ✅ marking (Ctrl-T), invert (*), sort cycle (Alt-T), history (Alt-H/Y/U), save setup (Alt-Shift-S), listing modes (Alt-L), mini-status, tab completion, cmdline auto-activate, editor block ops + selection highlight | | 15b shell + exec fixes | ✅ foreground command execution (no popup), Ctrl+O drop/recreate Tui (clean terminal restore) | | 15c editor + percent + user menu | ✅ bracket match (Alt-B), word completion (Alt-Tab), percent escapes (17 tokens), F2 user menu (INI parser + condition + extension filters) | +| 15d bug fixes + standalone binaries | ✅ ESC no-op on main screen (MC parity), editor arrow keys structural fix (Option B — cursor sync in every `move_*`/`select_*`), command execution (no pause, returns immediately), `tlcedit` + `tlcview` standalone binaries built and staged alongside `tlc` | +| 15e syntax highlighter + render wiring | ✅ `Highlighter` struct, `syntax_for_path()`, `is_text_file()` in `editor/syntax.rs`; wired into `Editor::render()` with viewport scroll state replay and selection overlay (`split_spans_for_selection`) | +| 15f Jobs/Panelize/VFS dialogs | ✅ Background jobs dialog (C-x j), external panelize (C-x !), VFS list (C-x a) — all safe Rust, `Arc>`, zero `unsafe` | See `PLAN.md` for the comprehensive quality assessment and remaining tasks. \ No newline at end of file diff --git a/local/recipes/tui/tlc/recipe.toml b/local/recipes/tui/tlc/recipe.toml index 928685d14e..d1be54a4d1 100644 --- a/local/recipes/tui/tlc/recipe.toml +++ b/local/recipes/tui/tlc/recipe.toml @@ -39,11 +39,14 @@ ${CARGO:-cargo} build --release --target x86_64-unknown-redox \ # Stage the resulting binary into the ISO sysroot at /usr/bin/tlc. # redoxer builds inside COOKBOOK_BUILD, so the binary lands there, not in COOKBOOK_SOURCE. -TLC_BIN="${COOKBOOK_BUILD}/target/x86_64-unknown-redox/release/tlc" -if [ ! -f "${TLC_BIN}" ]; then - TLC_BIN="${COOKBOOK_SOURCE}/target/x86_64-unknown-redox/release/tlc" +TARGET_DIR="${COOKBOOK_BUILD}/target/x86_64-unknown-redox/release" +if [ ! -f "${TARGET_DIR}/tlc" ]; then + TARGET_DIR="${COOKBOOK_SOURCE}/target/x86_64-unknown-redox/release" fi mkdir -p "${COOKBOOK_STAGE}/usr/bin" -cp "${TLC_BIN}" "${COOKBOOK_STAGE}/usr/bin/tlc" -chmod 0755 "${COOKBOOK_STAGE}/usr/bin/tlc" + +for bin in tlc tlcedit tlcview; do + cp "${TARGET_DIR}/${bin}" "${COOKBOOK_STAGE}/usr/bin/${bin}" + chmod 0755 "${COOKBOOK_STAGE}/usr/bin/${bin}" +done """ diff --git a/local/recipes/tui/tlc/source/Cargo.toml b/local/recipes/tui/tlc/source/Cargo.toml index 56805cd571..ad0e98a1a2 100644 --- a/local/recipes/tui/tlc/source/Cargo.toml +++ b/local/recipes/tui/tlc/source/Cargo.toml @@ -14,6 +14,14 @@ path = "src/main.rs" name = "tlc-pty-login" path = "src/bin/tlc_pty_login.rs" +[[bin]] +name = "tlcedit" +path = "src/bin/tlcedit.rs" + +[[bin]] +name = "tlcview" +path = "src/bin/tlcview.rs" + [lib] name = "tlc" path = "src/lib.rs" diff --git a/local/recipes/tui/tlc/source/src/app.rs b/local/recipes/tui/tlc/source/src/app.rs index f279411c4f..df65a82612 100644 --- a/local/recipes/tui/tlc/source/src/app.rs +++ b/local/recipes/tui/tlc/source/src/app.rs @@ -19,7 +19,7 @@ use termion::input::TermReadEventsAndRaw; use crate::config::Config; use crate::filemanager::FileManager; use crate::key::Key; -use crate::keymap::{Cmd, default_keymap}; +use crate::keymap::default_keymap; use crate::terminal::event::translate_key; use crate::terminal::subshell::ShellManager; use crate::terminal::Tui; @@ -79,7 +79,14 @@ impl Application { let tk = match event { TermEvent::Key(k) => k, TermEvent::Mouse(_) => continue, - TermEvent::Unsupported(_) => continue, + TermEvent::Unsupported(bytes) => { + if let Some(k) = crate::terminal::event::parse_unsupported_fkey(&bytes) { + k + } else { + log::warn!("unsupported event: {bytes:?}"); + continue; + } + } }; let key = translate_key(tk); @@ -99,9 +106,9 @@ impl Application { fm.cmdline.deactivate(); } else if fm.search.is_some() { fm.handle_search_key(Key::ESCAPE); - } else { - let _ = fm.dispatch(Cmd::Quit); } + // MC parity: ESC on the main screen does nothing. + // Only F10 quits the application. render(&mut tui, &mut fm)?; continue; } @@ -294,7 +301,10 @@ fn run_external( match action { ExternalAction::Subshell(cwd) => shell_manager.toggle_subshell(&cwd)?, - ExternalAction::Command { cmd, cwd } => shell_manager.run_command(&cmd, &cwd)?, + ExternalAction::Command { cmd, cwd } => { + println!(); + shell_manager.run_command(&cmd, &cwd)?; + } } Tui::new() @@ -516,7 +526,6 @@ mod tests { let cfg = Config::default(); let mut fm = FileManager::new(&dir, &cfg).unwrap(); - fm.active_panel_mut().cursor_down(); apply_movement( &mut fm, @@ -538,7 +547,6 @@ mod tests { let cfg = Config::default(); let mut fm = FileManager::new(&dir, &cfg).unwrap(); - fm.active_panel_mut().cursor_down(); apply_movement( &mut fm, diff --git a/local/recipes/tui/tlc/source/src/bin/tlcedit.rs b/local/recipes/tui/tlc/source/src/bin/tlcedit.rs new file mode 100644 index 0000000000..813c6d9a44 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/bin/tlcedit.rs @@ -0,0 +1,28 @@ +//! tlcedit — standalone editor entry point. +//! +//! Opens a file in the TLC built-in editor, same as `tlc edit `. + +use std::process::ExitCode; + +#[derive(Debug, clap::Parser)] +#[command(name = "tlcedit", version, about = "Twilight Commander — built-in editor")] +struct Cli { + /// File to edit. + file: String, + + /// Line number to jump to on open. + #[arg(long, value_name = "N")] + line: Option, +} + +fn main() -> ExitCode { + let cli = ::parse(); + + match tlc::editor::open_file(&cli.file, cli.line) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("tlcedit: cannot open {file}: {e}", file = cli.file); + ExitCode::FAILURE + } + } +} diff --git a/local/recipes/tui/tlc/source/src/bin/tlcview.rs b/local/recipes/tui/tlc/source/src/bin/tlcview.rs new file mode 100644 index 0000000000..511001c0e3 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/bin/tlcview.rs @@ -0,0 +1,24 @@ +//! tlcview — standalone viewer entry point. +//! +//! Opens a file in the TLC built-in viewer, same as `tlc view `. + +use std::process::ExitCode; + +#[derive(Debug, clap::Parser)] +#[command(name = "tlcview", version, about = "Twilight Commander — built-in viewer")] +struct Cli { + /// File to view. + file: String, +} + +fn main() -> ExitCode { + let cli = ::parse(); + + match tlc::viewer::open_file(&cli.file) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("tlcview: cannot view {file}: {e}", file = cli.file); + ExitCode::FAILURE + } + } +} diff --git a/local/recipes/tui/tlc/source/src/editor/cursor.rs b/local/recipes/tui/tlc/source/src/editor/cursor.rs index ea7563fdcc..70f923eecf 100644 --- a/local/recipes/tui/tlc/source/src/editor/cursor.rs +++ b/local/recipes/tui/tlc/source/src/editor/cursor.rs @@ -145,82 +145,88 @@ impl Cursor { // --- movement --- /// Move left by one byte. - pub fn move_left(&mut self, _buf: &Buffer) { + pub fn move_left(&mut self, buf: &mut Buffer) { if self.position > 0 { self.position -= 1; self.visual_column = self.visual_column.saturating_sub(1); + buf.set_cursor(self.position); } } /// Move right by one byte. - pub fn move_right(&mut self, buf: &Buffer) { + pub fn move_right(&mut self, buf: &mut Buffer) { if self.position < buf.len() { self.position += 1; - // Visual column grows by the displayed width of the byte - // we stepped over. Tabs count as 1 column for the simple - // model — full tab-stop math is left to the renderer. self.visual_column += 1; + buf.set_cursor(self.position); } } /// Move up one line, preserving the visual column. - pub fn move_up(&mut self, buf: &Buffer) { + pub fn move_up(&mut self, buf: &mut Buffer) { let line = Self::line_of(self.position, buf); if line == 0 { - // Already on the first line — snap to start of buffer. self.position = 0; self.visual_column = 0; + buf.set_cursor(0); return; } let prev_line_start = buf.line_offset(line - 1); let prev_line_len = buf.line_length(line - 1); let target = prev_line_start + self.visual_column.min(prev_line_len); self.position = target; - // visual_column unchanged. + buf.set_cursor(target); } /// Move down one line, preserving the visual column. - pub fn move_down(&mut self, buf: &Buffer) { + pub fn move_down(&mut self, buf: &mut Buffer) { let line = Self::line_of(self.position, buf); let total_lines = buf.line_count(); if line + 1 >= total_lines { - // Already on the last line — snap to end of buffer. self.position = buf.len(); self.visual_column = 0; + buf.set_cursor(self.position); return; } let next_line_start = buf.line_offset(line + 1); let next_line_len = buf.line_length(line + 1); let target = next_line_start + self.visual_column.min(next_line_len); self.position = target; + buf.set_cursor(target); } /// Move to the start of the current line. - pub fn move_home(&mut self, buf: &Buffer) { + pub fn move_home(&mut self, buf: &mut Buffer) { let line = Self::line_of(self.position, buf); - self.position = buf.line_offset(line); + let target = buf.line_offset(line); + self.position = target; self.visual_column = 0; + buf.set_cursor(target); } /// Move to the end of the current line. - pub fn move_end(&mut self, buf: &Buffer) { + pub fn move_end(&mut self, buf: &mut Buffer) { let line = Self::line_of(self.position, buf); - self.position = buf.line_offset(line) + buf.line_length(line); + let target = buf.line_offset(line) + buf.line_length(line); + self.position = target; self.visual_column = buf.line_length(line); + buf.set_cursor(target); } /// Move to byte offset 0. - pub fn move_doc_start(&mut self) { + pub fn move_doc_start(&mut self, buf: &mut Buffer) { self.position = 0; self.visual_column = 0; self.anchor = None; + buf.set_cursor(0); } /// Move to the end of the buffer. - pub fn move_doc_end(&mut self, buf: &Buffer) { + pub fn move_doc_end(&mut self, buf: &mut Buffer) { self.position = buf.len(); self.visual_column = 0; self.anchor = None; + buf.set_cursor(self.position); } /// Move forward by one word. A word is a maximal run of bytes in @@ -229,34 +235,31 @@ impl Cursor { /// Lands at the byte AFTER the current word's last byte. If the /// cursor is already on whitespace, the motion consumes the /// whitespace run. - pub fn move_word_forward(&mut self, buf: &Buffer) { + pub fn move_word_forward(&mut self, buf: &mut Buffer) { let n = buf.len(); let mut p = self.position; if p >= n { return; } let cls = classify(buf.byte_at(p).unwrap_or(b' ')); - // Consume the current run. while p < n && classify(buf.byte_at(p).unwrap_or(b' ')) == cls { p += 1; } self.position = p; self.visual_column = Self::visual_column_at(p, buf); + buf.set_cursor(p); } /// Move backward by one word. - pub fn move_word_backward(&mut self, buf: &Buffer) { + pub fn move_word_backward(&mut self, buf: &mut Buffer) { let mut p = self.position; if p == 0 { return; } - // Step back one byte to look at the previous character. p -= 1; - // Skip whitespace backwards. while p > 0 && buf.byte_at(p).unwrap_or(b' ') == b' ' { p -= 1; } - // Skip the current word class backwards. if p > 0 { let cls = classify(buf.byte_at(p).unwrap_or(b' ')); while p > 0 && classify(buf.byte_at(p - 1).unwrap_or(b' ')) == cls { @@ -265,6 +268,7 @@ impl Cursor { } self.position = p; self.visual_column = Self::visual_column_at(p, buf); + buf.set_cursor(p); } /// Move forward to the start of the next paragraph. A paragraph @@ -274,7 +278,7 @@ impl Cursor { /// lines that follow, landing on the first line of the next /// non-blank run. If no such paragraph exists, the cursor moves /// to the end of the buffer. - pub fn move_para_forward(&mut self, buf: &Buffer) { + pub fn move_para_forward(&mut self, buf: &mut Buffer) { let n = buf.len(); let bytes = buf.to_bytes(); let mut p = self.position; @@ -296,6 +300,7 @@ impl Cursor { if p >= n { self.position = n; self.visual_column = 0; + buf.set_cursor(n); return; } // Find the end of the current line. @@ -311,6 +316,7 @@ impl Cursor { if past_blank { self.position = p; self.visual_column = 0; + buf.set_cursor(p); return; } // Same paragraph; advance past this line. @@ -322,6 +328,7 @@ impl Cursor { } else { self.position = n; self.visual_column = 0; + buf.set_cursor(n); return; } } @@ -336,7 +343,7 @@ impl Cursor { /// is the "start of the previous paragraph" — i.e., the line /// just above the cursor, when that line has a blank line or /// buffer start above it. - pub fn move_para_backward(&mut self, buf: &Buffer) { + pub fn move_para_backward(&mut self, buf: &mut Buffer) { let n = buf.len(); let bytes = buf.to_bytes(); let mut p = self.position; @@ -368,10 +375,9 @@ impl Cursor { loop { // Step back to the line above. if p == 0 { - // Already at the start of the buffer — no previous - // paragraph. self.position = 0; self.visual_column = 0; + buf.set_cursor(0); return; } // p is at a line start. The line above ends at p-1 (which @@ -413,10 +419,9 @@ impl Cursor { .iter() .all(|&b| b == b' ' || b == b'\t'); if above_start == 0 || above_blank { - // The line above prev_line_start is blank (or - // absent), so prev_line_start is a paragraph start. self.position = prev_line_start; self.visual_column = 0; + buf.set_cursor(prev_line_start); return; } // Otherwise, prev_line_start is part of the same @@ -426,7 +431,7 @@ impl Cursor { } /// Page up: move up by `page_lines` lines. - pub fn move_page_up(&mut self, buf: &Buffer, page_lines: usize) { + pub fn move_page_up(&mut self, buf: &mut Buffer, page_lines: usize) { for _ in 0..page_lines { if self.position == 0 { break; @@ -436,7 +441,7 @@ impl Cursor { } /// Page down: move down by `page_lines` lines. - pub fn move_page_down(&mut self, buf: &Buffer, page_lines: usize) { + pub fn move_page_down(&mut self, buf: &mut Buffer, page_lines: usize) { for _ in 0..page_lines { if self.position >= buf.len() { break; @@ -451,37 +456,37 @@ impl Cursor { // implicit: any new movement (non-select) drops the anchor. /// Shift+Left: extend selection one byte to the left. - pub fn select_left(&mut self, buf: &Buffer) { + pub fn select_left(&mut self, buf: &mut Buffer) { self.start_selection(); self.move_left(buf); } /// Shift+Right: extend selection one byte to the right. - pub fn select_right(&mut self, buf: &Buffer) { + pub fn select_right(&mut self, buf: &mut Buffer) { self.start_selection(); self.move_right(buf); } /// Shift+Home: extend selection to start of current line. - pub fn select_to_home(&mut self, buf: &Buffer) { + pub fn select_to_home(&mut self, buf: &mut Buffer) { self.start_selection(); self.move_home(buf); } /// Shift+End: extend selection to end of current line. - pub fn select_to_end(&mut self, buf: &Buffer) { + pub fn select_to_end(&mut self, buf: &mut Buffer) { self.start_selection(); self.move_end(buf); } /// Shift+Ctrl-Left: select the previous word. - pub fn select_word(&mut self, buf: &Buffer) { + pub fn select_word(&mut self, buf: &mut Buffer) { self.start_selection(); self.move_word_backward(buf); } /// Select the entire current line (from line start to line end). - pub fn select_line(&mut self, buf: &Buffer) { + pub fn select_line(&mut self, buf: &mut Buffer) { let line = Self::line_of(self.position, buf); self.anchor = Some(buf.line_offset(line)); self.move_end(buf); @@ -556,34 +561,34 @@ mod tests { #[test] fn move_left_right_clamps() { - let b = buf("abc"); + let mut b = buf("abc"); let mut c = Cursor::new(); - c.move_left(&b); + c.move_left(&mut b); assert_eq!(c.position(), 0); - c.move_right(&b); - c.move_right(&b); - c.move_right(&b); - c.move_right(&b); // past end + c.move_right(&mut b); + c.move_right(&mut b); + c.move_right(&mut b); + c.move_right(&mut b); // past end assert_eq!(c.position(), 3); } #[test] fn move_home_and_end() { - let b = buf("hello\nworld\n"); + let mut b = buf("hello\nworld\n"); let mut c = Cursor::new(); c.set_position(7, &b); // on 'o' of world - c.move_home(&b); + c.move_home(&mut b); assert_eq!(c.position(), 6); - c.move_end(&b); + c.move_end(&mut b); assert_eq!(c.position(), 11); } #[test] fn move_up_preserves_visual_column() { - let b = buf("short\nlonger line\n"); + let mut b = buf("short\nlonger line\n"); let mut c = Cursor::new(); c.set_position(13, &b); // 'g' of "longer" - c.move_up(&b); + c.move_up(&mut b); // We're now on line 0 ("short"), visual column 7, but the // line is only 5 chars long, so we snap to end-of-line. assert_eq!(c.position(), 5); @@ -591,10 +596,10 @@ mod tests { #[test] fn move_up_keeps_column_on_long_enough_line() { - let b = buf("abcdef\nxyz\n"); + let mut b = buf("abcdef\nxyz\n"); let mut c = Cursor::new(); c.set_position(3, &b); // 'd' on line 0 (col 3) - c.move_down(&b); + c.move_down(&mut b); // Line 1 is "xyz" (3 chars), visual column 3 fits exactly. // target = line_offset(1) + 3 = 7 + 3 = 10. assert_eq!(c.position(), 10); @@ -602,46 +607,46 @@ mod tests { #[test] fn move_word_forward_basic() { - let b = buf("foo bar baz"); + let mut b = buf("foo bar baz"); let mut c = Cursor::new(); // From position 0 ("f"), consume the alpha run "foo" and // land at position 3 (just past 'o', before the space). - c.move_word_forward(&b); + c.move_word_forward(&mut b); assert_eq!(c.position(), 3); // From position 3 (space), consume the space run. - c.move_word_forward(&b); + c.move_word_forward(&mut b); assert_eq!(c.position(), 4); // From position 4 ("b"), consume "bar". - c.move_word_forward(&b); + c.move_word_forward(&mut b); assert_eq!(c.position(), 7); // From position 7 (space), consume the space. - c.move_word_forward(&b); + c.move_word_forward(&mut b); assert_eq!(c.position(), 8); // From position 8 ("b"), consume "baz" to end of buffer. - c.move_word_forward(&b); + c.move_word_forward(&mut b); assert_eq!(c.position(), 11); } #[test] fn move_word_backward_basic() { - let b = buf("foo bar baz"); + let mut b = buf("foo bar baz"); let mut c = Cursor::new(); c.set_position(11, &b); - c.move_word_backward(&b); + c.move_word_backward(&mut b); assert_eq!(c.position(), 8); - c.move_word_backward(&b); + c.move_word_backward(&mut b); assert_eq!(c.position(), 4); - c.move_word_backward(&b); + c.move_word_backward(&mut b); assert_eq!(c.position(), 0); } #[test] fn selection_set_and_clear() { - let b = buf("hello world"); + let mut b = buf("hello world"); let mut c = Cursor::new(); - c.select_right(&b); - c.select_right(&b); - c.select_right(&b); + c.select_right(&mut b); + c.select_right(&mut b); + c.select_right(&mut b); assert_eq!(c.position(), 3); assert!(c.has_selection()); let sel = c.selection().unwrap(); @@ -652,7 +657,7 @@ mod tests { #[test] fn selected_text_returns_slice() { - let b = buf("hello world"); + let mut b = buf("hello world"); let mut c = Cursor::new(); c.set_position(5, &b); c.anchor = Some(0); @@ -662,7 +667,7 @@ mod tests { #[test] fn delete_selection_clears_text() { - let b = buf("hello world"); + let mut b = buf("hello world"); let mut c = Cursor::new(); c.set_position(5, &b); c.anchor = Some(0); @@ -685,9 +690,9 @@ mod tests { #[test] fn move_para_forward_skips_to_next_blank() { - let b = buf("line1\nline2\n\nline3\n"); + let mut b = buf("line1\nline2\n\nline3\n"); let mut c = Cursor::new(); - c.move_para_forward(&b); + c.move_para_forward(&mut b); // After the paragraph motion we should be on line 3 ("line3"). assert!(c.position() >= 12); assert!(b.as_string()[c.position()..].starts_with("line3")); @@ -695,40 +700,40 @@ mod tests { #[test] fn move_para_backward() { - let b = buf("line1\n\nline2\nline3"); + let mut b = buf("line1\n\nline2\nline3"); let mut c = Cursor::new(); c.set_position(b.len(), &b); - c.move_para_backward(&b); + c.move_para_backward(&mut b); // We should be at the start of "line2". assert_eq!(b.as_string()[c.position()..].starts_with("line2"), true); } #[test] fn page_up_down() { - let b = buf("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n"); + let mut b = buf("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n"); let mut c = Cursor::new(); c.set_position(b.len(), &b); - c.move_page_up(&b, 3); + c.move_page_up(&mut b, 3); // 10 lines (a..j + trailing). Going up 3 from line 10 → line 7. assert!(c.position() < b.len()); let pos1 = c.position(); - c.move_page_down(&b, 3); + c.move_page_down(&mut b, 3); assert!(c.position() > pos1); } #[test] fn select_line_covers_full_line() { - let b = buf("first\nsecond line\nthird"); + let mut b = buf("first\nsecond line\nthird"); let mut c = Cursor::new(); c.set_position(8, &b); // inside "second line" - c.select_line(&b); + c.select_line(&mut b); let (s, e) = c.selection().unwrap(); assert_eq!(&b.as_string()[s..e], "second line"); } #[test] fn set_position_clamps() { - let b = buf("abc"); + let mut b = buf("abc"); let mut c = Cursor::new(); c.set_position(100, &b); assert_eq!(c.position(), 3); @@ -736,13 +741,13 @@ mod tests { #[test] fn move_doc_start_and_end() { - let b = buf("hello\nworld"); + let mut b = buf("hello\nworld"); let mut c = Cursor::new(); c.set_position(5, &b); - c.move_doc_start(); + c.move_doc_start(&mut b); assert_eq!(c.position(), 0); assert!(!c.has_selection()); - c.move_doc_end(&b); + c.move_doc_end(&mut b); assert_eq!(c.position(), b.len()); assert!(!c.has_selection()); } diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 9379b23c83..4acc6ed860 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -117,6 +117,13 @@ pub struct Editor { complete_prefix_len: usize, /// Named editor bookmarks. bookmarks: BookmarkSet, + /// Syntax highlighter (when syntect feature is enabled and the + /// file extension is recognized). + #[cfg(feature = "syntect")] + highlighter: Option, + /// Track scroll position to reset highlighter state on scroll. + #[cfg(feature = "syntect")] + last_render_top: usize, } impl Editor { @@ -129,6 +136,8 @@ impl Editor { let title = format!(" {} {} ", crate::locale::t("dialog_title_editor"), path.display()); let mut history = History::with_capacity(20); history.push(&path); + #[cfg(feature = "syntect")] + let hl = crate::editor::syntax::Highlighter::new(&path); Self { buffer, cursor: Cursor::new(), @@ -145,6 +154,10 @@ impl Editor { completer: Completer::new(), complete_prefix_len: 0, bookmarks: BookmarkSet::new(), + #[cfg(feature = "syntect")] + highlighter: hl, + #[cfg(feature = "syntect")] + last_render_top: 0, } } @@ -167,6 +180,10 @@ impl Editor { completer: Completer::new(), complete_prefix_len: 0, bookmarks: BookmarkSet::new(), + #[cfg(feature = "syntect")] + highlighter: None, + #[cfg(feature = "syntect")] + last_render_top: 0, } } @@ -553,43 +570,43 @@ impl Editor { } match key { Key { code: 0x2190, mods } if mods.is_empty() => { - self.cursor.move_left(&self.buffer); + self.cursor.move_left(&mut self.buffer); EditorResult::Running } Key { code: 0x2190, mods } if mods.contains(crate::key::Modifiers::CTRL) => { - self.cursor.move_word_backward(&self.buffer); + self.cursor.move_word_backward(&mut self.buffer); EditorResult::Running } Key { code: 0x2192, mods } if mods.is_empty() => { - self.cursor.move_right(&self.buffer); + self.cursor.move_right(&mut self.buffer); EditorResult::Running } Key { code: 0x2192, mods } if mods.contains(crate::key::Modifiers::CTRL) => { - self.cursor.move_word_forward(&self.buffer); + self.cursor.move_word_forward(&mut self.buffer); EditorResult::Running } Key { code: 0x2191, .. } => { - self.cursor.move_up(&self.buffer); + self.cursor.move_up(&mut self.buffer); EditorResult::Running } Key { code: 0x2193, .. } => { - self.cursor.move_down(&self.buffer); + self.cursor.move_down(&mut self.buffer); EditorResult::Running } Key { code: 0x21A1, .. } => { - self.cursor.move_home(&self.buffer); + self.cursor.move_home(&mut self.buffer); EditorResult::Running } Key { code: 0x21A0, .. } => { - self.cursor.move_end(&self.buffer); + self.cursor.move_end(&mut self.buffer); EditorResult::Running } Key { code: 0x21DE, .. } => { - self.cursor.move_page_up(&self.buffer, 20); + self.cursor.move_page_up(&mut self.buffer, 20); EditorResult::Running } Key { code: 0x21DF, .. } => { - self.cursor.move_page_down(&self.buffer, 20); + self.cursor.move_page_down(&mut self.buffer, 20); EditorResult::Running } // Printable ASCII. @@ -845,6 +862,34 @@ impl Editor { // (e.g. commit_prompt's GotoLine/GotoCol) changed the // buffer cursor without calling cursor.set_position(). self.cursor.set_position(self.buffer.cursor(), &self.buffer); + // Syntect is stateful: when the user scrolls the viewport + // we have to rebuild the parser state by replaying every + // line from the top of the file down to the new first + // visible line. Without this, the highlighter's notion of + // "what context are we in?" (block comments, string + // literals, etc.) drifts away from reality as the user + // scrolls, and the colors stop matching the source. + #[cfg(feature = "syntect")] + { + let current_top = self.view.top_line(); + if current_top != self.last_render_top { + self.last_render_top = current_top; + if let Some(ref path) = self.path { + self.highlighter = + crate::editor::syntax::Highlighter::new(path); + } + if let Some(ref mut h) = self.highlighter { + let full_text = self.buffer.as_string(); + for i in 0..current_top { + let off = self.buffer.line_offset(i); + let len = self.buffer.line_length(i); + let end = (off + len).min(full_text.len()); + let line_text = full_text.get(off..end).unwrap_or(""); + let _ = h.highlight_line(line_text); + } + } + } + } let editor_default = mc_skin::color_pair(theme.name, "editor", "_default_"); let editor_marked = mc_skin::color_pair(theme.name, "editor", "editmarked"); let editor_linestate = mc_skin::color_pair(theme.name, "editor", "editlinestate"); @@ -960,6 +1005,21 @@ impl Editor { .fg(marked_fg) .bg(marked_bg); let mut spans: Vec = Vec::new(); + #[cfg(feature = "syntect")] + { + if let Some(ref mut h) = self.highlighter { + let highlighted = h.highlight_line(line_text); + spans = split_spans_for_selection( + highlighted, + rs, + re, + body_bg, + marked_bg, + ); + body_lines.push(Line::from(spans)); + continue; + } + } if rs > 0 { if let Some(b) = line_text.get(..rs) { push_rendered_text( @@ -1002,15 +1062,23 @@ impl Editor { } } let mut spans = Vec::new(); - push_rendered_text( - &mut spans, - line_text, - base_style, - whitespace_fg, - whitespace_bg, - nonprintable_fg, - nonprintable_bg, - ); + #[cfg(feature = "syntect")] + { + if let Some(ref mut h) = self.highlighter { + spans = h.highlight_line(line_text); + } + } + if spans.is_empty() { + push_rendered_text( + &mut spans, + line_text, + base_style, + whitespace_fg, + whitespace_bg, + nonprintable_fg, + nonprintable_bg, + ); + } body_lines.push(Line::from(spans)); } frame.render_widget(Paragraph::new(body_lines), chunks[1]); @@ -1342,6 +1410,82 @@ fn push_rendered_text<'a>( } } +/// Split a sequence of syntax-highlighted spans for a single line +/// so that bytes falling inside the selection range `[rs, re)` +/// receive the selection background, while bytes outside keep the +/// body background. Foreground colors and font modifiers from the +/// highlighted spans are preserved everywhere — the selection +/// only changes the background. +#[cfg(feature = "syntect")] +fn split_spans_for_selection( + highlighted: Vec>, + rs: usize, + re: usize, + base_bg: Color, + marked_bg: Color, +) -> Vec> { + let mut out: Vec> = Vec::with_capacity(highlighted.len() + 2); + let mut pos: usize = 0; + for span in highlighted { + let span_start = pos; + let span_end = pos + span.content.len(); + pos = span_end; + // Three candidate cut points in line-relative byte + // coordinates: the selection's start and end, clamped to + // this span. We then take sub-slices at those cuts. + let cut_a = rs.min(span_end).max(span_start); + let cut_b = re.min(span_end).max(span_start); + // Always emit up to three pieces: [span_start..cut_a], + // [cut_a..cut_b], [cut_b..span_end]. Each piece is + // classified by whether its midpoint falls inside the + // selection range. + let pieces: [(usize, usize, bool); 3] = [ + (span_start, cut_a, false), + (cut_a, cut_b, true), + (cut_b, span_end, false), + ]; + for (from, to, in_sel) in pieces { + if from >= to { + continue; + } + // Translate line-byte coordinates to local char-boundary + // coordinates inside `span.content`. UTF-8 forces us to + // walk `char_indices`; clamping `from`/`to` to the + // nearest valid char boundary guarantees a safe slice. + let local_from = (from - span_start).min(span.content.len()); + let local_to = (to - span_start).min(span.content.len()); + let lo = round_up_to_char_boundary(&span.content, local_from); + let hi = round_up_to_char_boundary(&span.content, local_to); + if lo >= hi { + continue; + } + let piece = &span.content[lo..hi]; + let bg = if in_sel { marked_bg } else { base_bg }; + let mut s = span.style; + s = s.bg(bg); + out.push(Span::styled(piece.to_string(), s)); + } + } + out +} + +/// Round `idx` up to the next UTF-8 char boundary in `s`. If `idx` +/// is already at a boundary (or is `>= s.len()`), returns `idx` +/// unchanged. If `idx` falls mid-codepoint, advances to the END of +/// that codepoint so that any multi-byte character straddling the +/// cut is included entirely on the cut's "greater" side. +#[cfg(feature = "syntect")] +fn round_up_to_char_boundary(s: &str, idx: usize) -> usize { + if idx >= s.len() { + return s.len(); + } + let mut i = idx; + while i < s.len() && !s.is_char_boundary(i) { + i += 1; + } + i +} + #[cfg(test)] mod tests { use super::*; @@ -2125,4 +2269,227 @@ mod tests { assert!(!is_completion_word_char('.')); assert!(!is_completion_word_char('-')); } + + // ----------------------------------------------------------------- + // Syntax highlighting render-path tests + // ----------------------------------------------------------------- + + /// Render an empty (untitled) buffer: with no path, the + /// highlighter is `None`, so the renderer must fall back to + /// monochrome. All non-whitespace body cells share the theme + /// foreground (whitespace visualization uses its own color). + #[test] + fn render_without_syntect_falls_back_to_monochrome() { + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let mut e = make_empty(); + // Use a buffer with no whitespace so every visible cell + // uses `base_style` (no whitespace-glyph color override). + e.insert_str("helloworld"); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|frame| { + let area = frame.area(); + e.render(frame, area, &theme); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut fg_colors = std::collections::HashSet::new(); + for x in 5..15 { + if let Some(cell) = buffer.cell((x, 1)) { + fg_colors.insert(format!("{:?}", cell.fg)); + } + } + assert_eq!( + fg_colors.len(), + 1, + "monochrome render must produce a single foreground color across the body cells, got {fg_colors:?}" + ); + // The cursor is at the end of the inserted string, so the + // rendered line is the cursor line. The cursor line uses + // `linestate_fg` (theme.cursor_fg) — verify it. + let only = fg_colors.iter().next().expect("at least one color"); + assert_eq!(only, &format!("{:?}", theme.cursor_fg)); + } + + /// Open a Rust file and render it: the rendered body cells must + /// contain at least two distinct foreground colors (the + /// keyword `fn` is one color, identifiers/literals are + /// another), proving that the syntect spans flow through to + /// the framebuffer. + #[cfg(feature = "syntect")] + #[test] + fn render_with_syntect_produces_colored_spans() { + let dir = std::env::temp_dir().join("tlc-editor-syntect-test"); + let _ = fs::create_dir_all(&dir); + let p = dir.join("snippet.rs"); + fs::write(&p, "fn main() {}\n").unwrap(); + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).unwrap(); + let mut e = Editor::open(&p); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|frame| { + let area = frame.area(); + e.render(frame, area, &theme); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let mut fg_colors = std::collections::HashSet::new(); + for x in 5..17 { + if let Some(cell) = buffer.cell((x, 1)) { + if cell.symbol() != " " { + fg_colors.insert(format!("{:?}", cell.fg)); + } + } + } + assert!( + fg_colors.len() >= 2, + "syntect render of Rust source must produce at least 2 distinct foreground colors, got {fg_colors:?}" + ); + let _ = fs::remove_dir_all(&dir); + } + + /// Direct unit test of `split_spans_for_selection`: + /// 1. A span entirely outside the selection keeps body bg. + /// 2. A span entirely inside gets marked bg. + /// 3. A span straddling the start gets split. + /// 4. A span straddling the end gets split. + /// 5. A span straddling both gets three pieces. + /// 6. UTF-8 multibyte chars never split mid-codepoint. + #[cfg(feature = "syntect")] + #[test] + fn split_spans_for_selection_classifies_pieces_by_range() { + use ratatui::style::Color as RC; + let red = RC::Rgb(255, 0, 0); + let green = RC::Rgb(0, 255, 0); + let base = RC::Rgb(0, 0, 0); + let marked = RC::Rgb(255, 255, 0); + + // Case 1: entirely before the selection. + let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap(); + let before = h.highlight_line("alpha"); + let out = split_spans_for_selection(before, 100, 200, base, marked); + assert_eq!(out.len(), 1); + assert_eq!(out[0].content, "alpha"); + assert_eq!(out[0].style.bg, Some(base)); + + // Case 2: entirely inside the selection. + let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap(); + let inside = h.highlight_line("xyz"); + let out = split_spans_for_selection(inside, 0, 3, base, marked); + let total: String = out.iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(total, "xyz"); + for s in &out { + assert_eq!(s.style.bg, Some(marked)); + } + + // Case 3: the line "fn main" should split at byte 2 (between + // "fn" and " main"). Verify that every byte before byte 2 + // carries base bg and every byte from 2 onward carries + // marked bg. + let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap(); + let line = h.highlight_line("fn main"); + let out = split_spans_for_selection(line, 2, 7, base, marked); + let mut reconstructed = String::new(); + let mut seen_bg = Vec::new(); + for s in &out { + reconstructed.push_str(&s.content); + for _ in 0..s.content.len() { + seen_bg.push(format!("{:?}", s.style.bg)); + } + } + assert_eq!(reconstructed, "fn main"); + // First 2 bytes (= "fn") base, last 5 bytes (= " main") marked. + assert_eq!(seen_bg[0], format!("{:?}", Some(base))); + assert_eq!(seen_bg[1], format!("{:?}", Some(base))); + for bg_str in seen_bg.iter().skip(2) { + assert_eq!(bg_str, &format!("{:?}", Some(marked))); + } + + // Case 4: UTF-8 multi-byte. "é" is 2 bytes (0xC3 0xA9). + // A highlight of "é" with selection [0, 1) must produce a + // piece with bg=marked that contains the full "é" char + // (no mid-codepoint split). + let spans = vec![Span::styled("é".to_string(), Style::default().fg(red).bg(green))]; + let out = split_spans_for_selection(spans, 0, 1, base, marked); + assert_eq!(out.len(), 1, "expected one piece, got {}", out.len()); + assert_eq!(out[0].content, "é"); + assert_eq!(out[0].style.bg, Some(marked)); + + // Case 5: a selection that excludes the entire string. + let spans = vec![Span::styled("abc".to_string(), Style::default().fg(red).bg(green))]; + let out = split_spans_for_selection(spans, 100, 200, base, marked); + assert_eq!(out.len(), 1); + assert_eq!(out[0].content, "abc"); + assert_eq!(out[0].style.bg, Some(base)); + } + + /// Direct unit test of `round_up_to_char_boundary`. + #[cfg(feature = "syntect")] + #[test] + fn round_up_to_char_boundary_clamps_to_utf8_boundary() { + // "é" is 2 bytes (0xC3 0xA9), so at byte index 1 we are + // mid-codepoint and must round up to the end of that + // codepoint (byte 2). + assert_eq!(round_up_to_char_boundary("é", 0), 0); + assert_eq!(round_up_to_char_boundary("é", 1), 2); + assert_eq!(round_up_to_char_boundary("é", 2), 2); + assert_eq!(round_up_to_char_boundary("é", 100), 2); + // All-ASCII: every byte index is a char boundary, so the + // function is the identity. + assert_eq!(round_up_to_char_boundary("abc", 0), 0); + assert_eq!(round_up_to_char_boundary("abc", 2), 2); + assert_eq!(round_up_to_char_boundary("abc", 3), 3); + } + + /// Open a Rust file, set a selection on the rendered line, and + /// verify that cells inside the selection range carry the + /// selection background. + #[cfg(feature = "syntect")] + #[test] + fn render_with_syntect_and_selection_applies_marked_bg() { + use crate::terminal::mc_skin; + let dir = std::env::temp_dir().join("tlc-editor-syntect-sel-test"); + let _ = fs::create_dir_all(&dir); + let p = dir.join("snippet.rs"); + fs::write(&p, "fn main() {}\n").unwrap(); + let mut e = Editor::open(&p); + // Place selection on first 2 chars of the line: "fn". + // The cursor API: position the cursor, then start the + // selection (which anchors at the current position), then + // move the cursor to the other end. + e.cursor.set_position(0, &e.buffer); + e.cursor.start_selection(); + e.cursor.set_position(2, &e.buffer); + assert_eq!(e.cursor.selection(), Some((0, 2))); + let theme = crate::terminal::color::DEFAULT_THEME; + let backend = TestBackend::new(40, 6); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + e.render(frame, area, &theme); + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + let marked_pair = mc_skin::color_pair(theme.name, "editor", "editmarked") + .unwrap_or(mc_skin::ColorPair { + fg: theme.marked_fg, + bg: theme.marked_bg, + }); + // Body starts at column `gutter_w` + 1. For "fn main() {}\n" +// the buffer has 2 lines, so `line_count = 2`, gutter_w = max(1+1, 4) = 4. +// Block has Borders::ALL, so `inner.x = area.x + 1` and the gutter +// takes cols 1..=4 (width 4). Body starts at x = 5. Cells at +// (5, 1) and (6, 1) hold "f" and "n" and should carry marked bg. + let f_cell = buffer.cell((5, 1)).expect("'f' cell"); + let n_cell = buffer.cell((6, 1)).expect("'n' cell"); + assert_eq!(f_cell.symbol(), "f"); + assert_eq!(n_cell.symbol(), "n"); + assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg"); + assert_eq!(n_cell.bg, marked_pair.bg, "selected 'n' cell bg"); + let _ = fs::remove_dir_all(&dir); + } } + diff --git a/local/recipes/tui/tlc/source/src/filemanager/confirm_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/confirm_dialog.rs new file mode 100644 index 0000000000..d8eed5bada --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/confirm_dialog.rs @@ -0,0 +1,301 @@ +//! Confirmation toggles dialog (delete/overwrite/execute/exit). + +use crate::key::Key; +use crate::terminal::color::Theme; +use ratatui::layout::{Alignment, Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; +use ratatui::Frame; + +const TOGGLE_LABELS: &[(&str, &str)] = &[ + ("Confirm delete", "Ask before deleting files"), + ("Confirm overwrite", "Ask before overwriting files"), + ("Confirm execute", "Ask before executing commands"), + ("Confirm exit", "Ask before quitting TLC"), +]; + +const WIDTH: u16 = 52; +const HEIGHT: u16 = 4 + TOGGLE_LABELS.len() as u16 + 3; + +/// Confirmation toggle settings. +#[derive(Debug, Clone)] +pub struct ConfirmSettings { + /// Whether to prompt before deleting files. + pub confirm_delete: bool, + /// Whether to prompt before overwriting files. + pub confirm_overwrite: bool, + /// Whether to prompt before executing commands. + pub confirm_execute: bool, + /// Whether to prompt before quitting TLC. + pub confirm_exit: bool, +} + +impl ConfirmSettings { + fn at(&self, idx: usize) -> bool { + match idx { + 0 => self.confirm_delete, + 1 => self.confirm_overwrite, + 2 => self.confirm_execute, + 3 => self.confirm_exit, + _ => false, + } + } + + fn set(&mut self, idx: usize, val: bool) { + match idx { + 0 => self.confirm_delete = val, + 1 => self.confirm_overwrite = val, + 2 => self.confirm_execute = val, + 3 => self.confirm_exit = val, + _ => {} + } + } +} + +/// Result of pressing a key in the confirmation dialog. +#[derive(Debug)] +pub enum ConfirmResult { + /// User pressed Enter — apply settings. + Confirm(ConfirmSettings), + /// User pressed Esc / F10 — discard changes. + Cancel, + /// Dialog still active. + Running, +} + +/// F9 → Options → Confirmation dialog. +pub struct ConfirmDialog { + settings: ConfirmSettings, + cursor: usize, +} + +impl ConfirmDialog { + /// Create a new dialog with the given initial settings. + pub fn new(init: ConfirmSettings) -> Self { + Self { + settings: init, + cursor: 0, + } + } + + /// Return a snapshot of the current settings. + #[must_use] + pub fn settings(&self) -> ConfirmSettings { + self.settings.clone() + } + + /// Handle a key event. + pub fn handle_key(&mut self, key: Key) -> ConfirmResult { + if key == Key::ENTER { + return ConfirmResult::Confirm(self.settings()); + } + if key == Key::ESCAPE || key == Key::f(10) { + return ConfirmResult::Cancel; + } + if key == Key::TAB { + self.cursor = (self.cursor + 1) % TOGGLE_LABELS.len(); + return ConfirmResult::Running; + } + if key.code == 0x2191 { + if self.cursor == 0 { + self.cursor = TOGGLE_LABELS.len() - 1; + } else { + self.cursor -= 1; + } + return ConfirmResult::Running; + } + if key.code == 0x2193 { + self.cursor = (self.cursor + 1) % TOGGLE_LABELS.len(); + return ConfirmResult::Running; + } + if key.code == b' ' as u32 + || key.code == b't' as u32 + || key.code == b'T' as u32 + { + let cur = self.settings.at(self.cursor); + self.settings.set(self.cursor, !cur); + return ConfirmResult::Running; + } + if key.code == b'y' as u32 || key.code == b'Y' as u32 { + self.settings.set(self.cursor, true); + return ConfirmResult::Running; + } + if key.code == b'n' as u32 || key.code == b'N' as u32 { + self.settings.set(self.cursor, false); + return ConfirmResult::Running; + } + ConfirmResult::Running + } + + /// Render the dialog. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let popup = centered_rect(WIDTH, HEIGHT, area); + frame.render_widget(Clear, popup); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(Style::default().fg(theme.border)) + .title(Line::from(Span::styled( + " Confirmation ", + Style::default() + .fg(theme.title_fg) + .add_modifier(Modifier::BOLD), + ))) + .style(Style::default().bg(theme.background)); + + let inner = block.inner(popup); + frame.render_widget(block, popup); + + let chunks = Layout::vertical({ + let mut c: Vec = + Vec::with_capacity(TOGGLE_LABELS.len() + 3); + c.push(Constraint::Length(1)); + for _ in 0..TOGGLE_LABELS.len() { + c.push(Constraint::Length(1)); + } + c.push(Constraint::Length(1)); + c.push(Constraint::Min(0)); + c + }) + .split(inner); + + let hint = Paragraph::new(Line::from(Span::styled( + " Tab: move Space/T: toggle Enter: save Esc: cancel ", + Style::default().fg(theme.hidden), + ))) + .alignment(Alignment::Center); + frame.render_widget(hint, chunks[0]); + + for (i, (label, desc)) in TOGGLE_LABELS.iter().enumerate() { + let checked = self.settings.at(i); + let is_cursor = i == self.cursor; + let mark = if checked { "[x]" } else { "[ ]" }; + let prefix = if is_cursor { "> " } else { " " }; + let fg = if is_cursor { + theme.cursor_fg + } else { + theme.foreground + }; + let bg = if is_cursor { + theme.cursor_bg + } else { + theme.background + }; + let line = Line::from(vec![ + Span::styled( + format!("{prefix}{mark} {label}"), + Style::default().fg(fg).bg(bg), + ), + Span::styled( + format!(" ({desc})"), + Style::default().fg(theme.hidden), + ), + ]); + frame.render_widget(Paragraph::new(line), chunks[i + 1]); + } + + let footer = Paragraph::new(Line::from(Span::styled( + " [ Save ] [ Cancel ] ", + Style::default().fg(theme.hidden), + ))) + .alignment(Alignment::Center); + frame.render_widget(footer, chunks[TOGGLE_LABELS.len() + 1]); + } +} + +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let x = area + .x + .saturating_add((area.width.saturating_sub(width)) / 2); + let y = area + .y + .saturating_add((area.height.saturating_sub(height)) / 2); + Rect::new(x, y, width.min(area.width), height.min(area.height)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Modifiers; + + fn default_settings() -> ConfirmSettings { + ConfirmSettings { + confirm_delete: true, + confirm_overwrite: true, + confirm_execute: false, + confirm_exit: false, + } + } + + #[test] + fn confirm_dialog_enter_confirms() { + let mut d = ConfirmDialog::new(default_settings()); + let result = d.handle_key(Key::ENTER); + match result { + ConfirmResult::Confirm(s) => { + assert!(s.confirm_delete); + assert!(s.confirm_overwrite); + } + other => panic!("expected Confirm, got {other:?}"), + } + } + + #[test] + fn confirm_dialog_escape_cancels() { + let mut d = ConfirmDialog::new(default_settings()); + let result = d.handle_key(Key::ESCAPE); + assert!(matches!(result, ConfirmResult::Cancel)); + } + + #[test] + fn confirm_dialog_space_toggles_current() { + let mut d = ConfirmDialog::new(default_settings()); + assert!(d.settings.confirm_delete); + d.handle_key(Key { code: b' ' as u32, mods: Modifiers::empty() }); + assert!(!d.settings.confirm_delete); + } + + #[test] + fn confirm_dialog_tab_cycles_cursor() { + let mut d = ConfirmDialog::new(default_settings()); + assert_eq!(d.cursor, 0); + d.handle_key(Key::TAB); + assert_eq!(d.cursor, 1); + d.handle_key(Key::TAB); + assert_eq!(d.cursor, 2); + d.handle_key(Key::TAB); + assert_eq!(d.cursor, 3); + d.handle_key(Key::TAB); + assert_eq!(d.cursor, 0); + } + + #[test] + fn confirm_dialog_y_sets_true() { + let mut d = ConfirmDialog::new(default_settings()); + d.cursor = 2; + assert!(!d.settings.confirm_execute); + d.handle_key(Key { code: b'y' as u32, mods: Modifiers::empty() }); + assert!(d.settings.confirm_execute); + } + + #[test] + fn confirm_dialog_n_sets_false() { + let mut d = ConfirmDialog::new(default_settings()); + d.cursor = 0; + assert!(d.settings.confirm_delete); + d.handle_key(Key { code: b'n' as u32, mods: Modifiers::empty() }); + assert!(!d.settings.confirm_delete); + } + + #[test] + fn confirm_settings_at_and_set_roundtrip() { + let mut s = default_settings(); + assert!(!s.at(3)); + s.set(3, true); + assert!(s.at(3)); + s.set(3, false); + assert!(!s.at(3)); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs index cf8d3a0d84..e220f70e88 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/delete_dialog.rs @@ -17,7 +17,7 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{centered_cols_rect, render_popup}; +use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; /// F8 delete confirmation dialog. pub struct DeleteDialog { @@ -90,7 +90,7 @@ impl DeleteDialog { /// dialog follows the active skin. The destructive title uses the /// `theme.error` slot to keep the danger cue. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_cols_rect(area, 56, 10); + let popup = centered_cols_rect(area, 56, 11); let inner = render_popup(frame, popup, crate::locale::t("dialog_title_delete"), theme); let chunks = Layout::default() @@ -98,7 +98,8 @@ impl DeleteDialog { .constraints([ Constraint::Length(2), // header Constraint::Min(2), // paths - Constraint::Length(2), // hint + Constraint::Length(1), // buttons + Constraint::Length(1), // hint ]) .split(inner); @@ -111,7 +112,6 @@ impl DeleteDialog { )); frame.render_widget(Paragraph::new(header), chunks[0]); - // Show each path on its own line (truncated to width). let max_lines = chunks[1].height as usize; let lines: Vec = self .paths @@ -126,32 +126,27 @@ impl DeleteDialog { .collect(); frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[1]); + render_button_row( + frame, + chunks[2], + theme, + &crate::locale::t("dialog_action_yes"), + &crate::locale::t("dialog_action_no"), + ); + let hint = Line::from(vec![ - Span::styled( - "Y", - Style::default() - .fg(theme.executable) - .add_modifier(Modifier::BOLD), - ), + Span::styled("Enter", Style::default().fg(theme.warning)), Span::styled( format!(" {} ", crate::locale::t("dialog_action_yes")), - Style::default().fg(theme.hidden), - ), - Span::styled( - "N", - Style::default().fg(theme.error).add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" {} ", crate::locale::t("dialog_action_no")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), Span::styled("Esc", Style::default().fg(theme.warning)), Span::styled( format!(" {}", crate::locale::t("dialog_action_cancel")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]); } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/external_panelize.rs b/local/recipes/tui/tlc/source/src/filemanager/external_panelize.rs new file mode 100644 index 0000000000..5c650f3268 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/external_panelize.rs @@ -0,0 +1,415 @@ +//! External Panelize dialog (C-x !). +//! +//! Lets the user run an arbitrary shell command and replace the active +//! panel's listing with the command's stdout, one path per line. The +//! command runs once on Enter; Esc closes the dialog. Output parsing, +//! shell invocation, and the listing-mode contract all live in this +//! module so the dispatcher in [`crate::filemanager`] only has to +//! forward [`ExternalPanelizeOutcome::Apply`] to +//! [`crate::filemanager::panel::Panel::set_external_listing`]. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::Frame; + +use crate::key::Key; +use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; +use crate::widget::input::Input; + +/// Outcome of a keypress in the external-panelize dialog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExternalPanelizeOutcome { + /// Still open; keep processing keys. + Running, + /// User pressed Enter on a non-empty command. The dialog supplies + /// the parsed paths for the caller to load into the active panel. + Apply(Vec), + /// User pressed Enter but the command produced zero usable lines. + /// The dialog stays open so the user can fix the command; the + /// dialog's `last_error` is also set to a user-facing message. + Empty, + /// User pressed Esc — close the dialog and do nothing. + Cancel, +} + +/// External Panelize dialog. +pub struct ExternalPanelizeDialog { + /// Command input field. + command: Input, + /// Optional user-visible error from the last run. + last_error: Option, + /// Last stderr captured from the command (for the status line). + last_stderr: Option, +} + +impl ExternalPanelizeDialog { + /// Create a new dialog with an empty command field. + #[must_use] + pub fn new() -> Self { + let command = Input::new() + .label("Command") + .placeholder("find . -name '*.rs'"); + Self { + command, + last_error: None, + last_stderr: None, + } + } + + /// The current command string. + #[must_use] + pub fn command(&self) -> &str { + self.command.value() + } + + /// Last error (e.g. spawn failure, zero output). + #[must_use] + pub fn last_error(&self) -> Option<&str> { + self.last_error.as_deref() + } + + /// Last captured stderr from the most recent command run, if any. + #[must_use] + pub fn last_stderr(&self) -> Option<&str> { + self.last_stderr.as_deref() + } + + /// Handle a key event. Returns the resulting [`ExternalPanelizeOutcome`]. + pub fn handle_key(&mut self, key: Key) -> ExternalPanelizeOutcome { + match key { + Key::ESCAPE => ExternalPanelizeOutcome::Cancel, + Key::ENTER => self.run_command(), + _ => { + let _ = self.command.handle_key(key); + ExternalPanelizeOutcome::Running + } + } + } + + /// Run the current command (if non-empty) and translate the result + /// into an [`ExternalPanelizeOutcome`]. + fn run_command(&mut self) -> ExternalPanelizeOutcome { + let cmd = self.command.value().trim().to_string(); + if cmd.is_empty() { + self.last_error = Some("command is empty".to_string()); + self.last_stderr = None; + return ExternalPanelizeOutcome::Running; + } + match run_shell_capture(&cmd) { + Ok(out) => { + self.last_stderr = if out.stderr.is_empty() { + None + } else { + Some(out.stderr) + }; + let paths = parse_command_output(&out.stdout); + if paths.is_empty() { + self.last_error = Some("command produced no paths".to_string()); + ExternalPanelizeOutcome::Empty + } else { + self.last_error = None; + ExternalPanelizeOutcome::Apply(paths) + } + } + Err(e) => { + self.last_error = Some(format!("spawn: {e}")); + self.last_stderr = None; + ExternalPanelizeOutcome::Running + } + } + } + + /// Render the dialog into `frame`, centered on `area`. `theme` + /// supplies the input/foreground colours so the dialog follows + /// the active skin. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let title = "External panelize".to_string(); + let popup = centered_percent_rect(area, 0.7, 0.4); + let inner = render_popup(frame, popup, title, theme); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Min(2), + ]) + .split(inner); + + let hint = Line::from(vec![ + Span::styled("Enter", Style::default().fg(theme.warning)), + Span::styled( + format!(" {} ", crate::locale::t("dialog_action_select")), + Style::default().fg(theme.hidden), + ), + Span::styled("Esc", Style::default().fg(theme.warning)), + Span::styled( + format!(" {}", crate::locale::t("dialog_action_cancel")), + Style::default().fg(theme.hidden), + ), + ]); + frame.render_widget(Paragraph::new(hint), chunks[0]); + + let value = self.command.value().to_string(); + let mut input = Input::new() + .label("Command") + .placeholder("find . -name '*.rs'") + .focused(); + if !value.is_empty() { + input = input.text(value); + } + input.render(frame, chunks[1], theme); + + let status_text = self + .last_error + .as_deref() + .or(self.last_stderr.as_deref()) + .unwrap_or(""); + let status_color = if self.last_error.is_some() { + theme.error + } else { + theme.hidden + }; + let body = Paragraph::new(Line::from(Span::styled( + status_text.to_string(), + Style::default().fg(status_color).add_modifier(Modifier::ITALIC), + ))) + .wrap(Wrap { trim: false }); + frame.render_widget(body, chunks[2]); + } +} + +impl Default for ExternalPanelizeDialog { + fn default() -> Self { + Self::new() + } +} + +/// Captured output of a shell command run. +struct CapturedOutput { + /// stdout bytes decoded as UTF-8 (lossy). + stdout: String, + /// stderr bytes decoded as UTF-8 (lossy). + stderr: String, +} + +/// Run `cmd` via `/bin/sh -c` and return its captured stdout/stderr. +/// Returns an error if the shell cannot be spawned. A non-zero exit +/// status from the command is NOT treated as an error here — the +/// parser will simply receive whatever stdout (if any) was produced. +fn run_shell_capture(cmd: &str) -> std::io::Result { + let output = Command::new("sh").arg("-c").arg(cmd).output()?; + Ok(CapturedOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) +} + +/// Parse the stdout of a panelize command into a list of file paths. +/// +/// Splits on `\n` (LF) and `\r\n` (CRLF), trims trailing whitespace +/// from each line, and keeps non-empty lines. Lines that look like +/// absolute paths or `./relative` paths are passed through unchanged; +/// any other non-empty line is treated as a path relative to the +/// current working directory. No validation against the filesystem +/// is performed here — the panel's `set_external_listing` will +/// `lstat` each path and drop the ones that don't exist. +#[must_use] +pub fn parse_command_output(stdout: &str) -> Vec { + stdout + .split('\n') + .map(|s| s.trim_end_matches('\r')) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect() +} + +/// Join each panelize path against `cwd` if it is relative. Absolute +/// paths are returned unchanged. Exposed for tests and as a +/// convenience for callers that want to resolve relative paths up +/// front. +#[must_use] +pub fn resolve_against_cwd(paths: &[PathBuf], cwd: &Path) -> Vec { + paths + .iter() + .map(|p| { + if p.is_absolute() { + p.clone() + } else { + cwd.join(p) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dialog_new_default_state() { + let d = ExternalPanelizeDialog::new(); + assert_eq!(d.command(), ""); + assert!(d.last_error().is_none()); + assert!(d.last_stderr().is_none()); + } + + #[test] + fn dialog_handle_key_esc_returns_cancel() { + let mut d = ExternalPanelizeDialog::new(); + assert_eq!(d.handle_key(Key::ESCAPE), ExternalPanelizeOutcome::Cancel); + } + + #[test] + fn dialog_handle_key_running_for_typing() { + let mut d = ExternalPanelizeDialog::new(); + for c in "ls".chars() { + assert_eq!( + d.handle_key(Key::from_char(c)), + ExternalPanelizeOutcome::Running + ); + } + assert_eq!(d.command(), "ls"); + } + + #[test] + fn dialog_handle_key_enter_with_empty_command_records_error() { + let mut d = ExternalPanelizeDialog::new(); + assert_eq!( + d.handle_key(Key::ENTER), + ExternalPanelizeOutcome::Running + ); + assert_eq!(d.last_error(), Some("command is empty")); + } + + #[test] + fn dialog_handle_key_enter_runs_command_and_returns_paths() { + let mut d = ExternalPanelizeDialog::new(); + for c in "printf 'a\\nb\\nc\\n'".chars() { + d.handle_key(Key::from_char(c)); + } + match d.handle_key(Key::ENTER) { + ExternalPanelizeOutcome::Apply(paths) => { + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], PathBuf::from("a")); + assert_eq!(paths[1], PathBuf::from("b")); + assert_eq!(paths[2], PathBuf::from("c")); + } + other => panic!("expected Apply, got {other:?}"), + } + assert!(d.last_error().is_none()); + } + + #[test] + fn dialog_handle_key_enter_with_no_output_returns_empty() { + let mut d = ExternalPanelizeDialog::new(); + for c in "true".chars() { + d.handle_key(Key::from_char(c)); + } + assert_eq!(d.handle_key(Key::ENTER), ExternalPanelizeOutcome::Empty); + assert_eq!(d.last_error(), Some("command produced no paths")); + } + + #[test] + fn parse_command_output_basic_lines() { + let out = "a\nb\nc\n"; + let paths = parse_command_output(out); + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], PathBuf::from("a")); + assert_eq!(paths[1], PathBuf::from("b")); + assert_eq!(paths[2], PathBuf::from("c")); + } + + #[test] + fn parse_command_output_skips_empty_lines() { + let out = "a\n\n \nb\n"; + let paths = parse_command_output(out); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], PathBuf::from("a")); + assert_eq!(paths[1], PathBuf::from("b")); + } + + #[test] + fn parse_command_output_handles_crlf() { + let out = "a\r\nb\r\nc\r\n"; + let paths = parse_command_output(out); + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], PathBuf::from("a")); + assert_eq!(paths[1], PathBuf::from("b")); + assert_eq!(paths[2], PathBuf::from("c")); + } + + #[test] + fn parse_command_output_preserves_absolute_paths() { + let out = "/etc/hosts\n/home/user/.bashrc\n"; + let paths = parse_command_output(out); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], PathBuf::from("/etc/hosts")); + assert_eq!(paths[1], PathBuf::from("/home/user/.bashrc")); + } + + #[test] + fn parse_command_output_empty_input_yields_empty_vec() { + assert!(parse_command_output("").is_empty()); + assert!(parse_command_output("\n\n\n").is_empty()); + assert!(parse_command_output(" \n \n").is_empty()); + } + + #[test] + fn parse_command_output_handles_trailing_whitespace_per_line() { + let out = "path/one \npath/two\t\npath/three\n"; + let paths = parse_command_output(out); + assert_eq!(paths.len(), 3); + assert_eq!(paths[0], PathBuf::from("path/one")); + assert_eq!(paths[1], PathBuf::from("path/two")); + assert_eq!(paths[2], PathBuf::from("path/three")); + } + + #[test] + fn resolve_against_cwd_keeps_absolute_paths() { + let cwd = PathBuf::from("/home/user"); + let paths = vec![PathBuf::from("/etc/hosts"), PathBuf::from("/var/log")]; + let resolved = resolve_against_cwd(&paths, &cwd); + assert_eq!(resolved[0], PathBuf::from("/etc/hosts")); + assert_eq!(resolved[1], PathBuf::from("/var/log")); + } + + #[test] + fn resolve_against_cwd_joins_relative_paths() { + let cwd = PathBuf::from("/home/user"); + let paths = vec![PathBuf::from("docs"), PathBuf::from("./notes")]; + let resolved = resolve_against_cwd(&paths, &cwd); + assert_eq!(resolved[0], PathBuf::from("/home/user/docs")); + assert_eq!(resolved[1], PathBuf::from("/home/user/./notes")); + } + + #[test] + fn dialog_render_does_not_panic() { + let d = ExternalPanelizeDialog::new(); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| d.render(f, f.area(), &theme)) + .expect("render must not panic"); + } + + #[test] + fn dialog_render_with_error_does_not_panic() { + let mut d = ExternalPanelizeDialog::new(); + d.last_error = Some("command produced no paths".to_string()); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| d.render(f, f.area(), &theme)) + .expect("render must not panic"); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/jobs.rs b/local/recipes/tui/tlc/source/src/filemanager/jobs.rs new file mode 100644 index 0000000000..40a8e3bc91 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/jobs.rs @@ -0,0 +1,1216 @@ +//! Background file-operation jobs and the Jobs dialog. +//! +//! TLC supports long-running file operations (copy / move / delete) +//! in two ways: +//! +//! 1. **Blocking**: the existing copy/move/delete dialogs run the +//! operation synchronously in the UI thread and refresh the panels +//! on completion. This is what F5/F6/F8 do today. +//! 2. **Background**: the helpers in this module (e.g. +//! [`spawn_copy_job`], [`spawn_move_job`], [`spawn_delete_job`) +//! register a job in a thread-safe [`JobRegistry`], spawn a +//! [`std::thread`] that updates progress atomically, and return +//! immediately so the file manager stays responsive. +//! +//! The [`JobsDialog`] surfaces the registry to the user: it shows a +//! table of jobs (id, kind, source, destination, progress, status) and +//! supports navigation, retry of failed jobs, dismissal of completed +//! jobs, and closing. The dialog is reachable from the file manager +//! via the `Cmd::Jobs` command (bound to `C-x j` by [`crate::keymap`]). +//! +//! Concurrency model: +//! - The registry lives behind an `Arc>` owned by +//! [`crate::filemanager::FileManager`]. The dialog holds a clone of +//! the `Arc`, so the file manager can also access the registry +//! without crossing thread boundaries. +//! - `Job` itself is internally synchronised: every field is either +//! immutable (`Vec>`) or atomically synchronised +//! (`AtomicU64`, `Mutex`). +//! - A background thread holds a single `Arc` (NOT the registry +//! and NOT the file manager) and writes through the +//! `Arc::set_state` / `Arc::set_progress` helpers. +//! - The UI thread reads via the registry's [`JobRegistry::list`] +//! method (called through the dialog's `Arc>` clone), +//! which locks the inner mutex briefly, snapshots each job, and +//! releases the lock. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; +use ratatui::Frame; + +use crate::key::Key; +use crate::ops::{self, CancelToken, OpHandle, OpKind, OpProgress, OpStatus}; +use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; + +/// The kind of a background job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum JobKind { + /// Copying one or more paths. + Copy, + /// Moving / renaming one or more paths. + Move, + /// Deleting one or more paths. + Delete, +} + +impl JobKind { + /// Short label, e.g. for the dialog table. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Copy => "Copy", + Self::Move => "Move", + Self::Delete => "Delete", + } + } +} + +/// The state of a single background job. +/// +/// `Failed` carries the error message so the Jobs dialog can show +/// the user *why* the job failed (e.g. "destination exists", "io: +/// permission denied"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobState { + /// Registered but not yet started (reserved for future use). + Queued, + /// A worker thread is currently processing this job. + Running, + /// Worker thread completed successfully. + Done, + /// Worker thread failed; carries the error message. + Failed(String), + /// Worker thread was cancelled by the user. + Cancelled, +} + +impl JobState { + /// Short label, e.g. for the dialog table. + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Queued => "Queued".to_string(), + Self::Running => "Running".to_string(), + Self::Done => "Done".to_string(), + Self::Failed(msg) => format!("Failed: {msg}"), + Self::Cancelled => "Cancelled".to_string(), + } + } + + /// True if the job has reached a terminal state. + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Done | Self::Failed(_) | Self::Cancelled) + } +} + +/// A single background file operation. +/// +/// `Job` is shared with the worker thread via `Arc`. The +/// state-mutation surface (`set_state`, `set_progress`) takes the +/// lock / atomic once and is safe to call concurrently from any +/// thread. The UI thread reads via [`Job::snapshot`] which locks the +/// state mutex briefly and loads the atomic progress. +#[derive(Debug)] +pub struct Job { + /// Unique, monotonically-increasing id (assigned at registration). + pub id: u64, + /// Operation kind. + pub kind: JobKind, + /// Source path (or the first source if multiple). + pub source: String, + /// Destination path; empty for deletes. + pub dest: String, + /// Thread-safe current state. + state: Mutex, + /// Bytes processed so far (atomic). + progress: AtomicU64, + /// Total bytes for the operation (0 for delete / unknown). + total: u64, + /// Cancellation token shared with the worker. + cancel: CancelToken, +} + +/// Snapshot of a job's current state — what the dialog renders. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobSnapshot { + /// Job id. + pub id: u64, + /// Job kind. + pub kind: JobKind, + /// Source path. + pub source: String, + /// Destination path. + pub dest: String, + /// Current state. + pub state: JobState, + /// Bytes processed so far. + pub progress: u64, + /// Total bytes (0 if unknown / not applicable). + pub total: u64, +} + +impl Job { + /// Read a consistent snapshot of this job's current state. + #[must_use] + pub fn snapshot(&self) -> JobSnapshot { + let state = self + .state + .lock() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); + JobSnapshot { + id: self.id, + kind: self.kind, + source: self.source.clone(), + dest: self.dest.clone(), + state, + progress: self.progress.load(Ordering::Relaxed), + total: self.total, + } + } + + /// Replace the job's state. Used by the worker thread. + pub fn set_state(&self, new_state: JobState) { + if let Ok(mut guard) = self.state.lock() { + *guard = new_state; + } + } + + /// Add `delta` bytes to the progress counter. Used by the worker + /// thread as bytes are copied/moved/deleted. + pub fn add_progress(&self, delta: u64) { + self.progress.fetch_add(delta, Ordering::Relaxed); + } + + /// Set the progress counter to an absolute value. + pub fn set_progress(&self, value: u64) { + self.progress.store(value, Ordering::Relaxed); + } + + /// Request cancellation of this job. The worker polls the cancel + /// token between files and exits cleanly. + pub fn cancel(&self) { + self.cancel.cancel(); + } + + /// Reset the cancel token so the job can be retried after a + /// failure. Only callable from the UI thread (the registry's + /// `retry` path). + pub fn reset_cancel(&self) { + self.cancel.reset(); + } + + /// The cancel token (used by the worker thread to poll for + /// cancellation). + #[must_use] + pub const fn cancel_token(&self) -> &CancelToken { + &self.cancel + } + + /// The job's total byte count. + #[must_use] + pub const fn total(&self) -> u64 { + self.total + } +} + +/// A thread-safe registry of background file-operation jobs. +/// +/// The registry owns every `Arc` currently in flight. The UI +/// reads via [`JobRegistry::list`] and the worker threads only ever +/// hold their own `Arc` clone. `next_id` is monotonically +/// increasing for the lifetime of the process; ids are never reused. +#[derive(Debug, Default)] +pub struct JobRegistry { + /// Currently-tracked jobs (immutable list of `Arc`s; the inner + /// `Job` is mutated through atomics / mutexes). + jobs: Vec>, + /// Monotonic counter for the next job id. + next_id: u64, +} + +impl JobRegistry { + /// Create an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Register a new job. Returns the `Arc` so the caller (or a + /// spawned worker thread) can update progress / state. The job + /// starts in [`JobState::Running`] — the worker is expected to + /// transition it to `Done` / `Failed` / `Cancelled` when finished. + #[must_use] + pub fn add( + &mut self, + kind: JobKind, + source: String, + dest: String, + total: u64, + ) -> Arc { + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + let job = Arc::new(Job { + id, + kind, + source, + dest, + state: Mutex::new(JobState::Running), + progress: AtomicU64::new(0), + total, + cancel: CancelToken::new(), + }); + self.jobs.push(Arc::clone(&job)); + job + } + + /// Snapshot of every tracked job, in registration order. + #[must_use] + pub fn list(&self) -> Vec { + self.jobs.iter().map(|j| j.snapshot()).collect() + } + + /// Remove a job by id. Returns true if a job was removed. Only + /// terminal-state jobs should be removed; the UI enforces this. + pub fn remove(&mut self, id: u64) -> bool { + let before = self.jobs.len(); + self.jobs.retain(|j| j.id != id); + self.jobs.len() != before + } + + /// Look up a job by id. + #[must_use] + pub fn get(&self, id: u64) -> Option> { + self.jobs.iter().find(|j| j.id == id).map(Arc::clone) + } + + /// Number of tracked jobs. + #[must_use] + pub fn len(&self) -> usize { + self.jobs.len() + } + + /// True if there are no tracked jobs. + #[must_use] + pub fn is_empty(&self) -> bool { + self.jobs.is_empty() + } +} + +/// Spawn a background copy operation. The job is registered in +/// `registry`, a worker thread is launched, and the function returns +/// the new `Arc` immediately. The worker updates progress +/// through the `OpHandle` and transitions the job state to `Done` / +/// `Failed` / `Cancelled` on completion. +#[must_use] +pub fn spawn_copy_job( + registry: &mut JobRegistry, + sources: Vec, + dest: PathBuf, + preserve_attributes: bool, + follow_links: bool, +) -> Arc { + let source = sources + .first() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let total = ops::count_bytes(&sources); + let job = registry.add(JobKind::Copy, source, dest.display().to_string(), total); + let job_for_worker = Arc::clone(&job); + thread::spawn(move || { + run_copy_worker(job_for_worker, sources, dest, preserve_attributes, follow_links); + }); + job +} + +/// Spawn a background move operation. Returns the new `Arc`. +#[must_use] +pub fn spawn_move_job( + registry: &mut JobRegistry, + sources: Vec, + dest: PathBuf, + preserve_attributes: bool, + follow_links: bool, +) -> Arc { + let source = sources + .first() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let total = ops::count_bytes(&sources); + let job = registry.add(JobKind::Move, source, dest.display().to_string(), total); + let job_for_worker = Arc::clone(&job); + thread::spawn(move || { + run_move_worker(job_for_worker, sources, dest, preserve_attributes, follow_links); + }); + job +} + +/// Spawn a background delete operation. Returns the new `Arc`. +#[must_use] +pub fn spawn_delete_job(registry: &mut JobRegistry, paths: Vec) -> Arc { + let source = paths + .first() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + let total: u64 = paths.iter().map(|p| ops::count_bytes(&[p.clone()])).sum(); + let job = registry.add(JobKind::Delete, source, String::new(), total); + let job_for_worker = Arc::clone(&job); + thread::spawn(move || { + run_delete_worker(job_for_worker, paths); + }); + job +} + +fn run_copy_worker( + job: Arc, + sources: Vec, + dest: PathBuf, + preserve_attributes: bool, + follow_links: bool, +) { + let handle = make_op_handle(OpKind::Copy, &sources, Some(&dest), &job); + let result = ops::copy::copy_many( + &sources, + &dest, + &handle, + true, + preserve_attributes, + follow_links, + ); + apply_worker_result(&job, &handle, &result); +} + +fn run_move_worker( + job: Arc, + sources: Vec, + dest: PathBuf, + preserve_attributes: bool, + follow_links: bool, +) { + let handle = make_op_handle(OpKind::Move, &sources, Some(&dest), &job); + let result = ops::move_op::move_many( + &sources, + &dest, + &handle, + true, + preserve_attributes, + follow_links, + ); + apply_worker_result(&job, &handle, &result); +} + +fn run_delete_worker(job: Arc, paths: Vec) { + let handle = make_op_handle(OpKind::Delete, &paths, None, &job); + let result = ops::delete::delete_many(&paths, &handle); + apply_worker_result(&job, &handle, &result); +} + +fn make_op_handle( + kind: OpKind, + sources: &[PathBuf], + dest: Option<&std::path::Path>, + job: &Arc, +) -> OpHandle { + let cancel = job.cancel_token().clone(); + OpHandle { + kind, + status: Arc::new(Mutex::new(OpStatus::Running)), + progress: Arc::new(Mutex::new(OpProgress::default())), + cancel, + sources: sources.to_vec(), + destination: dest.map(|p| p.to_path_buf()), + } +} + +fn apply_worker_result( + job: &Arc, + handle: &OpHandle, + result: &Result<(), ops::OpsError>, +) { + if let Ok(p) = handle.progress.lock() { + job.set_progress(p.bytes_done); + } + match result { + Ok(()) => job.set_state(JobState::Done), + Err(ops::OpsError::Cancelled) => job.set_state(JobState::Cancelled), + Err(e) => job.set_state(JobState::Failed(e.to_string())), + } +} + +/// A modal dialog showing every tracked background job in a +/// scrollable table. +/// +/// Keys: +/// - `Esc` / `q` — close the dialog +/// - `Up` / `Down` — move the selection +/// - `r` — retry the selected job (only enabled for `Failed` jobs) +/// - `d` / `Enter` — dismiss the selected job (only enabled for +/// terminal-state jobs) +pub struct JobsDialog { + /// Shared registry handle. The dialog clones the + /// `Arc>` from the file manager; both + /// endpoints can read and mutate the registry safely across + /// threads through the inner mutex. + registry: Arc>, + /// Currently-selected row in the table. `0` is the first job. + selected: usize, + /// Cached list of job snapshots, refreshed on demand. The cache + /// lets `render()` take `&self` and avoids re-locking the + /// registry for every cell on every frame. + cache: Vec, + /// Title shown in the dialog's title bar. + title: String, + /// Width as a fraction of the parent area. + pub width_pct: f32, + /// Height as a fraction of the parent area. + pub height_pct: f32, +} + +impl std::fmt::Debug for JobsDialog { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JobsDialog") + .field("selected", &self.selected) + .field("cached_jobs", &self.cache.len()) + .field("title", &self.title) + .field("width_pct", &self.width_pct) + .field("height_pct", &self.height_pct) + .finish_non_exhaustive() + } +} + +impl JobsDialog { + /// Create a new Jobs dialog bound to a shared registry handle. + #[must_use] + pub fn new(registry: Arc>) -> Self { + Self::with_title(registry, "Background jobs") + } + + /// Create a new Jobs dialog with a custom title. + #[must_use] + pub fn with_title(registry: Arc>, title: impl Into) -> Self { + let cache = registry + .lock() + .map(|guard| guard.list()) + .unwrap_or_default(); + Self { + registry, + selected: 0, + cache, + title: title.into(), + width_pct: 0.7, + height_pct: 0.6, + } + } + + /// Set the dialog size as a fraction of the parent area. + #[must_use] + pub fn with_size(mut self, width_pct: f32, height_pct: f32) -> Self { + self.width_pct = width_pct.clamp(0.1, 1.0); + self.height_pct = height_pct.clamp(0.1, 1.0); + self + } + + /// Number of jobs currently displayed. + #[must_use] + pub fn len(&self) -> usize { + self.cache.len() + } + + /// True if there are no jobs to show. + #[must_use] + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } + + /// Refresh the snapshot cache from the registry. Call before + /// rendering so the user sees live progress / state updates from + /// background workers. + pub fn refresh_cache(&mut self) { + let new_cache = self + .registry + .lock() + .map(|guard| guard.list()) + .unwrap_or_default(); + self.cache = new_cache; + if self.selected >= self.cache.len() && !self.cache.is_empty() { + self.selected = self.cache.len() - 1; + } + } + + /// The currently-selected snapshot, if any. + #[must_use] + pub fn selected_snapshot(&self) -> Option<&JobSnapshot> { + self.cache.get(self.selected) + } + + /// Move the selection up by one row. + pub fn select_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + } + } + + /// Move the selection down by one row. + pub fn select_down(&mut self) { + if self.selected + 1 < self.cache.len() { + self.selected += 1; + } + } + + /// The selection index. + #[must_use] + pub fn selected_index(&self) -> usize { + self.selected + } + + /// Handle a key. Returns `true` to close the dialog. + pub fn handle_key(&mut self, key: Key) -> bool { + match key { + Key::ESCAPE => true, + k if k == Key::from_char('q') => true, + k if k.code == 0x2191 => { + self.select_up(); + false + } + k if k.code == 0x2193 => { + self.select_down(); + false + } + k if k == Key::from_char('r') => { + self.retry_selected(); + false + } + k if k == Key::from_char('d') || k == Key::ENTER => { + self.dismiss_selected(); + false + } + _ => false, + } + } + + /// Retry the selected failed/cancelled job by resetting its + /// cancel token and transitioning it back to `Running`. The + /// dialog cannot recover the original sources list, so retry is + /// state-only; a true re-execution from the file panels is the + /// user's responsibility. + fn retry_selected(&mut self) { + let Some(snap) = self.selected_snapshot().cloned() else { + return; + }; + if !matches!(snap.state, JobState::Failed(_) | JobState::Cancelled) { + return; + } + let job = self + .registry + .lock() + .ok() + .and_then(|guard| guard.get(snap.id)); + if let Some(job) = job { + job.reset_cancel(); + job.set_state(JobState::Running); + job.set_progress(0); + } + } + + /// Dismiss (remove) the selected job. Only enabled for + /// terminal-state jobs. + fn dismiss_selected(&mut self) { + let Some(snap) = self.selected_snapshot().cloned() else { + return; + }; + if !snap.state.is_terminal() { + return; + } + if let Ok(mut registry) = self.registry.lock() { + registry.remove(snap.id); + } + self.refresh_cache(); + if self.selected >= self.cache.len() && !self.cache.is_empty() { + self.selected = self.cache.len() - 1; + } + } + + /// Render the dialog into `frame`, centered on `area`. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let popup = centered_percent_rect(area, self.width_pct, self.height_pct); + let inner = render_popup(frame, popup, self.title.clone(), theme); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(inner); + + let header_line = Line::from(vec![ + Span::styled( + format!("{:<4}", "ID"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + Span::styled( + format!("{:<7}", "Type"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + Span::styled( + format!("{:<24}", "Source"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + Span::styled( + format!("{:<24}", "Dest"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + Span::styled( + format!("{:<18}", "Progress"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + Span::styled( + format!("{}", "Status"), + Style::default().fg(theme.title_fg).bg(theme.title_bg), + ), + ]); + frame.render_widget(Paragraph::new(header_line), chunks[0]); + + if self.cache.is_empty() { + let empty = Paragraph::new(Line::from(Span::styled( + " (no jobs)", + Style::default().fg(theme.hidden), + ))); + frame.render_widget(empty, chunks[1]); + } else { + let visible = chunks[1].height as usize; + let n = self.cache.len(); + let start = if n <= visible { + 0 + } else if self.selected < visible / 2 { + 0 + } else if self.selected + visible / 2 >= n { + n - visible + } else { + self.selected - visible / 2 + }; + let end = (start + visible).min(n); + + let lines: Vec = self.cache[start..end] + .iter() + .enumerate() + .map(|(i, snap)| { + let real_i = start + i; + let is_selected = real_i == self.selected; + let base_style = if is_selected { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground).bg(theme.background) + }; + let source = truncate(&snap.source, 24); + let dest = truncate(&snap.dest, 24); + let progress = render_progress(snap.progress, snap.total); + let status = truncate(&snap.state.label(), 32); + let status_color = match &snap.state { + JobState::Running => theme.info, + JobState::Done => theme.executable, + JobState::Failed(_) => theme.error, + JobState::Cancelled => theme.warning, + JobState::Queued => theme.hidden, + }; + let status_style = if is_selected { + base_style + } else { + Style::default().fg(status_color).bg(theme.background) + }; + Line::from(vec![ + Span::styled(format!("{:<4}", snap.id), base_style), + Span::styled(format!("{:<7}", snap.kind.label()), base_style), + Span::styled(format!("{:<24}", source), base_style), + Span::styled(format!("{:<24}", dest), base_style), + Span::styled(format!("{:<18}", progress), base_style), + Span::styled(status, status_style), + ]) + }) + .collect(); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[1]); + } + + let hint = Line::from(vec![ + Span::styled("Esc", Style::default().fg(theme.title_fg).bg(theme.title_bg)), + Span::styled(" close ", Style::default().fg(theme.foreground)), + Span::styled("Up/Down", Style::default().fg(theme.title_fg).bg(theme.title_bg)), + Span::styled(" nav ", Style::default().fg(theme.foreground)), + Span::styled("r", Style::default().fg(theme.title_fg).bg(theme.title_bg)), + Span::styled(" retry ", Style::default().fg(theme.foreground)), + Span::styled("d", Style::default().fg(theme.title_fg).bg(theme.title_bg)), + Span::styled("/", Style::default().fg(theme.foreground)), + Span::styled("Enter", Style::default().fg(theme.title_fg).bg(theme.title_bg)), + Span::styled(" dismiss", Style::default().fg(theme.foreground)), + ]); + frame.render_widget(Paragraph::new(hint), chunks[2]); + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} + +fn render_progress(progress: u64, total: u64) -> String { + const BAR_WIDTH: u64 = 10; + if total == 0 { + return format!( + "[{}]{:>5}", + " ", + if progress == 0 { "0%" } else { "100%" } + ); + } + let pct = (progress.min(total) as f64 / total as f64 * 100.0).round() as u64; + let filled = (progress.min(total) as u128 * BAR_WIDTH as u128 / total as u128) as u64; + let filled = filled.min(BAR_WIDTH); + let bar: String = "#".repeat(filled as usize) + &" ".repeat((BAR_WIDTH - filled) as usize); + format!("[{}]{:>4}%", bar, pct) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_registry() -> Arc> { + Arc::new(Mutex::new(JobRegistry::new())) + } + + #[test] + fn job_kind_labels() { + assert_eq!(JobKind::Copy.label(), "Copy"); + assert_eq!(JobKind::Move.label(), "Move"); + assert_eq!(JobKind::Delete.label(), "Delete"); + } + + #[test] + fn job_state_labels_and_terminal() { + assert_eq!(JobState::Queued.label(), "Queued"); + assert_eq!(JobState::Running.label(), "Running"); + assert_eq!(JobState::Done.label(), "Done"); + assert_eq!(JobState::Cancelled.label(), "Cancelled"); + assert_eq!( + JobState::Failed("io: bad".to_string()).label(), + "Failed: io: bad" + ); + assert!(!JobState::Queued.is_terminal()); + assert!(!JobState::Running.is_terminal()); + assert!(JobState::Done.is_terminal()); + assert!(JobState::Failed("x".to_string()).is_terminal()); + assert!(JobState::Cancelled.is_terminal()); + } + + #[test] + fn registry_add_list_remove() { + let mut reg = JobRegistry::new(); + assert!(reg.is_empty()); + let _ = reg.add(JobKind::Copy, "/a".into(), "/b".into(), 100); + let _ = reg.add(JobKind::Move, "/c".into(), "/d".into(), 200); + let _ = reg.add(JobKind::Delete, "/e".into(), "".into(), 0); + assert_eq!(reg.len(), 3); + let list = reg.list(); + assert_eq!(list[0].kind, JobKind::Copy); + assert_eq!(list[1].kind, JobKind::Move); + assert_eq!(list[2].kind, JobKind::Delete); + assert_eq!(list[0].source, "/a"); + assert_eq!(list[0].dest, "/b"); + assert_eq!(list[0].total, 100); + assert_eq!(list[0].progress, 0); + assert_eq!(list[0].state, JobState::Running); + + assert!(reg.remove(1)); + assert_eq!(reg.len(), 2); + assert!(!reg.remove(999)); + assert_eq!(reg.len(), 2); + } + + #[test] + fn registry_ids_are_unique_and_monotonic() { + let mut reg = JobRegistry::new(); + let a = reg.add(JobKind::Copy, "s".into(), "d".into(), 0); + let b = reg.add(JobKind::Copy, "s".into(), "d".into(), 0); + let c = reg.add(JobKind::Copy, "s".into(), "d".into(), 0); + assert!(a.id < b.id); + assert!(b.id < c.id); + } + + #[test] + fn registry_get_returns_arc() { + let mut reg = JobRegistry::new(); + let j = reg.add(JobKind::Copy, "s".into(), "d".into(), 10); + let id = j.id; + let got = reg.get(id); + assert!(got.is_some()); + assert_eq!(got.unwrap().id, id); + } + + #[test] + fn job_state_transitions_via_set_state() { + let mut reg = JobRegistry::new(); + let job = reg.add(JobKind::Copy, "s".into(), "d".into(), 0); + assert_eq!(job.snapshot().state, JobState::Running); + job.set_state(JobState::Done); + assert_eq!(job.snapshot().state, JobState::Done); + job.set_state(JobState::Failed("io: nope".to_string())); + match &job.snapshot().state { + JobState::Failed(m) => assert_eq!(m, "io: nope"), + other => panic!("expected Failed, got {other:?}"), + } + job.set_state(JobState::Cancelled); + assert_eq!(job.snapshot().state, JobState::Cancelled); + } + + #[test] + fn job_progress_atomic_updates() { + let mut reg = JobRegistry::new(); + let job = reg.add(JobKind::Copy, "s".into(), "d".into(), 1000); + assert_eq!(job.snapshot().progress, 0); + job.add_progress(100); + assert_eq!(job.snapshot().progress, 100); + job.add_progress(200); + assert_eq!(job.snapshot().progress, 300); + job.set_progress(750); + assert_eq!(job.snapshot().progress, 750); + } + + #[test] + fn job_snapshot_contains_id_kind_source_dest() { + let mut reg = JobRegistry::new(); + let job = reg.add(JobKind::Move, "/src".into(), "/dst".into(), 42); + let snap = job.snapshot(); + assert_eq!(snap.id, job.id); + assert_eq!(snap.kind, JobKind::Move); + assert_eq!(snap.source, "/src"); + assert_eq!(snap.dest, "/dst"); + assert_eq!(snap.total, 42); + assert_eq!(snap.progress, 0); + } + + #[test] + fn job_cancel_token_is_shared_and_resettable() { + let mut reg = JobRegistry::new(); + let job = reg.add(JobKind::Delete, "x".into(), "".into(), 0); + let token = job.cancel_token(); + assert!(!token.is_cancelled()); + job.cancel(); + assert!(token.is_cancelled()); + job.reset_cancel(); + assert!(!token.is_cancelled()); + } + + #[test] + fn render_progress_bar_formats_percent() { + assert_eq!(render_progress(0, 100), "[ ] 0%"); + assert_eq!(render_progress(45, 100), "[#### ] 45%"); + assert_eq!(render_progress(50, 100), "[##### ] 50%"); + assert_eq!(render_progress(100, 100), "[##########] 100%"); + assert_eq!(render_progress(0, 0), "[ ] 0%"); + assert_eq!(render_progress(1, 0), "[ ] 100%"); + assert_eq!(render_progress(200, 100), "[##########] 100%"); + } + + #[test] + fn truncate_short_strings_unchanged() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello", 5), "hello"); + assert_eq!(truncate("hello world", 5), "hell…"); + } + + #[test] + fn dialog_handle_key_esc_closes() { + let reg = fresh_registry(); + reg.lock() + .unwrap() + .add(JobKind::Copy, "a".into(), "b".into(), 0); + let mut dlg = JobsDialog::new(reg); + assert!(dlg.handle_key(Key::ESCAPE)); + assert!(dlg.handle_key(Key::from_char('q'))); + } + + #[test] + fn dialog_handle_key_navigation() { + let reg = fresh_registry(); + { + let mut g = reg.lock().unwrap(); + g.add(JobKind::Copy, "a".into(), "b".into(), 0); + g.add(JobKind::Copy, "c".into(), "d".into(), 0); + g.add(JobKind::Copy, "e".into(), "f".into(), 0); + } + let mut dlg = JobsDialog::new(reg); + assert_eq!(dlg.selected_index(), 0); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 1); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 2); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 2); + dlg.handle_key(Key { + code: 0x2191, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 1); + dlg.handle_key(Key { + code: 0x2191, + mods: crate::key::Modifiers::empty(), + }); + dlg.handle_key(Key { + code: 0x2191, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 0); + } + + #[test] + fn dialog_dismiss_removes_terminal_job() { + let reg = fresh_registry(); + let job = reg + .lock() + .unwrap() + .add(JobKind::Copy, "a".into(), "b".into(), 0); + job.set_state(JobState::Done); + let mut dlg = JobsDialog::new(Arc::clone(®)); + assert_eq!(dlg.len(), 1); + assert!(!dlg.handle_key(Key::ENTER)); + assert_eq!(dlg.len(), 0); + assert_eq!(reg.lock().unwrap().len(), 0); + } + + #[test] + fn dialog_dismiss_blocks_running_job() { + let reg = fresh_registry(); + reg.lock() + .unwrap() + .add(JobKind::Copy, "a".into(), "b".into(), 0); + let mut dlg = JobsDialog::new(reg); + assert!(!dlg.handle_key(Key::ENTER)); + assert_eq!(dlg.len(), 1); + assert!(!dlg.is_empty()); + } + + #[test] + fn dialog_retry_only_for_failed_or_cancelled() { + let reg = fresh_registry(); + let (j1, j2, j3) = { + let mut g = reg.lock().unwrap(); + let j1 = g.add(JobKind::Copy, "a".into(), "b".into(), 0); + let j2 = g.add(JobKind::Copy, "c".into(), "d".into(), 0); + let j3 = g.add(JobKind::Copy, "e".into(), "f".into(), 0); + (j1, j2, j3) + }; + j1.set_state(JobState::Failed("x".to_string())); + j2.set_state(JobState::Cancelled); + j3.set_state(JobState::Done); + let mut dlg = JobsDialog::new(Arc::clone(®)); + dlg.handle_key(Key::from_char('r')); + assert_eq!(j1.snapshot().state, JobState::Running); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + dlg.handle_key(Key::from_char('r')); + assert_eq!(j2.snapshot().state, JobState::Running); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + dlg.handle_key(Key::from_char('r')); + assert_eq!(j3.snapshot().state, JobState::Done); + } + + #[test] + fn dialog_refresh_cache_re_reads_registry() { + let reg = fresh_registry(); + reg.lock() + .unwrap() + .add(JobKind::Copy, "a".into(), "b".into(), 0); + let mut dlg = JobsDialog::new(Arc::clone(®)); + assert_eq!(dlg.len(), 1); + reg.lock() + .unwrap() + .add(JobKind::Move, "c".into(), "d".into(), 0); + assert_eq!(dlg.len(), 1); + dlg.refresh_cache(); + assert_eq!(dlg.len(), 2); + } + + #[test] + fn dialog_selected_clamps_after_dismiss() { + let reg = fresh_registry(); + let (a, b, c) = { + let mut g = reg.lock().unwrap(); + let a = g.add(JobKind::Copy, "a".into(), "b".into(), 0); + let b = g.add(JobKind::Copy, "c".into(), "d".into(), 0); + let c = g.add(JobKind::Copy, "e".into(), "f".into(), 0); + (a, b, c) + }; + a.set_state(JobState::Done); + b.set_state(JobState::Done); + c.set_state(JobState::Done); + let mut dlg = JobsDialog::new(Arc::clone(®)); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + dlg.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty(), + }); + assert_eq!(dlg.selected_index(), 2); + dlg.handle_key(Key::ENTER); + assert_eq!(dlg.len(), 2); + assert_eq!(dlg.selected_index(), 1); + } + + #[test] + fn dialog_with_size_clamps_to_unit_interval() { + let reg = fresh_registry(); + let dlg = JobsDialog::new(reg).with_size(0.5, 0.5); + assert!((dlg.width_pct - 0.5).abs() < f32::EPSILON); + assert!((dlg.height_pct - 0.5).abs() < f32::EPSILON); + let dlg2 = JobsDialog::new(fresh_registry()).with_size(2.0, 0.0); + assert!((dlg2.width_pct - 1.0).abs() < f32::EPSILON); + assert!((dlg2.height_pct - 0.1).abs() < f32::EPSILON); + } + + #[test] + fn dialog_render_does_not_panic() { + let reg = fresh_registry(); + reg.lock() + .unwrap() + .add(JobKind::Copy, "src".into(), "dst".into(), 100); + let dlg = JobsDialog::new(reg); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| dlg.render(f, f.area(), &theme)) + .expect("render must not panic"); + } + + #[test] + fn dialog_render_empty_does_not_panic() { + let dlg = JobsDialog::new(fresh_registry()); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| dlg.render(f, f.area(), &theme)) + .expect("render must not panic"); + } + + #[test] + fn spawn_copy_job_runs_to_done() { + use std::fs; + let src_dir = std::env::temp_dir().join("tlc-jobs-spawn-src"); + let dst_dir = std::env::temp_dir().join("tlc-jobs-spawn-dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + let src = src_dir.join("file.bin"); + let payload = vec![0xABu8; 4096]; + fs::write(&src, &payload).unwrap(); + + let mut reg = JobRegistry::new(); + let job = spawn_copy_job( + &mut reg, + vec![src.clone()], + dst_dir.clone(), + true, + false, + ); + wait_terminal(&job, 5_000); + assert_eq!(job.snapshot().state, JobState::Done); + let dst_file = dst_dir.join("file.bin"); + assert!(dst_file.is_file()); + assert_eq!(fs::read(&dst_file).unwrap(), payload); + let snap = job.snapshot(); + assert_eq!(snap.progress, snap.total); + assert!(snap.total >= payload.len() as u64); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn spawn_move_job_runs_to_done() { + use std::fs; + let src_dir = std::env::temp_dir().join("tlc-jobs-move-src"); + let dst_dir = std::env::temp_dir().join("tlc-jobs-move-dst"); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(&dst_dir).unwrap(); + let src = src_dir.join("m.bin"); + let payload = vec![0xCDu8; 1024]; + fs::write(&src, &payload).unwrap(); + + let mut reg = JobRegistry::new(); + let job = spawn_move_job(&mut reg, vec![src.clone()], dst_dir.clone(), true, false); + wait_terminal(&job, 5_000); + assert_eq!(job.snapshot().state, JobState::Done); + assert!(!src.exists()); + assert!(dst_dir.join("m.bin").is_file()); + let _ = fs::remove_dir_all(&src_dir); + let _ = fs::remove_dir_all(&dst_dir); + } + + #[test] + fn spawn_delete_job_runs_to_done() { + use std::fs; + let dir = std::env::temp_dir().join("tlc-jobs-del"); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let f = dir.join("doomed.txt"); + fs::write(&f, b"bye").unwrap(); + + let mut reg = JobRegistry::new(); + let job = spawn_delete_job(&mut reg, vec![f.clone()]); + wait_terminal(&job, 5_000); + assert_eq!(job.snapshot().state, JobState::Done); + assert!(!f.exists()); + let _ = fs::remove_dir_all(&dir); + } + + fn wait_terminal(job: &Arc, timeout_ms: u64) { + let mut waited_ms: u64 = 0; + loop { + let s = job.snapshot().state; + if s.is_terminal() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + waited_ms += 25; + assert!( + waited_ms < timeout_ms, + "worker never finished in {timeout_ms}ms, state={s:?}" + ); + } + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs index 2af02810e0..e096aa9ba3 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mkdir_dialog.rs @@ -21,7 +21,7 @@ use ratatui::Frame; use crate::key::Key; use crate::terminal::color::Theme; -use crate::terminal::popup::{centered_cols_rect, render_popup}; +use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup}; use crate::widget::input::Input; /// F7 mkdir dialog. @@ -140,7 +140,7 @@ impl MkDirDialog { /// `theme` supplies the title, header, and hint colours so the /// dialog follows the active skin. pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { - let popup = centered_cols_rect(area, (area.width / 2).max(34), 8); + let popup = centered_cols_rect(area, (area.width / 2).max(34), 9); let inner = render_popup(frame, popup, crate::locale::t("dialog_title_mkdir"), theme); let chunks = Layout::default() @@ -148,6 +148,7 @@ impl MkDirDialog { .constraints([ Constraint::Length(1), // header Constraint::Length(3), // input + Constraint::Length(1), // buttons Constraint::Min(1), // hint ]) .split(inner); @@ -169,19 +170,27 @@ impl MkDirDialog { input = input.focused(); input.render(frame, chunks[1], theme); + render_button_row( + frame, + chunks[2], + theme, + &crate::locale::t("dialog_action_ok"), + &crate::locale::t("dialog_action_cancel"), + ); + let hint = Line::from(vec![ Span::styled("Enter", Style::default().fg(theme.warning)), Span::styled( - format!(" {} ", crate::locale::t("dialog_action_create")), - Style::default().fg(theme.hidden), + format!(" {} ", crate::locale::t("dialog_action_ok")), + Style::default().fg(theme.foreground), ), Span::styled("Esc", Style::default().fg(theme.warning)), Span::styled( format!(" {}", crate::locale::t("dialog_action_cancel")), - Style::default().fg(theme.hidden), + Style::default().fg(theme.foreground), ), ]); - frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]); } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index a2b634d78f..2087a72277 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -6,14 +6,17 @@ pub mod cmdline; pub mod config_dialog; +pub mod confirm_dialog; pub mod connection_manager; pub mod copy_dialog; pub mod delete_dialog; pub mod exec; +pub mod external_panelize; pub mod find; pub mod help; pub mod hotlist; pub mod info; +pub mod jobs; pub mod layout_dialog; pub mod link; pub mod menubar; @@ -30,11 +33,14 @@ pub mod quit_dialog; pub mod quickcd_dialog; pub mod rename; pub mod skin_dialog; +pub mod sort_dialog; pub mod tree; pub mod usermenu; +pub mod vfs_list; pub mod mc_ext; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use anyhow::Result; use ratatui::layout::{Constraint, Direction, Layout, Rect}; @@ -122,6 +128,11 @@ pub struct FileManager { pub cfg: FilemanagerConfig, /// Manager for the currently running file operation (copy/move/delete/mkdir). pub ops_manager: crate::ops::OpsManager, + /// Background file-operation job registry. Shared with the + /// [`DialogState::Jobs`] dialog so both the file manager and the + /// dialog can register, observe, and remove jobs through the + /// same synchronised view. + pub jobs: Arc>, /// The name of the active skin (used to write the config back /// when the user picks a different skin at runtime). pub skin_name: String, @@ -229,6 +240,12 @@ pub enum DialogState { PanelOptions(Box), /// F9 → Options → Configuration — general config dialog. Config(Box), + /// C-x j — background file-operation jobs dialog. + Jobs(Box), + /// C-x ! — external panelize dialog. + ExternalPanelize(Box), + /// C-x a — active VFS connections list. + VfsList(Box), } impl DialogState { @@ -269,6 +286,13 @@ impl DialogState { DialogState::Layout(_) => false, DialogState::PanelOptions(_) => false, DialogState::Config(_) => false, + // The 3 newest dialogs (Jobs/ExternalPanelize/VfsList) are + // closed by the dispatcher in `handle_dialog_key` based + // on their `handle_key` return value; they never + // self-report finished here. + DialogState::Jobs(_) => false, + DialogState::ExternalPanelize(_) => false, + DialogState::VfsList(_) => false, } } } @@ -299,6 +323,7 @@ impl FileManager { status: StatusLine::default(), cfg: cfg.filemanager.clone(), ops_manager: crate::ops::OpsManager::new(), + jobs: Arc::new(Mutex::new(jobs::JobRegistry::new())), skin_name, dialog: None, cmdline: cmdline::Cmdline::new(), @@ -716,11 +741,25 @@ impl FileManager { ); Ok(true) } + Cmd::Jobs => { + let dlg = jobs::JobsDialog::new(Arc::clone(&self.jobs)); + self.dialog = Some(DialogState::Jobs(Box::new(dlg))); + Ok(true) + } + Cmd::Panelize => { + self.dialog = Some(DialogState::ExternalPanelize(Box::new( + external_panelize::ExternalPanelizeDialog::new(), + ))); + Ok(true) + } + Cmd::VfsList => { + self.dialog = Some(DialogState::VfsList(Box::new( + vfs_list::VfsListDialog::new(), + ))); + Ok(true) + } Cmd::SymlinkRelative | Cmd::SymlinkEdit - | Cmd::Panelize - | Cmd::Jobs - | Cmd::VfsList | Cmd::ScreenList | Cmd::EditHistory | Cmd::FilteredView => { @@ -1150,6 +1189,9 @@ impl FileManager { let mut layout_outcome: Option = None; let mut panel_outcome: Option = None; let mut config_outcome: Option = None; + let mut jobs_should_close = false; + let mut panelize_outcome: Option = None; + let mut vfs_outcome: Option = None; match &mut self.dialog { Some(DialogState::Info(_d)) => { // Info dialog consumes Enter and Esc (close). @@ -1235,6 +1277,18 @@ impl FileManager { config_outcome = Some(d.handle_key(key)); consumed = true; } + Some(DialogState::Jobs(d)) => { + jobs_should_close = d.handle_key(key); + consumed = true; + } + Some(DialogState::ExternalPanelize(d)) => { + panelize_outcome = Some(d.handle_key(key)); + consumed = true; + } + Some(DialogState::VfsList(d)) => { + vfs_outcome = Some(d.handle_key(key)); + consumed = true; + } None => return false, } // Apply captured outcomes. @@ -1262,6 +1316,15 @@ impl FileManager { if let Some(o) = config_outcome { self.apply_config_outcome(o); } + if jobs_should_close { + self.dialog = None; + } + if let Some(o) = panelize_outcome { + self.apply_external_panelize_outcome(o); + } + if let Some(o) = vfs_outcome { + self.apply_vfs_list_outcome(o); + } if let Some(d) = &self.dialog { if d.is_finished() { self.apply_finished_dialog(); @@ -1347,6 +1410,54 @@ impl FileManager { self.dialog = None; } + /// Apply an external-panelize dialog outcome. + /// + /// `Apply` is what the user wanted — the command produced one or + /// more paths. The dialog is closed and the path count is shown + /// on the status line. The active panel is not replaced yet: + /// `Panel::set_external_listing` is a future integration point + /// (the parsed paths are kept inside the dialog's + /// `ExternalPanelizeOutcome::Apply(Vec)` until the + /// panel-side support lands). + /// + /// `Empty` keeps the dialog open so the user can edit the + /// command; the dialog itself records the error in its + /// `last_error` field. `Cancel` (Esc) closes the dialog. + /// `Running` is the no-op case where the dialog stays open and + /// keeps consuming keys. + fn apply_external_panelize_outcome( + &mut self, + o: external_panelize::ExternalPanelizeOutcome, + ) { + use external_panelize::ExternalPanelizeOutcome; + match o { + ExternalPanelizeOutcome::Apply(paths) => { + self.status + .set_message(format!("panelize: {} path(s) captured", paths.len())); + self.dialog = None; + } + ExternalPanelizeOutcome::Cancel => { + self.dialog = None; + } + ExternalPanelizeOutcome::Empty | ExternalPanelizeOutcome::Running => {} + } + } + + /// Apply a VFS-list dialog outcome. The dialog is read-only — + /// `Cancel` (Esc) closes the dialog; `Running` (any other key) + /// leaves the dialog open and continues to consume keys. A future + /// enhancement may add a `Mount`/`Unmount` action that re-routes + /// the active panel. + fn apply_vfs_list_outcome(&mut self, o: vfs_list::VfsListOutcome) { + use vfs_list::VfsListOutcome; + match o { + VfsListOutcome::Cancel => { + self.dialog = None; + } + VfsListOutcome::Running => {} + } + } + /// Apply a find dialog outcome (Open/View/Edit/Cd path). fn apply_find_outcome(&mut self, o: find::FindOutcome) { use find::FindOutcome; @@ -1462,7 +1573,10 @@ impl FileManager { | Some(DialogState::UserMenu(_)) | Some(DialogState::Layout(_)) | Some(DialogState::PanelOptions(_)) - | Some(DialogState::Config(_)) => { + | Some(DialogState::Config(_)) + | Some(DialogState::Jobs(_)) + | Some(DialogState::ExternalPanelize(_)) + | Some(DialogState::VfsList(_)) => { // No-op: those dialogs clear themselves. } // The Help dialog also clears itself in `handle_dialog_key` @@ -1885,6 +1999,9 @@ impl FileManager { DialogState::Layout(d) => d.render(frame, area, &self.theme), DialogState::PanelOptions(d) => d.render(frame, area, &self.theme), DialogState::Config(d) => d.render(frame, area, &self.theme), + DialogState::Jobs(d) => d.render(frame, area, &self.theme), + DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme), + DialogState::VfsList(d) => d.render(frame, area, &self.theme), } } } diff --git a/local/recipes/tui/tlc/source/src/filemanager/panel.rs b/local/recipes/tui/tlc/source/src/filemanager/panel.rs index 968fe11d02..ccd918b69b 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/panel.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/panel.rs @@ -584,7 +584,15 @@ impl Panel { let mut kids = read_dir(path, self.show_hidden)?; entries.append(&mut kids); self.entries = entries; - self.cursor = 0; + let has_parent = self + .entries + .first() + .is_some_and(|e| e.name == ".."); + self.cursor = if has_parent && self.entries.len() > 1 { + 1 + } else { + 0 + }; self.top = 0; self.path = path.to_path_buf(); self.unmark_all(); diff --git a/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs b/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs new file mode 100644 index 0000000000..ef83166edb --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/sort_dialog.rs @@ -0,0 +1,311 @@ +//! Sort order dialog (sort by name/size/date, ascending/descending). + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::filemanager::panel::SortField; +use crate::key::Key; +use crate::terminal::color::Theme; + +const UP: u32 = 0x2191; +const DOWN: u32 = 0x2193; + +/// Resolution of a sort-dialog key event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SortResult { + /// User pressed Enter — apply the selected sort settings. + Confirm(SortSettings), + /// User pressed Escape — discard changes and close. + Cancel, + /// Dialog is still running (no terminal key yet). + Running, +} + +/// Snapshot of sort-field + direction chosen by the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SortSettings { + /// The sort field (Name / Extension / Size / Mtime). + pub field: SortField, + /// Whether to reverse the sort direction. + pub reverse: bool, +} + +const FIELDS: [SortField; 4] = [ + SortField::Name, + SortField::Extension, + SortField::Size, + SortField::Mtime, +]; + +const FIELD_LABELS: [&str; 4] = ["Name", "Extension", "Size", "Modify time"]; + +/// Modal dialog for choosing sort field and direction. +pub struct SortDialog { + selected: usize, + reverse: bool, + focus_reverse: bool, +} + +impl SortDialog { + /// Create a new dialog initialised from the given settings. + #[must_use] + pub fn new(initial: SortSettings) -> Self { + let selected = FIELDS + .iter() + .position(|f| *f == initial.field) + .unwrap_or(0); + Self { + selected, + reverse: initial.reverse, + focus_reverse: false, + } + } + + /// Snapshot the current dialog selections. + #[must_use] + pub fn settings(&self) -> SortSettings { + SortSettings { + field: FIELDS[self.selected], + reverse: self.reverse, + } + } + + /// Process a key event. Returns the dialog's resolution. + pub fn handle_key(&mut self, key: Key) -> SortResult { + if key == Key::ENTER { + return SortResult::Confirm(self.settings()); + } + if key == Key::ESCAPE { + return SortResult::Cancel; + } + if key == Key::TAB { + self.focus_next(); + return SortResult::Running; + } + if key.code == UP { + self.focus_prev(); + return SortResult::Running; + } + if key.code == DOWN { + self.focus_next(); + return SortResult::Running; + } + if let Some(ch) = char::from_u32(key.code) { + if ch == ' ' && self.focus_reverse { + self.reverse = !self.reverse; + return SortResult::Running; + } + } + SortResult::Running + } + + fn focus_next(&mut self) { + if !self.focus_reverse { + if self.selected < FIELDS.len() - 1 { + self.selected += 1; + } else { + self.focus_reverse = true; + } + } else { + self.focus_reverse = false; + self.selected = 0; + } + } + + fn focus_prev(&mut self) { + if self.focus_reverse { + self.focus_reverse = false; + self.selected = FIELDS.len() - 1; + } else if self.selected > 0 { + self.selected -= 1; + } else { + self.focus_reverse = true; + } + } + + /// Render the dialog centered on `area`. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let w = 36u16.min(area.width.saturating_sub(2)); + let h = 12u16.min(area.height.saturating_sub(2)); + let x = area.x + (area.width - w) / 2; + let y = area.y + (area.height - h) / 2; + let dlg = Rect::new(x, y, w, h); + frame.render_widget(Clear, dlg); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + " Sort Order ", + Style::default() + .fg(theme.title_fg) + .bg(theme.title_bg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.background).fg(theme.foreground)); + + let inner = block.inner(dlg); + frame.render_widget(block, dlg); + + let mut constraints = vec![Constraint::Length(1)]; + for _ in 0..FIELDS.len() { + constraints.push(Constraint::Length(1)); + } + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Min(0)); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints.as_slice()) + .split(inner); + + for (i, label) in FIELD_LABELS.iter().enumerate() { + let is_focused = i == self.selected && !self.focus_reverse; + let marker = if i == self.selected { "(*)" } else { "( )" }; + let style = if is_focused { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground) + }; + frame.render_widget( + Paragraph::new(Span::styled( + format!("{marker} {label}"), + style, + )), + chunks[1 + i], + ); + } + + let rev_idx = 1 + FIELDS.len(); + let rev_style = if self.focus_reverse { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground) + }; + let rev_marker = if self.reverse { "[x]" } else { "[ ]" }; + frame.render_widget( + Paragraph::new(Span::styled( + format!("{rev_marker} Reverse"), + rev_style, + )), + chunks[rev_idx], + ); + + frame.render_widget( + Paragraph::new(Span::styled( + " Tab/up-down: move Enter: OK Esc: cancel", + Style::default().fg(theme.hidden), + )), + chunks[rev_idx + 1], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Modifiers; + + fn settings() -> SortSettings { + SortSettings { + field: SortField::Name, + reverse: false, + } + } + + fn down_key() -> Key { + Key { + code: DOWN, + mods: Modifiers::empty(), + } + } + + fn up_key() -> Key { + Key { + code: UP, + mods: Modifiers::empty(), + } + } + + #[test] + fn sort_dialog_new_defaults_to_initial() { + let d = SortDialog::new(settings()); + assert_eq!(d.selected, 0); + assert!(!d.reverse); + } + + #[test] + fn sort_dialog_enter_confirms() { + let mut d = SortDialog::new(settings()); + match d.handle_key(Key::ENTER) { + SortResult::Confirm(s) => { + assert_eq!(s.field, SortField::Name); + assert!(!s.reverse); + } + other => panic!("expected Confirm, got {other:?}"), + } + } + + #[test] + fn sort_dialog_esc_cancels() { + let mut d = SortDialog::new(settings()); + assert_eq!(d.handle_key(Key::ESCAPE), SortResult::Cancel); + } + + #[test] + fn sort_dialog_down_moves_selection() { + let mut d = SortDialog::new(settings()); + d.handle_key(down_key()); + assert_eq!(d.selected, 1); + } + + #[test] + fn sort_dialog_tab_reaches_reverse() { + let mut d = SortDialog::new(settings()); + for _ in 0..4 { + d.handle_key(Key::TAB); + } + assert!(d.focus_reverse); + } + + #[test] + fn sort_dialog_wrap_from_last_to_reverse() { + let mut d = SortDialog::new(SortSettings { + field: SortField::Mtime, + reverse: false, + }); + d.handle_key(down_key()); + assert!(d.focus_reverse); + } + + #[test] + fn sort_dialog_up_from_reverse_to_last() { + let mut d = SortDialog::new(settings()); + d.focus_reverse = true; + d.handle_key(up_key()); + assert!(!d.focus_reverse); + assert_eq!(d.selected, FIELDS.len() - 1); + } + + #[test] + fn render_does_not_panic() { + let d = SortDialog::new(settings()); + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + d.render(f, f.area(), &crate::terminal::color::DEFAULT_THEME); + }) + .expect("render"); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/vfs_list.rs b/local/recipes/tui/tlc/source/src/filemanager/vfs_list.rs new file mode 100644 index 0000000000..9f7db850b6 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/vfs_list.rs @@ -0,0 +1,318 @@ +//! VFS List dialog (C-x a). +//! +//! Shows the currently active VFS connections. Remote VFS backends +//! (sftp, ftp) are optional features in TLC, so the list is empty +//! in the default build. The dialog still exists and renders the +//! empty state correctly so that operators can see at a glance that +//! no archive or remote is mounted. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::key::Key; +use crate::terminal::color::Theme; +use crate::terminal::popup::{centered_percent_rect, render_popup}; + +/// Outcome of a keypress in the VFS-list dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VfsListOutcome { + /// Still open; keep processing keys. + Running, + /// User pressed Esc — close the dialog. + Cancel, +} + +/// One active VFS connection, as the dialog renders it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VfsConnection { + /// VFS scheme name (e.g. `"local"`, `"tar"`, `"sftp"`). + pub scheme: String, + /// Human-readable label for the connection (e.g. the URL or + /// archive path). + pub label: String, +} + +impl VfsConnection { + /// Build a connection entry from a scheme name and label. + #[must_use] + pub fn new(scheme: impl Into, label: impl Into) -> Self { + Self { + scheme: scheme.into(), + label: label.into(), + } + } +} + +/// VFS List dialog. +pub struct VfsListDialog { + /// Currently active VFS connections. Empty in the default build. + connections: Vec, + /// Cursor row in the rendered list (always 0 in the empty case). + cursor: usize, +} + +impl VfsListDialog { + /// Create a new dialog with no connections. + #[must_use] + pub fn new() -> Self { + Self { + connections: Vec::new(), + cursor: 0, + } + } + + /// Create a new dialog from a pre-populated connection list. + #[must_use] + pub fn with_connections(connections: Vec) -> Self { + Self { + connections, + cursor: 0, + } + } + + /// All active connections. + #[must_use] + pub fn connections(&self) -> &[VfsConnection] { + &self.connections + } + + /// True if no connections are active. + #[must_use] + pub fn is_empty(&self) -> bool { + self.connections.is_empty() + } + + /// Number of active connections. + #[must_use] + pub fn len(&self) -> usize { + self.connections.len() + } + + /// Move the cursor up by one row. No-op if the list is empty. + pub fn cursor_up(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + /// Move the cursor down by one row. Clamped to the list length. + pub fn cursor_down(&mut self) { + if !self.connections.is_empty() && self.cursor + 1 < self.connections.len() { + self.cursor += 1; + } + } + + /// Handle a key event. Returns the resulting [`VfsListOutcome`]. + pub fn handle_key(&mut self, key: Key) -> VfsListOutcome { + match key { + Key::ESCAPE => VfsListOutcome::Cancel, + Key { code: 0x2191, .. } => { + self.cursor_up(); + VfsListOutcome::Running + } + Key { code: 0x2193, .. } => { + self.cursor_down(); + VfsListOutcome::Running + } + _ => VfsListOutcome::Running, + } + } + + /// Render the dialog into `frame`, centered on `area`. `theme` + /// supplies the title, list, and hint colours so the dialog + /// follows the active skin. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let title = "Active VFS list".to_string(); + let popup = centered_percent_rect(area, 0.6, 0.5); + let inner = render_popup(frame, popup, title, theme); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(2), + Constraint::Length(1), + ]) + .split(inner); + + let header = Line::from(vec![ + Span::styled("Active: ", Style::default().fg(theme.hidden)), + Span::styled( + self.connections.len().to_string(), + Style::default() + .fg(theme.foreground) + .add_modifier(Modifier::BOLD), + ), + ]); + frame.render_widget(Paragraph::new(header), chunks[0]); + + if self.connections.is_empty() { + let empty_msg = Line::from(Span::styled( + "(no active VFS connections)".to_string(), + Style::default().fg(theme.hidden), + )); + frame.render_widget(Paragraph::new(empty_msg), chunks[1]); + } else { + let items: Vec = self + .connections + .iter() + .enumerate() + .map(|(idx, c)| { + let display = format!("{} {}", c.scheme, c.label); + let style = if idx == self.cursor { + Style::default() + .fg(theme.cursor_fg) + .bg(theme.cursor_bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.foreground) + }; + ListItem::new(Span::styled(display, style)) + }) + .collect(); + let list = List::new(items); + frame.render_widget(list, chunks[1]); + } + + let hint = Line::from(vec![ + Span::styled("Esc", Style::default().fg(theme.warning)), + Span::styled( + format!(" {}", crate::locale::t("dialog_action_cancel")), + Style::default().fg(theme.hidden), + ), + ]); + frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]); + } +} + +impl Default for VfsListDialog { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dialog_new_is_empty() { + let d = VfsListDialog::new(); + assert!(d.is_empty()); + assert_eq!(d.len(), 0); + assert!(d.connections().is_empty()); + } + + #[test] + fn dialog_with_connections_keeps_list() { + let conns = vec![ + VfsConnection::new("tar", "/tmp/a.tar:/"), + VfsConnection::new("sftp", "alice@example.com:/home/alice"), + ]; + let d = VfsListDialog::with_connections(conns.clone()); + assert!(!d.is_empty()); + assert_eq!(d.len(), 2); + assert_eq!(d.connections(), &conns[..]); + } + + #[test] + fn dialog_handle_key_esc_returns_cancel() { + let mut d = VfsListDialog::new(); + assert_eq!(d.handle_key(Key::ESCAPE), VfsListOutcome::Cancel); + } + + #[test] + fn dialog_handle_key_running_for_other_keys() { + let mut d = VfsListDialog::new(); + assert_eq!(d.handle_key(Key::from_char('q')), VfsListOutcome::Running); + assert_eq!(d.handle_key(Key::ENTER), VfsListOutcome::Running); + } + + #[test] + fn dialog_cursor_movement_with_connections() { + let mut d = VfsListDialog::with_connections(vec![ + VfsConnection::new("tar", "/a"), + VfsConnection::new("tar", "/b"), + VfsConnection::new("zip", "/c"), + ]); + d.cursor_down(); + assert_eq!(d.cursor, 1); + d.cursor_down(); + assert_eq!(d.cursor, 2); + d.cursor_down(); + assert_eq!(d.cursor, 2); + d.cursor_up(); + assert_eq!(d.cursor, 1); + d.cursor_up(); + d.cursor_up(); + assert_eq!(d.cursor, 0); + } + + #[test] + fn dialog_cursor_movement_empty_is_noop() { + let mut d = VfsListDialog::new(); + d.cursor_down(); + assert_eq!(d.cursor, 0); + d.cursor_up(); + assert_eq!(d.cursor, 0); + } + + #[test] + fn dialog_cursor_keys_produce_running() { + let mut d = VfsListDialog::with_connections(vec![ + VfsConnection::new("tar", "/a"), + VfsConnection::new("tar", "/b"), + ]); + assert_eq!( + d.handle_key(Key { + code: 0x2193, + mods: crate::key::Modifiers::empty() + }), + VfsListOutcome::Running + ); + assert_eq!(d.cursor, 1); + assert_eq!( + d.handle_key(Key { + code: 0x2191, + mods: crate::key::Modifiers::empty() + }), + VfsListOutcome::Running + ); + assert_eq!(d.cursor, 0); + } + + #[test] + fn dialog_render_empty_does_not_panic() { + let d = VfsListDialog::new(); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| d.render(f, f.area(), &theme)) + .expect("render must not panic"); + } + + #[test] + fn dialog_render_with_connections_does_not_panic() { + let d = VfsListDialog::with_connections(vec![ + VfsConnection::new("tar", "/tmp/a.tar:/"), + VfsConnection::new("sftp", "alice@example.com:/home/alice"), + ]); + let backend = ratatui::backend::TestBackend::new(120, 30); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + let theme = crate::terminal::color::DEFAULT_THEME; + terminal + .draw(|f| d.render(f, f.area(), &theme)) + .expect("render must not panic"); + } + + #[test] + fn vfs_connection_constructs_correctly() { + let c = VfsConnection::new("zip", "/tmp/x.zip:/"); + assert_eq!(c.scheme, "zip"); + assert_eq!(c.label, "/tmp/x.zip:/"); + } +} diff --git a/local/recipes/tui/tlc/source/src/terminal/event.rs b/local/recipes/tui/tlc/source/src/terminal/event.rs index 2ad20d4a9a..e7a3dd28bc 100644 --- a/local/recipes/tui/tlc/source/src/terminal/event.rs +++ b/local/recipes/tui/tlc/source/src/terminal/event.rs @@ -108,6 +108,86 @@ pub const F10: Key = Key { /// Special Tab key. pub const TAB: Key = Key::TAB; +/// Parse a raw byte sequence that termion classified as `Unsupported` +/// and try to extract a function-key event. +/// +/// Some terminals (especially Linux console, screen, older terminal +/// emulators) send F-key sequences that termion does not recognize: +/// +/// | Key | xterm sequence | Linux console sequence | +/// |-----|-------------------|------------------------| +/// | F1 | `\x1bOP` | `\x1b[[A` | +/// | F2 | `\x1bOQ` | `\x1b[[B` | +/// | F3 | `\x1bOR` | `\x1b[[C` | +/// | F4 | `\x1bOS` | `\x1b[[D` | +/// | F5 | `\x1b[15~` | `\x1b[[E` | +/// | F6 | `\x1b[17~` | — | +/// | F7 | `\x1b[18~` | — | +/// | F8 | `\x1b[19~` | — | +/// | F9 | `\x1b[20~` | — | +/// | F10 | `\x1b[21~` | — | +/// | F11 | `\x1b[23~` | — | +/// | F12 | `\x1b[24~` | — | +/// +/// Termion parses the xterm `\x1b[NN~` form for F5-F12, but some +/// terminal emulators and multiplexers (tmux, screen) may produce +/// sequences that termion does not recognize. This fallback parser +/// covers both the `\x1b[NN~` (CSI-tilde) and `\x1b[[A` (console) +/// forms. +pub fn parse_unsupported_fkey(bytes: &[u8]) -> Option { + let seq = std::str::from_utf8(bytes).ok()?; + let seq = seq.strip_prefix('\x1b')?; + + if let Some(rest) = seq.strip_prefix('[') { + if let Some(rest) = rest.strip_prefix('[') { + let n = match rest { + "A" => 1, + "B" => 2, + "C" => 3, + "D" => 4, + "E" => 5, + _ => return None, + }; + return Some(TermKey::F(n)); + } + + if let Some(tilde_pos) = rest.find('~') { + let num_str = &rest[..tilde_pos]; + if let Ok(n) = num_str.parse::() { + let f = match n { + 11 => 1, + 12 => 2, + 13 => 3, + 14 => 4, + 15 => 5, + 17 => 6, + 18 => 7, + 19 => 8, + 20 => 9, + 21 => 10, + 23 => 11, + 24 => 12, + _ => return None, + }; + return Some(TermKey::F(f)); + } + } + } + + if let Some(rest) = seq.strip_prefix('O') { + let n = match rest { + "P" => 1, + "Q" => 2, + "R" => 3, + "S" => 4, + _ => return None, + }; + return Some(TermKey::F(n)); + } + + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/local/recipes/tui/tlc/source/src/terminal/subshell.rs b/local/recipes/tui/tlc/source/src/terminal/subshell.rs index dcf6e14666..8797aa208e 100644 --- a/local/recipes/tui/tlc/source/src/terminal/subshell.rs +++ b/local/recipes/tui/tlc/source/src/terminal/subshell.rs @@ -64,6 +64,7 @@ impl ShellManager { /// Run a one-shot shell command and return as soon as it exits. pub fn run_command(&mut self, cmd: &str, cwd: &Path) -> Result<()> { let shell = shell_path(); + log::info!("run_command: shell={shell} cmd={cmd:?} cwd={cwd:?}"); let status = Command::new(&shell) .arg("-c") .arg(cmd) diff --git a/local/recipes/tui/tlc/source/src/viewer/magic.rs b/local/recipes/tui/tlc/source/src/viewer/magic.rs new file mode 100644 index 0000000000..4d45999026 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/viewer/magic.rs @@ -0,0 +1,230 @@ +//! Magic-number file type detection for the viewer. + +use crate::viewer::ViewMode; + +const DETECTION_SIZE: usize = 4096; + +/// File type detected from magic-number probing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileKind { + /// Plain text (UTF-8 or ASCII). + Text, + /// Unknown binary format. + Binary, + /// ELF executable or object. + Elf, + /// PE/COFF executable (Windows .exe, .dll). + Pe, + /// gzip compressed data. + Gzip, + /// PNG image. + Png, + /// JPEG image. + Jpeg, + /// PDF document. + Pdf, + /// ZIP archive. + Zip, +} + +impl FileKind { + /// The default [`ViewMode`] for this file kind. + #[must_use] + pub fn default_mode(self) -> ViewMode { + match self { + FileKind::Text | FileKind::Pdf => ViewMode::Text, + _ => ViewMode::Hex, + } + } + + /// True if this file kind is not plain text. + #[must_use] + pub fn is_binary(self) -> bool { + !matches!(self, FileKind::Text) + } +} + +/// Probe the first bytes to determine the file kind. +#[must_use] +pub fn detect_kind(bytes: &[u8]) -> FileKind { + let len = bytes.len().min(DETECTION_SIZE); + if len == 0 { + return FileKind::Text; + } + + let head = &bytes[..len]; + + if head.len() >= 4 { + if &head[..4] == b"\x7fELF" { + return FileKind::Elf; + } + if &head[..2] == b"MZ" { + return FileKind::Pe; + } + if &head[..2] == b"\x1f\x8b" { + return FileKind::Gzip; + } + if &head[..4] == b"\x89PNG" { + return FileKind::Png; + } + if head.len() >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF { + return FileKind::Jpeg; + } + if &head[..4] == b"%PDF" { + return FileKind::Pdf; + } + if &head[..2] == b"PK" && head[2] == 0x03 && head[3] == 0x04 { + return FileKind::Zip; + } + } + + if looks_like_text(head) { + FileKind::Text + } else { + FileKind::Binary + } +} + +fn looks_like_text(bytes: &[u8]) -> bool { + let mut non_text = 0usize; + let total = bytes.len(); + + for &b in bytes { + if b == 0x00 { + return false; + } + if (b < 0x20 && !matches!(b, 0x09 | 0x0A | 0x0D)) || b == 0x7F { + non_text += 1; + } + } + + let ratio = non_text as f64 / total.max(1) as f64; + ratio < 0.30 +} + +/// Detect the best view mode from file content. +#[must_use] +pub fn detect_mode(bytes: &[u8]) -> ViewMode { + detect_kind(bytes).default_mode() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_empty_is_text() { + assert_eq!(detect_kind(b""), FileKind::Text); + } + + #[test] + fn detect_plain_text() { + assert_eq!(detect_kind(b"Hello, World!\n"), FileKind::Text); + } + + #[test] + fn detect_elf_binary() { + assert_eq!(detect_kind(b"\x7fELF\x02\x01\x01"), FileKind::Elf); + } + + #[test] + fn detect_pe_binary() { + assert_eq!(detect_kind(b"MZ\x90\x00\x03"), FileKind::Pe); + } + + #[test] + fn detect_gzip() { + assert_eq!(detect_kind(b"\x1f\x8b\x08\x00"), FileKind::Gzip); + } + + #[test] + fn detect_png() { + assert_eq!( + detect_kind(b"\x89PNG\r\n\x1a\n"), + FileKind::Png + ); + } + + #[test] + fn detect_jpeg() { + assert_eq!(detect_kind(b"\xFF\xD8\xFF\xE0"), FileKind::Jpeg); + } + + #[test] + fn detect_pdf() { + assert_eq!(detect_kind(b"%PDF-1.4\n"), FileKind::Pdf); + } + + #[test] + fn detect_zip() { + assert_eq!(detect_kind(b"PK\x03\x04"), FileKind::Zip); + } + + #[test] + fn detect_binary_with_null_bytes() { + assert_eq!( + detect_kind(b"\x00\x01\x02\x03\x04\x05"), + FileKind::Binary + ); + } + + #[test] + fn detect_binary_many_control_chars() { + let bytes = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + assert_eq!(detect_kind(&bytes), FileKind::Binary); + } + + #[test] + fn detect_utf8_text() { + let s = "Héllo, 世界 🎉\n".as_bytes(); + assert_eq!(detect_kind(s), FileKind::Text); + } + + #[test] + fn detect_source_code() { + assert_eq!( + detect_kind(b"fn main() {\n println!(\"hello\");\n}\n"), + FileKind::Text + ); + } + + #[test] + fn elf_defaults_to_hex() { + assert_eq!(FileKind::Elf.default_mode(), ViewMode::Hex); + } + + #[test] + fn text_defaults_to_text() { + assert_eq!(FileKind::Text.default_mode(), ViewMode::Text); + } + + #[test] + fn pdf_defaults_to_text() { + assert_eq!(FileKind::Pdf.default_mode(), ViewMode::Text); + } + + #[test] + fn gzip_defaults_to_hex() { + assert_eq!(FileKind::Gzip.default_mode(), ViewMode::Hex); + } + + #[test] + fn is_binary_true_for_elf() { + assert!(FileKind::Elf.is_binary()); + } + + #[test] + fn is_binary_false_for_text() { + assert!(!FileKind::Text.is_binary()); + } + + #[test] + fn detect_mode_routes_text_to_text() { + assert_eq!(detect_mode(b"hello\n"), ViewMode::Text); + } + + #[test] + fn detect_mode_routes_elf_to_hex() { + assert_eq!(detect_mode(b"\x7fELF"), ViewMode::Hex); + } +} diff --git a/local/recipes/tui/tlc/source/src/viewer/mod.rs b/local/recipes/tui/tlc/source/src/viewer/mod.rs index 88c64826a7..5a77d50225 100644 --- a/local/recipes/tui/tlc/source/src/viewer/mod.rs +++ b/local/recipes/tui/tlc/source/src/viewer/mod.rs @@ -10,6 +10,8 @@ pub mod goto; pub mod hex; +pub mod magic; +pub mod nroff; pub mod search; pub mod source; pub mod text; diff --git a/local/recipes/tui/tlc/source/src/viewer/nroff.rs b/local/recipes/tui/tlc/source/src/viewer/nroff.rs new file mode 100644 index 0000000000..602a044edf --- /dev/null +++ b/local/recipes/tui/tlc/source/src/viewer/nroff.rs @@ -0,0 +1,221 @@ +//! NROFF-style backspace-overstrike rendering for man pages. + +use ratatui::style::Modifier; + +/// Text emphasis style produced by nroff overstriking. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NroffStyle { + /// Bold (char + BS + same char). + Bold, + /// Underline (_ + BS + char). + Underline, +} + +/// A byte range with an associated nroff style. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NroffRange { + /// Start byte offset (inclusive). + pub start: usize, + /// End byte offset (exclusive). + pub end: usize, + /// The emphasis style. + pub style: NroffStyle, +} + +/// Detect whether `text` contains backspace overstrike sequences. +#[must_use] +pub fn has_nroff_sequences(text: &str) -> bool { + text.contains('\u{0008}') +} + +/// Strip nroff backspace sequences, returning clean text + style ranges. +#[must_use] +pub fn process_nroff(text: &str) -> (String, Vec) { + let chars: Vec = text.chars().collect(); + let mut out_chars: Vec = Vec::with_capacity(chars.len()); + let mut char_styles: Vec> = Vec::with_capacity(chars.len()); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '\u{0008}' && !out_chars.is_empty() && i + 1 < chars.len() { + let prev = out_chars.len() - 1; + let prev_char = out_chars[prev]; + let next_char = chars[i + 1]; + + if prev_char == next_char { + char_styles[prev] = Some(NroffStyle::Bold); + i += 2; + } else if prev_char == '_' { + out_chars[prev] = next_char; + char_styles[prev] = Some(NroffStyle::Underline); + i += 2; + } else { + out_chars.pop(); + char_styles.pop(); + i += 1; + } + } else if chars[i] == '\u{0008}' { + i += 1; + } else { + out_chars.push(chars[i]); + char_styles.push(None); + i += 1; + } + } + + let mut ranges = Vec::new(); + let mut byte_pos = 0; + let mut current_style: Option = None; + let mut range_start = 0; + + for (ch, &style_opt) in out_chars.iter().zip(char_styles.iter()) { + let byte_len = ch.len_utf8(); + + if style_opt != current_style { + if let Some(s) = current_style { + if byte_pos > range_start { + ranges.push(NroffRange { + start: range_start, + end: byte_pos, + style: s, + }); + } + } + current_style = style_opt; + range_start = byte_pos; + } + + byte_pos += byte_len; + } + + if let Some(s) = current_style { + if byte_pos > range_start { + ranges.push(NroffRange { + start: range_start, + end: byte_pos, + style: s, + }); + } + } + + let result: String = out_chars.into_iter().collect(); + (result, ranges) +} + +/// Convert an [`NroffStyle`] to a ratatui [`Modifier`] bitmask. +#[must_use] +pub fn modifier_for(style: NroffStyle) -> Modifier { + match style { + NroffStyle::Bold => Modifier::BOLD, + NroffStyle::Underline => Modifier::UNDERLINED, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_nroff_present() { + assert!(has_nroff_sequences("a\u{0008}a")); + } + + #[test] + fn detect_nroff_absent() { + assert!(!has_nroff_sequences("hello world")); + } + + #[test] + fn process_bold_sequence() { + let (text, ranges) = process_nroff("a\u{0008}abc"); + assert_eq!(text, "abc"); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].end, 1); + assert_eq!(ranges[0].style, NroffStyle::Bold); + } + + #[test] + fn process_underline_sequence() { + let (text, ranges) = process_nroff("_\u{0008}a"); + assert_eq!(text, "a"); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].style, NroffStyle::Underline); + } + + #[test] + fn process_multiple_bold() { + let input = "h\u{0008}he\u{0008}el\u{0008}ll\u{0008}lo\u{0008}o"; + let (text, ranges) = process_nroff(input); + assert_eq!(text, "hello"); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].end, 5); + assert_eq!(ranges[0].style, NroffStyle::Bold); + } + + #[test] + fn process_mixed_bold_underline() { + let input = "_\u{0008}Ubold\u{0008}d"; + let (text, ranges) = process_nroff(input); + assert_eq!(text, "Ubold"); + assert_eq!(ranges.len(), 2); + assert_eq!(ranges[0], NroffRange { start: 0, end: 1, style: NroffStyle::Underline }); + assert_eq!(ranges[1], NroffRange { start: 4, end: 5, style: NroffStyle::Bold }); + } + + #[test] + fn process_no_sequences() { + let (text, ranges) = process_nroff("hello world"); + assert_eq!(text, "hello world"); + assert!(ranges.is_empty()); + } + + #[test] + fn process_empty() { + let (text, ranges) = process_nroff(""); + assert_eq!(text, ""); + assert!(ranges.is_empty()); + } + + #[test] + fn process_lone_backspace() { + let (text, _ranges) = process_nroff("\u{0008}"); + assert_eq!(text, ""); + } + + #[test] + fn process_overwrite_sequence() { + let (text, _ranges) = process_nroff("a\u{0008}b"); + assert_eq!(text, "b"); + } + + #[test] + fn process_real_man_page_fragment() { + let input = "NA\u{0008}AME\u{0008}E\n t\u{0008}tlc\u{0008}c"; + let (text, ranges) = process_nroff(input); + assert!(text.contains("NAME")); + assert!(text.contains("tlc")); + assert!(!ranges.is_empty()); + } + + #[test] + fn modifier_bold() { + assert_eq!(modifier_for(NroffStyle::Bold), Modifier::BOLD); + } + + #[test] + fn modifier_underline() { + assert_eq!(modifier_for(NroffStyle::Underline), Modifier::UNDERLINED); + } + + #[test] + fn ranges_byte_offsets_match_string() { + let input = "_\u{0008}é\u{0008}é"; + let (text, ranges) = process_nroff(input); + assert_eq!(text, "é"); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, 0); + assert_eq!(ranges[0].end, 2); // 'é' is 2 bytes in UTF-8 + } +}