Skip to content

Commit e02f964

Browse files
ioma8claude
andcommitted
Suspend TUI for terminal editors; add hidden toggle, sorting, bat previews
F4/Ctrl+O now hands the terminal to $EDITOR via renderer.suspend() (cooked mode, blocking .status() call, raw mode + size restored on exit) instead of spawning it over the running UI. Also: Ctrl+H dotfile toggle, dirs-first case-insensitive sorting, optional bat highlighting, README/ CHANGELOG/demo refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d3f7a9 commit e02f964

17 files changed

Lines changed: 752 additions & 128 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/target
22
log.txt
33
.DS_Store
4+
RELEASE_PLAN.md

CHANGELOG.md

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- Initial release of Fishez terminal file manager
12-
- Keyboard-driven terminal interface
13-
- Quick view with syntax highlighting
14-
- Multi-select functionality
15-
- Delete files to trash
16-
- Open files in VS Code with F4
17-
- Fuzzy search with fd (F6)
18-
- Content search with ripgrep (F7)
19-
- Favorites system (Ctrl+D)
20-
- Dual-pane mode support
11+
- `fz` cd-on-exit shell wrapper via `fishez --init`
12+
- Start path support, e.g. `fishez ~/projects`
13+
- Safe background copy/move with progress and overwrite prompts
14+
- Shell command overlay with `{1}` for the focused path and `{@}` for selected paths
15+
- OSC52 path copy for terminal clipboard workflows
16+
- Hidden-file toggle with `Ctrl+H`
17+
- Optional `bat` integration for richer text previews
2118

2219
### Features
20+
- Keyboard-driven terminal file manager
21+
- Quick view for text, directories, and terminal-supported images
22+
- Open files in `$EDITOR` with F4, falling back to VS Code
23+
- Fuzzy search with fd (Ctrl+F)
24+
- Content search with ripgrep (Ctrl+R)
25+
- Favorites system (Ctrl+D), stored in `~/.fishez/favorites.txt`
2326
- Two-pane mode for comparing directories
24-
- Image preview in supported terminals (iTerm2, kitty)
25-
- Directory creation and renaming (placeholder)
26-
- Command palette (placeholder)
27-
- Batch operations panel (placeholder)
27+
- Directory creation, rename, trash delete, copy, and move operations
2828
- Structured logging infrastructure
2929
- Clean Architecture implementation
3030

@@ -41,16 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4141
- fd integration for fuzzy file search
4242
- ripgrep integration for content search
4343
- Favorites management system
44-
- Command palette framework
45-
- Batch operations panel framework
4644

4745
### Dependencies
4846
- crossterm 0.28.1 - Terminal UI
49-
- clipboard 0.5.0 - Clipboard operations
5047
- trash 5.2.0 - Safe file deletion
5148
- image 0.25.5 - Image preview
5249
- textwrap 0.16.2 - Text wrapping
53-
- tree_magic_mini 3.1.6 - File type detection
5450

5551
### Architecture
5652
- Clean Architecture layers (domain, application, infrastructure, presentation)
@@ -68,21 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6864

6965
## Version History
7066

71-
### Upcoming
72-
- **v0.2.0** - Planned improvements
73-
- Directory creation/rename
74-
- Command palette
75-
- Batch operations
76-
- Image preview improvements
77-
- Better error handling
78-
- Config file support
79-
80-
### Known Limitations
81-
- Image preview requires iTerm2 or kitty terminal
82-
- External dependencies required: fd, ripgrep, VS Code CLI
83-
- No command palette implementation yet
84-
- No batch operations UI yet
85-
- No directory creation UI yet
67+
### Current Notes
68+
- Image preview works best in iTerm2 or kitty terminals.
69+
- `fd`, `ripgrep`, `bat`, and the VS Code CLI are optional external tools.
8670

8771
---
8872

IMPLEMENTATION_PLAN3.md

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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

Comments
 (0)