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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.DS_Store
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Middle Manager is a dual-panel TUI file manager written in Rust, inspired by Far Manager/Norton Commander. Built on ratatui + crossterm. Designed for large files — viewer, hex viewer, and editor use sliding buffers and lazy scanning (10 GB files open instantly).

Binary is installed as `mm`.

## Build & Test Commands

```bash
cargo build # dev build
cargo build --release # release build
cargo test # all tests
cargo test test_name # single test
cargo test test_name -- --nocapture # with stdout
cargo fmt --check # format check
cargo fmt # format fix
cargo clippy -- -D warnings # lint (CI enforces -D warnings)
```

CI runs: fmt check, clippy with `-D warnings`, tests on both Ubuntu and macOS.

## Architecture

**Single crate, no workspace.** ~50 source files, ~400+ inline unit tests.

### Action-Dispatch Pattern
Key presses → `Action` enum (action.rs, 120+ variants) → `App` handlers (app.rs). This decouples input handling from business logic. All user intents go through this enum.

### App State Machine (app.rs)
Central struct holding: two `Panel`s (left/right), active panel index, `PanelFocus` (file panel, CI, shell, Claude, diff, search), `AppMode` (Normal, Viewer, Editor, etc.), 20+ overlay states (dialogs, prompts). This is the largest file in the codebase.

### Large-File Handling
- **ViewerState** (viewer.rs): Sliding buffer (~10K lines in memory), sparse line index (every 1000 lines) for O(1) seeking
- **EditorState** (editor.rs): Line-level piece table — unmodified segments reference disk by byte offset, only edited lines in memory. Save copies unmodified byte ranges directly.
- **HexViewerState** (hex_viewer.rs): 256 KB sliding buffer
- **Search**: Streams 4 MB chunks, seeks directly to cursor position via sparse index. No full-file scan.

### Custom VT Terminal Emulator (vt/)
Replaces vt100 crate for full control. Ring buffer grid for O(1) scrolling, 10K-line scrollback. Handles SGR, OSC, DEC private modes. Used by Shell Panel (Ctrl+O) and Claude Code Panel (F12).

### Event System (event.rs)
Background thread polls keyboard/mouse/resize. Coalescing wakeup mechanism: multiple PTY output signals collapse to one event, preventing event loop flooding. `WakeupSender` passed to background threads.

### Module Organization
- `panel/` — Panel state, directory reading, entry metadata, sorting, git status cache, GitHub PR queries (via `gh` CLI)
- `vt/` — VT parser, screen state, ring buffer grid, cell/attribute storage, color types
- `fs_ops/` — Copy/move/delete/mkdir/rename, archive creation (tar.zst/gz/xz, zip)
- `ui/` — All rendering: one `*_view.rs` per mode, dialog helpers, footer, header, shadows. Rendering is strictly separated from logic.
- Top-level modules: `ci.rs` (CI panel), `terminal.rs` (PTY lifecycle), `syntax.rs` (tree-sitter highlighting), `theme.rs` (centralized Far Manager blue color scheme), `state.rs` (persistent JSON state at `~/.config/middle-manager/state.json`), `text_input.rs` (reusable input widget with selection/undo/redo), `file_search.rs` (ripgrep-powered search)

### Key Patterns
- **Syntax highlighting** (syntax.rs): Tree-sitter with hybrid caching — files < 10 MB get full parse cached; ≥ 10 MB use context-window (200 lines before viewport)
- **Git integration** (panel/git.rs): `GitCache` shared across both panels, async queries, 30-second refresh, `--no-optional-locks` to avoid index.lock conflicts
- **Persistent state** (state.rs): Panel paths, sort prefs, search queries, open panels, split sizes survive restarts
- **Background threading**: Git status, PR queries, CI fetches, file search, archive compression — all non-blocking with `WakeupSender` for UI updates

