|
| 1 | +# Implementation Plan 3 — Performance & Fluency |
| 2 | + |
| 3 | +Goal: quick view appears on the same frame as the keypress for normal files; all async |
| 4 | +results land without artificial delay; redraws are one syscall. Items are ordered — |
| 5 | +implement top to bottom, each is independently shippable. |
| 6 | + |
| 7 | +## 1. Kill the 100ms async-result delay |
| 8 | + |
| 9 | +**Problem:** `run()` blocks in `event::poll(100ms)` (`event_loop.rs:73`), and |
| 10 | +`handle_async_messages` (`event_loop.rs:781`) runs only after the poll returns and drains |
| 11 | +**one** message per iteration. Every async result (quick view, search, transfer progress, |
| 12 | +dir sizes) waits up to 100ms in the channel. |
| 13 | + |
| 14 | +**Change (`event_loop.rs`):** |
| 15 | +- Poll timeout `100ms` → `15ms`: `event::poll(std::time::Duration::from_millis(15))`. |
| 16 | +- In `handle_async_messages`, change `if let Ok(message) = receiver.try_recv()` to |
| 17 | + `while let Ok(message) = receiver.try_recv()`. Each arm already redraws; that is |
| 18 | + acceptable (bursts are rare) — do NOT restructure the arms. |
| 19 | +- Onboarding auto-dismiss at `event_loop.rs:53` uses elapsed time, unaffected. |
| 20 | + |
| 21 | +**Test:** none practical (loop timing). Rely on existing suite passing. |
| 22 | + |
| 23 | +## 2. Synchronous fast path for small text quick view |
| 24 | + |
| 25 | +**Problem:** `schedule_quick_view` (`input_handler.rs:243`) always sets |
| 26 | +`QuickViewMode::Loading` + spawns a thread → spinner flash even for tiny files. Also hit |
| 27 | +on Left/Right file flipping (`event_loop.rs:539`). |
| 28 | + |
| 29 | +**Change (`input_handler.rs`, in `schedule_quick_view`):** |
| 30 | +```rust |
| 31 | +const SYNC_PREVIEW_MAX: u64 = 1024 * 1024; // 1 MB |
| 32 | + |
| 33 | +if let Some(path) = panel.get_selected_path() { |
| 34 | + let small = std::fs::metadata(&path) |
| 35 | + .map(|m| m.is_file() && m.len() <= SYNC_PREVIEW_MAX) |
| 36 | + .unwrap_or(false); |
| 37 | + let is_image = quick_view::looks_like_image(&path); // new helper, see below |
| 38 | + if small && !is_image { |
| 39 | + panel.mode = PanelMode::QuickView(quick_view::preview(path, columns)); |
| 40 | + return; |
| 41 | + } |
| 42 | + // existing Loading + thread::spawn path unchanged |
| 43 | +} |
| 44 | +``` |
| 45 | +- Add `pub fn looks_like_image(path: &Path) -> bool` in `quick_view.rs`: returns true for |
| 46 | + `raw_image::supports_path(path)` or an image extension (reuse `classify_extension`). |
| 47 | + Images stay async (decode can be slow even under 1MB). |
| 48 | +- Directories: `metadata.is_file()` is false → stay async (network mounts can hang). |
| 49 | + Local dirs are fast but the thread path is now only ~15ms behind (item 1). Fine. |
| 50 | +- Caller draws after input handling already, so the panel mode set here renders |
| 51 | + immediately — verify no extra draw needed (mirror what the `QuickViewResult` arm does). |
| 52 | + |
| 53 | +**Test:** unit test in `input_handler.rs`: small temp .txt file, call |
| 54 | +`schedule_quick_view`, assert `panel.mode` is `QuickView(Text {..})` immediately (no |
| 55 | +Loading), and that nothing arrives on the channel. |
| 56 | + |
| 57 | +## 3. Cap text preview size |
| 58 | + |
| 59 | +**Problem:** `preview_text` (`quick_view.rs:79`) reads, wraps, and highlights the whole |
| 60 | +file — O(file size), only a screenful is shown. |
| 61 | + |
| 62 | +**Change (`quick_view.rs`):** |
| 63 | +```rust |
| 64 | +const TEXT_PREVIEW_MAX_BYTES: u64 = 1024 * 1024; // 1 MB |
| 65 | +const TEXT_PREVIEW_MAX_LINES: usize = 5_000; |
| 66 | +``` |
| 67 | +- Replace `fs::read_to_string(path)` with: open file, `take(TEXT_PREVIEW_MAX_BYTES)`, |
| 68 | + read to `Vec<u8>`, `String::from_utf8_lossy`. If the file was larger (compare |
| 69 | + `metadata.len()`), trim the last (possibly split) line. |
| 70 | +- After wrapping, `lines.truncate(TEXT_PREVIEW_MAX_LINES)`. |
| 71 | +- If either cap hit, push a final line: `"… preview truncated"`. |
| 72 | +- `highlight()` now runs on capped input — no separate change needed. |
| 73 | + |
| 74 | +**Test:** write a 2MB file of `"word "` repeats, assert preview returns, last line is the |
| 75 | +truncation marker, and `lines.len() <= TEXT_PREVIEW_MAX_LINES + 1`. |
| 76 | + |
| 77 | +## 4. Replace tree_magic_mini with extension list + NUL sniff |
| 78 | + |
| 79 | +**Problem:** `get_type` (`quick_view.rs:60`) whitelist covers only txt/md/rs/toml; every |
| 80 | +other file hits `tree_magic_mini::from_filepath` (loads system MIME magic DB, reads file). |
| 81 | + |
| 82 | +**Change (`quick_view.rs`):** |
| 83 | +- Expand `classify_extension` text arm: |
| 84 | + `txt md markdown rs toml json yaml yml js ts jsx tsx py rb go c h cpp hpp java kt swift |
| 85 | + sh bash zsh fish css scss html xml svg sql lock cfg conf ini env gitignore log csv tsv` |
| 86 | + (svg is not in the image arm today; classify it as Text — markup preview). |
| 87 | +- Replace the `tree_magic_mini` fallback with: |
| 88 | +```rust |
| 89 | +fn sniff_is_text(path: &Path) -> bool { |
| 90 | + let mut buf = [0u8; 8192]; |
| 91 | + let Ok(mut f) = fs::File::open(path) else { return false }; |
| 92 | + let Ok(n) = f.read(&mut buf) else { return false }; |
| 93 | + n > 0 && !buf[..n].contains(&0) |
| 94 | +} |
| 95 | +``` |
| 96 | + `get_type` fallback order: known extension → `sniff_is_text` → `FileType::Other`. |
| 97 | + Extensionless files (Makefile, LICENSE) now preview via the sniff. |
| 98 | +- Remove `tree_magic_mini` from `Cargo.toml` and the `use`. |
| 99 | + |
| 100 | +**Test:** existing `test_get_type_*` tests must pass unchanged (data.bin test writes |
| 101 | +bytes without NUL — `[0u8, ...]` starts with NUL, still passes). Add: extensionless file |
| 102 | +with plain ASCII → Text; file starting with `\0\0` → Other. |
| 103 | + |
| 104 | +## 5. Decode images once in preview_image |
| 105 | + |
| 106 | +**Problem:** `preview_image` (`quick_view.rs:179`) decodes up to 3×: `extract_thumbnail`, |
| 107 | +`load_from_memory(&buf)` (existence check), then `downscale_image_if_needed` decodes the |
| 108 | +same bytes again. |
| 109 | + |
| 110 | +**Change (`quick_view.rs`):** |
| 111 | +```rust |
| 112 | +fn preview_image(path: &Path, wrap_width: u16) -> QuickViewMode { |
| 113 | + if let Some(raw_bytes) = raw_image::try_render_from_raw(path) { |
| 114 | + return QuickViewMode::Image(raw_bytes); |
| 115 | + } |
| 116 | + let Ok(buf) = fs::read(path) else { return QuickViewMode::NotSupported }; |
| 117 | + let Some(img) = extract_thumbnail(path).or_else(|| load_from_memory(&buf).ok()) else { |
| 118 | + return QuickViewMode::NotSupported; |
| 119 | + }; |
| 120 | + let target = terminal_pixel_limit(wrap_width); |
| 121 | + if img.width() <= target && img.height() <= target { |
| 122 | + return QuickViewMode::Image(buf); |
| 123 | + } |
| 124 | + let resized = imageops::thumbnail(&img, target, target); |
| 125 | + let mut output = Vec::new(); |
| 126 | + match JpegEncoder::new_with_quality(&mut output, 80).encode_image(&resized) { |
| 127 | + Ok(_) => QuickViewMode::Image(output), |
| 128 | + Err(_) => QuickViewMode::Image(buf), |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | +- Delete `downscale_image_if_needed`; drop the `.to_rgb8()` existence check. |
| 133 | +- Note the thumbnail branch changes behavior slightly: if EXIF thumbnail exists it is |
| 134 | + used for dimension check AND as resize source — acceptable, it was already preferred. |
| 135 | + |
| 136 | +**Test:** existing `preview_image_downscales_large_images` must still pass. |
| 137 | + |
| 138 | +## 6. Buffer stdout writes |
| 139 | + |
| 140 | +**Problem:** renderer `queue!`s into raw `std::io::Stdout` (`renderer.rs:119`) — a full |
| 141 | +redraw is many small write syscalls. |
| 142 | + |
| 143 | +**Change (`renderer.rs`):** |
| 144 | +- `StdoutKind::Real(std::io::Stdout)` → `Real(std::io::BufWriter<std::io::Stdout>)`. |
| 145 | +- Construction: `StdoutKind::Real(std::io::BufWriter::with_capacity(256 * 1024, std::io::stdout()))`. |
| 146 | +- `Write` impl arms unchanged (`BufWriter` implements `Write`); `flush()` already called |
| 147 | + at end of every draw (`renderer.rs:166,193`) — audit that **every** public draw entry |
| 148 | + point ends with `self.stdout.flush()`; add where missing (grep `fn draw`). |
| 149 | +- `reset_terminal` (`renderer.rs:144`) uses `execute!(std::io::stdout(), ...)` directly |
| 150 | + for mouse capture — leave those, they self-flush; keep the final `self.stdout.flush()`. |
| 151 | + |
| 152 | +**Test:** existing renderer tests use `TestWriter`, unaffected. |
| 153 | + |
| 154 | +## 7. Generation counter for quick view results (do with item 2) |
| 155 | + |
| 156 | +**Problem:** rapid Left/Right spawns parallel preview threads; a slow older result can |
| 157 | +overwrite a newer one. |
| 158 | + |
| 159 | +**Change:** |
| 160 | +- `PanelState`: add `pub quick_view_generation: u64` (init 0 in `new()`). |
| 161 | +- `schedule_quick_view`: `panel.quick_view_generation += 1;` capture the value, include |
| 162 | + it in `Message::QuickViewResult { pane, generation, mode }` (extend the enum). |
| 163 | +- Sync fast path (item 2) also bumps the generation, so an in-flight async result from a |
| 164 | + previous file is discarded. |
| 165 | +- `handle_async_messages` `QuickViewResult` arm: apply only if |
| 166 | + `generation == panel.quick_view_generation`, else drop silently. |
| 167 | + |
| 168 | +**Test:** unit test: bump generation after send, deliver stale message via the handler |
| 169 | +path, assert panel mode unchanged. |
| 170 | + |
| 171 | +## Explicitly out of scope |
| 172 | + |
| 173 | +ratatui migration / diffed rendering, syntect highlighting, async runtime for the UI, |
| 174 | +`list_dir` stat batching. Revisit only if the above measurably falls short. |
0 commit comments