### Key Dependencies
- `ratatui` 0.30 + `crossterm` 0.28 — TUI framework
- `tree-sitter` + language grammars — syntax highlighting
- `portable-pty` — PTY spawning for shell/Claude panels
- `ignore` + `grep-regex`/`grep-searcher` — ripgrep's search engine
- `parquet2` (QuestDB fork from git) — Parquet file reading
- `notify` — filesystem watcher (kqueue/inotify)
- `tar`/`flate2`/`xz2`/`zstd`/`zip` — archive compression
1 change: 1 addition & 0 deletions src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub enum Action {
// Search (editor)
SearchPrompt,
FindNext,
FindPrev,

// Word navigation (editor)
WordLeft,
Expand Down
111 changes: 97 additions & 14 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ pub struct App {
pub archive_progress: Option<ArchiveProgress>,
/// Stashed diff viewer context for F4 editor↔diff toggle.
pub stashed_diff: Option<StashedDiff>,
/// Stashed focus to restore when exiting editor (e.g. back to CI panel).
pub stashed_focus: Option<PanelFocus>,
}

pub struct StashedDiff {
Expand Down Expand Up @@ -994,6 +996,7 @@ impl App {
dialog_content_area: None,
archive_progress: None,
stashed_diff: None,
stashed_focus: None,
}
}

Expand Down Expand Up @@ -2098,7 +2101,11 @@ impl App {
}

fn map_viewer_key(&self, key: KeyEvent) -> Action {
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
// Opt+a/e on Mac → top/bottom of file (reliable across all terminals)
KeyCode::Char('a') if alt => Action::MoveToTop,
KeyCode::Char('e') if alt => Action::MoveToBottom,
KeyCode::Up => Action::MoveUp,
KeyCode::Down => Action::MoveDown,
KeyCode::PageUp => Action::PageUp,
Expand All @@ -2107,6 +2114,9 @@ impl App {
KeyCode::End => Action::MoveToBottom,
KeyCode::Tab | KeyCode::F(4) => Action::Toggle, // switch text <-> hex
KeyCode::Char('g') => Action::GotoLinePrompt,
KeyCode::Char('f') => Action::SearchPrompt,
KeyCode::Char('n') => Action::FindNext,
KeyCode::Char('b') => Action::FindPrev,
KeyCode::Char('q') | KeyCode::Esc => Action::DialogCancel,
_ => Action::None,
}
Expand Down Expand Up @@ -2151,7 +2161,11 @@ impl App {
}

fn map_parquet_key(&self, key: KeyEvent) -> Action {
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
// Opt+a/e on Mac → top/bottom of file (reliable across all terminals)
KeyCode::Char('a') if alt => Action::MoveToTop,
KeyCode::Char('e') if alt => Action::MoveToBottom,
KeyCode::Up => Action::MoveUp,
KeyCode::Down => Action::MoveDown,
KeyCode::Left => Action::CursorLeft,
Expand All @@ -2178,6 +2192,8 @@ impl App {
KeyCode::Char('c') => Action::CopySelection,
KeyCode::Char('a') => Action::SelectAll,
KeyCode::Char('f') => Action::SearchPrompt,
KeyCode::Char('n') => Action::FindNext,
KeyCode::Char('p') => Action::FindPrev,
KeyCode::Char('z') if shift => Action::EditorRedo,
KeyCode::Char('z') => Action::EditorUndo,
KeyCode::Char('g') => Action::GotoLinePrompt,
Expand All @@ -2201,6 +2217,9 @@ impl App {
// Opt+Left/Right on Mac → sends Alt+b/Alt+f (readline-style)
KeyCode::Char('b') if alt => Action::WordLeft,
KeyCode::Char('f') if alt => Action::WordRight,
// Opt+a/e on Mac → top/bottom of file (reliable across all terminals)
KeyCode::Char('a') if alt => Action::MoveToTop,
KeyCode::Char('e') if alt => Action::MoveToBottom,
KeyCode::Up if shift => Action::SelectUp,
KeyCode::Down if shift => Action::SelectDown,
KeyCode::Left if shift => Action::SelectLeft,
Expand Down Expand Up @@ -2251,14 +2270,15 @@ impl App {
}

// Poll CI panels for async results and downloads
for ci in self.ci_panels.iter_mut().flatten() {
for (side, ci) in self.ci_panels.iter_mut().enumerate() {
let Some(ci) = ci else { continue };
ci.poll();
if let Some(result) = ci.poll_download() {
match result {
Ok(path) => {
if matches!(self.focus, PanelFocus::Ci(_)) {
self.focus = PanelFocus::FilePanel;
}
// Remember the CI focus so we can restore it when the editor closes.
self.stashed_focus = Some(PanelFocus::Ci(side));
self.focus = PanelFocus::FilePanel;
self.mode =
AppMode::Editing(Box::new(crate::editor::EditorState::open(path)));
return;
Expand Down Expand Up @@ -2894,9 +2914,50 @@ impl App {
| Action::WordLeft
| Action::WordRight
| Action::EditorUndo
| Action::EditorRedo
| Action::SearchPrompt
| Action::FindNext => {}
| Action::EditorRedo => {}

Action::SearchPrompt => {
if let AppMode::Viewing(ref v) = self.mode {
let (query, direction, case_sensitive) = if let Some(ref s) = v.search {
(s.query.clone(), SearchDirection::Forward, s.case_sensitive)
} else if !self.persisted.search_query.is_empty() {
let dir = if self.persisted.search_direction_forward {
SearchDirection::Forward
} else {
SearchDirection::Backward
};
(
self.persisted.search_query.clone(),
dir,
self.persisted.search_case_sensitive,
)
} else {
(String::new(), SearchDirection::Forward, false)
};
let mut q = TextInput::new(query);
q.select_all();
self.search_dialog = Some(SearchDialogState {
query: q,
direction,
case_sensitive,
focused: SearchDialogField::Query,
});
}
}
Action::FindNext => {
if let AppMode::Viewing(ref mut v) = self.mode {
if v.search.is_some() {
v.find_next();
}
}
}
Action::FindPrev => {
if let AppMode::Viewing(ref mut v) = self.mode {
if v.search.is_some() {
v.find_prev();
}
}
}

// Panel multi-file selection
Action::ToggleSelect => self.active_panel_mut().toggle_select_current(),
Expand Down Expand Up @@ -4153,14 +4214,19 @@ impl App {
focused: SearchDialogField::Query,
});
}
Action::FindNext => {
Action::FindNext | Action::FindPrev => {
let params = if let AppMode::Editing(ref e) = self.mode {
e.last_search.clone()
} else {
None
};
if let Some(params) = params {
self.do_find(params);
if let Some(mut params) = params {
params.direction = if action == Action::FindNext {
SearchDirection::Forward
} else {
SearchDirection::Backward
};
self.do_find(params, false);
} else if let AppMode::Editing(ref mut e) = self.mode {
e.status_msg = Some("No previous search".to_string());
}
Expand Down Expand Up @@ -4468,13 +4534,30 @@ impl App {
matches!(params.direction, SearchDirection::Forward);
self.persisted.search_case_sensitive = params.case_sensitive;

self.do_find(params);
// Viewer mode: use viewer search
if let AppMode::Viewing(ref mut v) = self.mode {
v.set_search(params.query.clone(), params.case_sensitive);
let found = match params.direction {
SearchDirection::Forward => v.find_next(),
SearchDirection::Backward => v.find_prev(),
};
if !found {
self.status_message = Some(format!("\"{}\" not found", params.query));
}
return;
}

self.do_find(params, true);
}

/// Run a non-wrapping search. If not found, show the wrap confirmation dialog.
fn do_find(&mut self, params: crate::editor::SearchParams) {
/// When `save` is true, updates `last_search` (used by the dialog). FindNext/FindPrev
/// pass false so they don't overwrite the dialog's direction setting.
fn do_find(&mut self, params: crate::editor::SearchParams, save: bool) {
if let AppMode::Editing(ref mut e) = self.mode {
e.last_search = Some(params.clone());
if save {
e.last_search = Some(params.clone());
}
if !e.find(&params) {
// Not found in current direction — offer to wrap
self.search_wrap_dialog = Some(SearchWrapDialog {
Expand Down Expand Up @@ -6031,7 +6114,7 @@ impl App {
self.mode = AppMode::DiffViewing(Box::new(dv));
} else {
self.mode = AppMode::Normal;
self.focus = PanelFocus::FilePanel;
self.focus = self.stashed_focus.take().unwrap_or(PanelFocus::FilePanel);
}
self.needs_clear = true;
}
Expand Down
6 changes: 5 additions & 1 deletion src/ui/help_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[
("Up / Down", "Scroll line by line"),
("PageUp / PageDown", "Scroll by page"),
("Home / End", "Jump to top / bottom"),
("Alt+a / Alt+e", "Jump to top / bottom"),
("g", "Go to line"),
("Tab / F4", "Toggle text / hex view"),
("q / Esc", "Close viewer"),
Expand All @@ -163,6 +164,7 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[
("Left", "Collapse node / jump to parent"),
("PageUp / PageDown", "Page through tree or table"),
("Home / End", "Jump to top / bottom"),
("Alt+a / Alt+e", "Jump to top / bottom"),
("Tab / F4", "Toggle tree / table view"),
("g", "Go to row"),
("q / Esc", "Close viewer"),
Expand All @@ -174,6 +176,7 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[
("Arrow keys", "Move cursor"),
("Ctrl+Left/Right", "Word skip"),
("Home / End", "Line start / end"),
("Alt+a / Alt+e", "File start / end"),
("PgUp / PgDn", "Page up / down"),
("Shift+arrows", "Select text"),
("Ctrl+A", "Select all"),
Expand All @@ -183,7 +186,8 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[
("Ctrl+K", "Delete line"),
("Ctrl+G", "Go to line:col"),
("Ctrl+F / F7", "Search"),
("Shift+F7", "Find next"),
("Ctrl+N / Shift+F7", "Find next"),
("Ctrl+P", "Find previous"),
("F2 / Ctrl+S", "Save"),
("Esc", "Close (prompts if unsaved)"),
],
Expand Down
4 changes: 4 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ fn render_viewer(frame: &mut Frame, app: &mut App) {
if let AppMode::Viewing(ref mut viewer) = app.mode {
viewer_view::render(frame, frame.area(), viewer);
}
if let Some(ref state) = app.search_dialog {
let area = search_dialog::render(frame, state);
shadow::render_shadow(frame, area);
}
}

fn render_hex_viewer(frame: &mut Frame, app: &mut App) {
Expand Down
Loading
Loading