diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..52a42055 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,70 @@ +name: Test + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: ${{ matrix.label }} (${{ matrix.features }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + label: linux-x86_64 + features: default + - os: ubuntu-latest + label: linux-x86_64 + features: minimal + - os: windows-latest + label: windows-x86_64 + features: default + - os: windows-latest + label: windows-x86_64 + features: minimal + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: | + if ("${{ matrix.features }}" -eq "minimal") { + cargo clippy --workspace --no-default-features --all-targets --locked + } else { + cargo clippy --workspace --all-targets --locked + } + shell: pwsh + + - name: Build + run: | + if ("${{ matrix.features }}" -eq "minimal") { + cargo build --workspace --no-default-features --locked + } else { + cargo build --workspace --locked + } + shell: pwsh + + - name: Run tests + run: | + if ("${{ matrix.features }}" -eq "minimal") { + cargo test --workspace --no-default-features --locked + } else { + cargo test --workspace --locked + } + shell: pwsh diff --git a/Cargo.toml b/Cargo.toml index c5bab494..ff379769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,22 @@ version = "0.5.12" edition = "2021" [features] -default = [] +default = ["syntax-highlight", "image-preview", "sftp", "terminal-panel", "auto-update"] # Enables extraction/preview of `.tar.bz2` and `.tar.xz` archives. # Off by default because bzip2-sys and lzma-sys are C builds that noticeably # slow `cargo install zeta`. Without this feature those formats produce a # clear runtime error; `.zip`, `.tar`, `.tar.gz` continue to work. archives-extra = ["dep:bzip2", "dep:xz2"] +# Syntax highlighting for file previews (syntect + two-face). Heavy (~5-8 MB). +syntax-highlight = ["dep:syntect", "dep:two-face"] +# Image preview rendering (image + ratatui-image). Adds ~3-5 MB. +image-preview = ["dep:image", "dep:ratatui-image"] +# SSH/SFTP remote filesystem support (ssh2 + C libssh2 build). +sftp = ["dep:ssh2"] +# Embedded terminal panel (vt100 + portable-pty/conpty). +terminal-panel = ["dep:vt100", "dep:conpty", "dep:portable-pty"] +# Automatic update checks via GitHub API (ureq). +auto-update = ["dep:ureq"] [dependencies] anyhow = "1.0" @@ -26,10 +36,10 @@ ratatui = "0.30" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" trash = { version = "5", default-features = false, features = ["coinit_apartmentthreaded"] } -syntect = { version = "5.3", default-features = false, features = ["default-fancy", "default-syntaxes"] } +syntect = { version = "5.3", default-features = false, features = ["default-fancy", "default-syntaxes"], optional = true } thiserror = "2.0" -ssh2 = "0.9" -two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } +ssh2 = { version = "0.9", optional = true } +two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"], optional = true } tui-input = "0.14" unicode-width = "0.2" # basic-toml is a slim TOML parser/serializer (no `toml_edit`/`winnow`). @@ -47,24 +57,24 @@ xz2 = { version = "0.1", optional = true } # Trim chrono: drop default `oldtime`, `wasmbind`, etc. We only need clock + std for # formatting/now(); this also avoids pulling iana-time-zone where unnecessary. chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "bmp", "webp"], optional = true } tempfile = "3" -vt100 = "0.15" +vt100 = { version = "0.15", optional = true } # Disable `image-defaults` (re-enables every image codec) and `chafa-dyn` # (runs pkg-config at build looking for libchafa). We provide our own # image features above; halfblocks/sixel/kitty work without them. -ratatui-image = { version = "10", default-features = false, features = ["crossterm"] } -ureq = { version = "3", default-features = false, features = ["rustls"] } +ratatui-image = { version = "10", default-features = false, features = ["crossterm"], optional = true } +ureq = { version = "3", default-features = false, features = ["rustls"], optional = true } tui-textarea-2 = { version = "0.11", default-features = false, features = ["crossterm_0_28"] } [target.'cfg(windows)'.dependencies] -conpty = "0.7" +conpty = { version = "0.7", optional = true } # portable-pty is only used by the Unix branch of src/pty.rs; on Windows we use # conpty directly. Scoping it to cfg(unix) avoids compiling portable-pty + nix + # serial2 + filedescriptor on Windows builds. [target.'cfg(unix)'.dependencies] -portable-pty = "0.9" +portable-pty = { version = "0.9", optional = true } [profile.release] opt-level = "z" # optimise for binary size diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 00000000..936c37ff --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,151 @@ +# Zeta Implementation Plan + +> Created: 2026-05-12 +> Branch: `feat/feature-gating` +> Based on: `docs/optimization-and-architecture-analysis.md` + +--- + +## Phase 1: Feature Gating (v0.6.0) — IN PROGRESS + +**Goal:** Make heavy dependencies optional via Cargo features to reduce binary size and startup overhead. A minimal build (`--no-default-features`) should compile and run core file manager functionality. + +**Branch:** `feat/feature-gating` + +### 1.1 Cargo.toml Features + +| Feature | Dependencies | Default? | Files Impacted | +|---------|-------------|----------|----------------| +| `syntax-highlight` | `syntect`, `two-face` | ✅ Yes | `src/highlight.rs`, `src/jobs.rs`, `src/preview.rs`, `src/ui/mod.rs`, `src/ui/pane.rs`, `src/config.rs` | +| `image-preview` | `image`, `ratatui-image` | ✅ Yes | `src/app.rs`, `src/jobs.rs`, `src/preview.rs`, `src/state/mod.rs`, `src/ui/preview.rs` | +| `sftp` | `ssh2` | ✅ Yes | `src/jobs.rs`, `src/fs/sftp.rs`, `src/state/ssh.rs`, `src/ui/ssh.rs` | +| `terminal-panel` | `vt100`, `portable-pty`, `conpty` | ✅ Yes | `src/pty.rs`, `src/state/terminal.rs`, `src/ui/terminal.rs`, `src/jobs.rs` | +| `auto-update` | `ureq` | ✅ Yes | `src/update.rs`, `src/app.rs` | +| `archives-extra` | `bzip2`, `xz2` | ❌ No | `src/jobs.rs` (already gated) | + +### 1.2 Checklist + +- [x] Create branch `feat/feature-gating` +- [ ] Gate `syntax-highlight` + - [ ] Make `syntect`, `two-face` optional in Cargo.toml + - [ ] `#[cfg(feature = "syntax-highlight")]` on `src/highlight.rs` module + - [ ] Stub `highlight_text()` returning `None` when feature disabled + - [ ] Remove `syntect_theme` from `PreviewRequest` / `AppConfig` when disabled + - [ ] Fix `ui/mod.rs` markdown preview path + - [ ] Fix `ui/pane.rs` test code +- [ ] Gate `image-preview` + - [ ] Make `image`, `ratatui-image` optional in Cargo.toml + - [ ] `#[cfg(feature = "image-preview")]` on image preview code in `src/preview.rs`, `src/ui/preview.rs` + - [ ] Stub image preview returning "image preview disabled" when feature off + - [ ] Handle `Picker` type in `AppState` — use `()` or feature-gate field +- [ ] Gate `sftp` + - [ ] Make `ssh2` optional in Cargo.toml + - [ ] `#[cfg(feature = "sftp")]` on `src/fs/sftp.rs` + - [ ] Feature-gate SFTP commands/worker in `src/jobs.rs` + - [ ] Feature-gate SSH state/UI modules +- [ ] Gate `terminal-panel` + - [ ] Make `vt100` optional; `portable-pty` and `conpty` already target-scoped + - [ ] `#[cfg(feature = "terminal-panel")]` on `src/pty.rs`, `src/state/terminal.rs`, `src/ui/terminal.rs` + - [ ] Feature-gate terminal worker/commands in `src/jobs.rs` + - [ ] Handle `TerminalState` in `WorkspaceState` — use `Option` or feature-gate field +- [ ] Gate `auto-update` + - [ ] Make `ureq` optional in Cargo.toml + - [ ] `#[cfg(feature = "auto-update")]` on `src/update.rs` + - [ ] Stub `UpdateChecker` / `UpdateState` when disabled + - [ ] Feature-gate update check at startup in `src/app.rs` +- [ ] Testing + - [ ] `cargo test --workspace --features default` passes + - [ ] `cargo test --workspace --no-default-features` passes (minimal build) + - [ ] Add CI workflow matrix for both feature sets +- [ ] Validation + - [ ] `cargo fmt --all -- --check` + - [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - [ ] `cargo test --workspace` + - [ ] `cargo build --release --no-default-features` (minimal binary) + - [ ] `cargo build --release` (full binary) + +--- + +## Phase 2: Rendering & Cache Optimizations (v0.6.x) + +**Branch:** TBD (`feat/render-optimizations`) + +### 2.1 Pane Cache Rebuild +- Cache `lower_name: String` in `EntryInfo` at scan time +- Reuse `filtered_indices` Vec with `clear()` + `extend()` +- Pre-allocate `Vec` capacity in `rebuild_cache()` + +### 2.2 Rendering Hot Path +- Pre-allocate `Vec` capacity in `render_status_bar()`, `render_key_hints()`, `render_pane()` +- Cache breadcrumb strings in `PaneState` +- Reuse `Vec` buffer across frames +- Replace `"─".repeat()` with static lookup +- Move markdown parsing to preview worker thread + +### 2.3 String/Path Optimizations +- `selected_path()` → return `Option<&Path>` +- Cache `display_name: String` in `EntryInfo` +- Gate debug string formatting behind `debug_visible` + +--- + +## Phase 3: TUI Evolution — Composable Layout (v0.6.x) + +**Branch:** TBD (`feat/composable-layout`) + +### 3.1 Panel Trait Abstraction +- Extract `Panel` trait from pane concepts +- Implementors: `FilePanel`, `PreviewPanel`, `TerminalPanel`, `EditorPanel` + +### 3.2 Docked Panels +- Add bottom/side dock areas to workspace layout +- Migrate search, bookmarks, git status from modals to docks + +### 3.3 Configurable Layouts +- Per-workspace layout config (2×2, 3×1, classic dual-pane) +- Session persistence for layout state + +--- + +## Phase 4: Extensibility — Rich Hooks (v0.6.x / v0.7.x) + +**Branch:** TBD (`feat/rich-hooks`) + +### 4.1 JSON-RPC Hook Protocol +- New `mode = "jsonrpc"` for hooks +- stdin/stdout protocol for two-way communication +- Hook can emit `PluginCommand` responses + +### 4.2 New Hook Events +- `on_preview`, `on_mark`, `on_pre_command`, `on_post_command`, `on_key` + +### 4.3 Plugin Context +- Serialize `PluginContext` (selection, marks, workspace, pane) to JSON +- Allow hooks to return `Vec` to influence Zeta state + +--- + +## Phase 5: Native Dynamic Plugins (v0.7.x) + +**Branch:** TBD (`feat/native-plugins`) + +### 5.1 `ZetaPlugin` Trait +- Define stable(ish) plugin trait +- Load `.so`/`.dll` from `~/.config/zeta/plugins/` via `libloading` + +### 5.2 Plugin Points +- Custom preview generators +- Custom filesystem backends +- Custom panels +- Custom actions + +--- + +## Progress Log + +| Date | Phase | Action | Status | +|------|-------|--------|--------| +| 2026-05-12 | 1 | Created branch, analyzed dependency usage | ✅ Done | +| 2026-05-12 | 1 | Implemented all feature gates, fixed compilation | ✅ Done | +| 2026-05-12 | 1 | cargo fmt, cargo clippy --all-features, cargo test --workspace | ✅ Done | +| 2026-05-12 | 1 | Committed to `feat/feature-gating` | ✅ Done | diff --git a/docs/optimization-and-architecture-analysis.md b/docs/optimization-and-architecture-analysis.md new file mode 100644 index 00000000..a24326c3 --- /dev/null +++ b/docs/optimization-and-architecture-analysis.md @@ -0,0 +1,352 @@ +# Zeta Optimization & Architecture Analysis + +> Date: 2026-05-12 +> Version analyzed: 0.5.12 +> Scope: Performance, binary footprint, TUI evolution, extensibility + +--- + +## Executive Summary + +Zeta is a well-architected modular monolith with clean separation (Action → Command → JobResult) and a worker-per-subsystem threading model that keeps the UI responsive. However, **three structural constraints are beginning to bite**: + +1. **Rendering and state hot paths allocate heavily** — fresh `Vec`s, `String`s, and `PathBuf` clones on every frame and every cache rebuild. +2. **Heavy dependencies are unconditionally linked** — `syntect` + `two-face`, `image` + `ratatui-image`, `ssh2`, and `vt100` bloat the binary and startup even when unused. +3. **The Norton Commander dual-pane paradigm is becoming a ceiling** — complex workflows (multi-step operations, rich previews, plugin integrations) don't fit naturally into two static panes + modals. + +The codebase has **no plugin system** (deliberately, per `AGENTS.md`), and the existing **shell hooks** (`on_cd`, `on_open`, `on_start`, `on_exit`) are too coarse for real extensibility. + +This document breaks down specific findings and proposes a phased roadmap. + +--- + +## 1. Performance & Memory Footprint + +### 1.1 Rendering Hot Path (High Impact) + +Every frame rebuilds collections from scratch: + +| Location | Allocation | Frequency | +|----------|-----------|-----------| +| `src/ui/pane.rs:114` | `visible_entries()` → `Vec<&EntryInfo>` | Every draw | +| `src/ui/pane.rs:295` | `truncate_text()` → `String` per visible row | Every draw | +| `src/ui/mod.rs:443` | Status bar `spans`, `right_spans`, `"─".repeat()` | Every draw | +| `src/ui/mod.rs:640` | Key hints `vec![...]` + `format!()` strings | Every draw | +| `src/ui/preview.rs:262` | `collect::>().join("\n")` | Every draw (cheap mode) | +| `src/ui/preview.rs:23` | `wrap_preview_line()` → `Vec` + per-char `String` boxing | Every draw | + +**Recommendations:** +- **Pre-allocate with capacity** in `render_pane()`, `render_status_bar()`, and `render_key_hints()`. +- **Cache breadcrumb strings** in `PaneState` instead of computing `path.display().to_string()` + home-dir stripping every frame. +- **Reuse `Vec` buffers** across frames with `Vec::clear()` rather than `collect()`. +- **Replace `"─".repeat(filled)`** with a small static lookup table or `ratatui::symbols::bar::FULL` repeated via `Span::raw` with a count. +- **Defer markdown parsing** (`src/ui/markdown.rs`) to the preview worker thread. It currently runs on the UI thread when the cache is stale, blocking input for large files. + +### 1.2 Pane Cache Rebuild (High Impact) + +`PaneState::rebuild_cache()` (`src/pane.rs:511`) is called on every sort change, filter toggle, or directory scan: + +```rust +let indices: Vec = (0..self.entries.len()).collect(); +let lower_names: Vec = self.entries.iter() + .map(|e| e.name.to_lowercase()).collect(); +``` + +- `lower_names` is rebuilt from scratch every time. +- Extension sort re-computes `to_lowercase()` inside the comparator closure. +- `filtered_indices: RefCell>` is replaced, not reused. + +**Recommendations:** +- **Store a cached `lower_name: String`** inside `EntryInfo` at scan time. Sorting and filtering then use zero-allocation references. +- **Reuse the `filtered_indices` Vec** — call `.clear()` and `.extend(...)` instead of replacing it. +- **Use `Vec::with_capacity(self.entries.len())`** for `indices`. + +### 1.3 String / PathBuf Cloning (Medium Impact) + +- `src/state/mod.rs` has **168 `.clone()` calls** — many clone `PathBuf` and `String` during action dispatch. +- `PaneState::selected_path()` clones `PathBuf` on every call. +- `path.display().to_string()` is used pervasively for status messages, breadcrumbs, and error text. + +**Recommendations:** +- `selected_path()` should return `Option<&Path>` instead of `Option` where callers only need a reference. +- Cache `display_name: String` in `EntryInfo` (already has `name: String`; add a `display_path` for the full path). +- Use `Cow<'_, str>` for status messages that are usually static literals but occasionally formatted. + +### 1.4 Event Loop (Low-Medium Impact) + +The main loop is generally efficient (~60 Hz poll, non-blocking channels), but: + +- `dispatch()` formats `action_name = format!("{:?}", action)` on **every action** for debug logging, even when the debug panel is hidden. +- `mark_drawn()` increments `redraw_count` but previously failed to clear `needs_redraw` (fixed in v0.5.12). + +**Recommendations:** +- Gate the debug string behind `if self.state.debug_visible` or a compile-time `cfg!(debug_assertions)` check. + +--- + +## 2. Binary Size & Startup Time + +### 2.1 Dependency Bloat (Very High Impact) + +| Dependency | Size Impact | Currently Optional? | +|-----------|-------------|---------------------| +| `syntect` + `two-face` | ~5-8 MB (regex-fancy + all syntax defs) | ❌ No | +| `image` + `ratatui-image` | ~3-5 MB (decoders, color management) | ❌ No | +| `ssh2` + `libssh2-sys` | ~1-2 MB + C build | ❌ No | +| `vt100` | ~500 KB-1 MB (terminal emulator) | ❌ No | +| `ureq` | ~500 KB | ❌ No | +| `arboard` | Moderate | ❌ No | +| `notify` | Moderate | ❌ No | + +**Recommendation — add Cargo features:** + +```toml +[features] +default = ["syntax-highlight", "image-preview", "sftp", "terminal-panel", "auto-update"] +syntax-highlight = ["syntect", "two-face"] +image-preview = ["ratatui-image", "image"] +sftp = ["ssh2"] +terminal-panel = ["vt100", "portable-pty"] +auto-update = ["ureq"] +``` + +This would let users build a **minimal Zeta** (~5-10 MB smaller) with: +```bash +cargo install zeta --no-default-features +``` + +A CI matrix should build and test both `default` and `minimal` feature sets. + +### 2.2 Eager Initialization (Medium Impact) + +- **11+ worker threads** are spawned unconditionally at boot (`spawn_workers()` in `src/jobs.rs:516`). Workers for disabled features (e.g., SFTP when no remotes are configured) still sit idle. +- **WSL timezone detection** (`UTC_OFFSET_MINUTES` at `src/state/mod.rs:58`) spawns PowerShell with a 2-second blocking timeout at startup. +- **`syntax_set()` / `theme_set()`** (`src/highlight.rs:18-27`) are `OnceLock`-lazy but may stutter on first preview if a file is pre-selected. + +**Recommendations:** +- **Lazy-spawn workers** — only start the SFTP worker on first remote connection, the terminal worker on first terminal panel open, etc. +- **Cache timezone offset** to a temp file and read it synchronously; spawn PowerShell only on cache miss. +- **Pre-warm syntax sets** in a background thread during idle ticks if `syntax-highlight` is enabled. + +### 2.3 Directory Scanning Memory (Medium Impact) + +`scan_directory()` (`src/fs.rs:117`) collects all entries into a `Vec` before returning. For directories with 100k+ entries, this causes a large memory spike and blocks the scan worker until completion. + +**Recommendation:** +- Consider a **streaming scan API** that yields `EntryInfo` batches (e.g., `Vec` chunks of 1,000) across the channel. `PaneState` appends incrementally, and the UI can show partial results. This is a significant refactor but dramatically improves perceived performance on huge directories. + +--- + +## 3. TUI Paradigm Limitations + +### 3.1 The Norton Commander Ceiling + +The dual-pane layout (left/right + preview/editor + modals) is fast and familiar, but it creates friction for: + +| Workflow | Current Fit | Friction | +|----------|-------------|----------| +| Multi-step batch operations (e.g., "find all `.log` files > 7 days, compress, upload to SFTP, delete local") | Poor | Each step is a separate modal or file-op; no pipeline composition | +| Rich media preview (video, audio, PDF) | Poor | Text/image only; no embedded player | +| Custom views (tree view, flattened view, git log graph, du visualization) | Poor | Fixed pane layout; no alternative view modes | +| Plugin-driven panels (e.g., a fuzzy-finder panel, a git branch graph, a process list) | Impossible | No dynamic panel API | +| Floating tool windows (persistent search, command palette, quick nav) | Limited | Modals are transient and modal-only | + +### 3.2 Modal Fatigue + +Current UX relies heavily on modals for: +- Confirmation dialogs (delete, overwrite) +- Settings panel +- Bookmarks panel +- Help/cheatsheet +- Find/replace in editor +- Git diff view + +As features accumulate, modals stack and the user loses spatial context. The NC paradigm has no concept of **docked panels** or **split views** beyond the fixed left/right panes. + +### 3.3 Proposed Evolution: "Composable Layout Engine" + +Rather than abandoning the NC roots, **generalize the pane concept**: + +``` +┌─────────────────────────────────────┐ +│ [Pane A: files] │ [Pane B: files] │ ← Classic NC +├─────────────────┴───────────────────┤ +│ [Panel: preview / terminal / git] │ ← Flexible bottom panel +├─────────────────────────────────────┤ +│ [Panel: command palette / search] │ ← Overlay or docked +└─────────────────────────────────────┘ +``` + +**Concrete steps:** +1. **Extract `Pane` into a generic `Panel` trait** that can host: + - `FilePanel` (current pane) + - `PreviewPanel` (current preview) + - `TerminalPanel` (current terminal) + - `EditorPanel` (current editor, currently fullscreen) + - `CustomPanel` (future plugin surface) +2. **Add a layout grid** (2×2, 3×1, etc.) configurable per workspace. +3. **Support side/bottom docks** for persistent tool panels (search, git status, bookmarks) that don't block the main view. +4. **Keep modals for true interruptions** (confirmations) but move tools to docked panels. + +This is a large refactor but preserves the keyboard-first NC workflow while removing the layout ceiling. + +--- + +## 4. Extensibility: Hooks → Plugins + +### 4.1 Current Hooks (Too Coarse) + +Zeta has 4 config-driven shell hooks (`src/hooks.rs`): +- `on_cd` — fires on directory change +- `on_open` — fires on file open +- `on_start` — fires at boot +- `on_exit` — fires at shutdown + +Execution model: **fire-and-forget `sh -c` processes** with env vars (`ZETA_PATH`, `ZETA_PANE`, etc.). + +**Limitations:** +- No return value — hooks cannot influence Zeta's behavior (e.g., a hook cannot say "don't open this file, preview it instead"). +- No async lifecycle — hooks run detached; Zeta doesn't know when they finish. +- No state access — hooks cannot read the current selection, marks, or workspace state. +- Cross-platform shell dependency — Windows users need `sh` available. + +### 4.2 A Pragmatic Plugin Roadmap + +Rather than a heavy WASM or Lua runtime (which violates the "low overhead" mandate), Zeta can adopt a **"micro-plugin" model** inspired by `helix` and `kakoune`: + +#### Phase 1: Richer Hooks (Immediate, Low Risk) + +Add more lifecycle points and a **JSON-RPC/stdin protocol** for two-way communication: + +```toml +[[hooks]] +event = "on_preview" +command = "zeta-lsp-hook" +mode = "jsonrpc" # new: two-way protocol +``` + +The hook process receives a JSON payload on stdin and can emit commands on stdout: + +```json +// Zeta sends: +{"event":"on_preview","path":"/src/main.rs","workspace":0,"pane":"left"} + +// Hook responds: +{"commands":[{"type":"show_message","text":"LSP: 3 errors found"}]} +``` + +New hook events: +- `on_preview` — before/after preview load +- `on_mark` — when entries are marked/unmarked +- `on_pre_command` — before executing a command (allows cancellation/modification) +- `on_post_command` — after command completion +- `on_key` — custom key handling (fallback before default binding) + +#### Phase 2: Native Dynamic Libraries (Medium Effort) + +For performance-critical extensions (custom preview renderers, new filesystem backends): + +```rust +// src/plugin.rs +pub trait ZetaPlugin: Send + Sync { + fn on_event(&mut self, event: PluginEvent, ctx: &PluginContext) -> Vec; +} + +// Loaded via dlopen / libloading at startup from ~/.config/zeta/plugins/ +``` + +**Pros:** Zero serialization overhead, full Rust type safety. +**Cons:** ABI stability, unsafe code for loading. Mitigate by versioning the trait and requiring plugins to be recompiled per minor version. + +#### Phase 3: WASM Sandbox (Long-term, High Effort) + +For cross-platform, user-safe plugins: + +```rust +// wasmtime or wasmer runtime +wasm_plugin.on_event(event) -> Vec +``` + +**Pros:** Safe, cross-platform, language-agnostic (Rust, Go, JS compiled to WASM). +**Cons:** Adds ~1-2 MB to binary, runtime overhead, complex host bindings. + +**Recommendation:** Skip Phase 3 until v2.0. Implement Phase 1 (rich JSON-RPC hooks) in v0.6.x and Phase 2 (native `.so`/`.dll` plugins) in v0.7.x. + +### 4.3 Plugin Surface Areas + +The most valuable extension points, ordered by user demand: + +1. **Custom preview generators** — render PDFs, video thumbnails, database schemas, API responses. +2. **Custom filesystem backends** — beyond local and SFTP (e.g., S3, WebDAV, Docker containers). +3. **Custom views/panels** — a tree-view panel, a git log graph, a `du`-style size treemap. +4. **Custom actions/commands** — new keyboard-driven operations without recompiling Zeta. +5. **Theme extensions** — load external themes from files (currently only 10 built-in presets). + +--- + +## 5. Prioritized Action Plan + +### Immediate (v0.6.0 — Performance Patch) + +| # | Task | Impact | Effort | +|---|------|--------|--------| +| 1 | Feature-gate `syntect`/`two-face`, `image`/`ratatui-image`, `ssh2`, `vt100` | Binary size ↓ 30-50% | Medium | +| 2 | Cache `lower_name` in `EntryInfo`; reuse `filtered_indices` Vec | Cache rebuild ↓ allocations | Low | +| 3 | Pre-allocate render Vecs with capacity; cache breadcrumbs | Frame time ↓ | Low | +| 4 | Move markdown parsing to preview worker | UI thread unblock | Low | +| 5 | Lazy-spawn workers (SFTP, terminal) | Startup time ↓, RAM ↓ | Medium | +| 6 | Fix WSL timezone detection to non-blocking | Startup time ↓ | Low | + +### Short-term (v0.6.x — Evolving the TUI) + +| # | Task | Impact | Effort | +|---|------|--------|--------| +| 7 | Design `Panel` trait abstraction | Enables custom panels | High | +| 8 | Add bottom/side docked panels (search, git, bookmarks) | Reduced modal fatigue | High | +| 9 | Support 2×2 and 3×1 workspace layouts | Power-user productivity | Medium | +| 10 | Richer JSON-RPC hooks (Phase 1 extensibility) | Plugin ecosystem seed | Medium | + +### Medium-term (v0.7.x — Extensibility) + +| # | Task | Impact | Effort | +|---|------|--------|--------| +| 11 | Native dynamic library plugin API (Phase 2) | High-performance extensions | High | +| 12 | Streaming directory scan (batched `EntryInfo` chunks) | Huge directory performance | High | +| 13 | External theme loading from files | Customization | Low | +| 14 | Custom preview generator hooks | PDF, video, database previews | Medium | + +--- + +## 6. Measurement Checklist + +Before and after each optimization: + +```bash +# Binary size +cargo bloat --release +cargo build --release && ls -lh target/release/zeta + +# Startup time (hyperfine) +hyperfine --warmup 3 'target/release/zeta --version' + +# Frame time (add a `--profile` flag that prints avg draw time on quit) +# Memory (valgrind massif, or heaptrack on Linux) +heaptrack target/release/zeta + +# Directory scan throughput +# cd /usr/lib && time zeta --scan-only --quit +``` + +--- + +## Appendix: Existing Modularity Patterns (To Preserve) + +These patterns are working well and should be the foundation for future evolution: + +1. **`Action` / `Command` / `JobResult` triad** — clean deterministic state machine. +2. **`FsBackend` trait** — compile-time polymorphism for local vs remote filesystems. +3. **Worker-per-subsystem + `try_send()`** — non-blocking, backpressure-aware concurrency. +4. **Config-driven behavior** — hot-reloadable TOML for keymap, theme, hooks, openers. +5. **Session persistence** — seamless restart continuity. + +The goal is to **extend** these patterns (e.g., make `FsBackend` dynamically loadable, generalize `Panel` from the pane concept) rather than replace them. diff --git a/src/app.rs b/src/app.rs index 3a6cc953..9c7753b7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,14 +15,17 @@ use crossterm::terminal::{ }; use ratatui::backend::CrosstermBackend; use ratatui::{Frame, Terminal}; +#[cfg(feature = "image-preview")] use ratatui_image::picker::Picker; use crate::action::{Action, Command}; use crate::config::{AppConfig, RuntimeKeymap}; use crate::event::AppEvent; +#[cfg(feature = "auto-update")] +use crate::jobs::UpdateCheckRequest; use crate::jobs::{ self, DirSizeRequest, EditorLoadRequest, FileOpRequest, FindRequest, GitStatusRequest, - JobResult, PreviewRequest, ScanRequest, UpdateCheckRequest, WatchRequest, WorkerChannels, + JobResult, PreviewRequest, ScanRequest, WatchRequest, WorkerChannels, }; use crate::state::{AppState, FocusLayer, ModalKind}; use crate::ui; @@ -79,12 +82,15 @@ impl App { } // Spawn background update check on startup - let current_version = env!("CARGO_PKG_VERSION").to_string(); - if app.state.config().check_updates_on_startup { - let _ = app - .workers - .update_check_tx - .send(UpdateCheckRequest::CheckLatestRelease { current_version }); + #[cfg(feature = "auto-update")] + { + let current_version = env!("CARGO_PKG_VERSION").to_string(); + if app.state.config().check_updates_on_startup { + let _ = app + .workers + .update_check_tx + .send(UpdateCheckRequest::CheckLatestRelease { current_version }); + } } Ok(app) @@ -106,6 +112,7 @@ impl App { // Use halfblocks by default to avoid potential hangs with from_query_stdio(). // The query can block indefinitely in some terminal environments (e.g., WSL). + #[cfg(feature = "image-preview")] self.state.set_image_picker(Picker::halfblocks()); while !self.state.should_quit() { @@ -217,6 +224,7 @@ impl App { fn execute_command_try(&mut self, command: Command) -> Result<()> { match command { + #[cfg(feature = "terminal-panel")] Command::ResizeTerminal { cols, rows } => { let _ = self .workers @@ -263,6 +271,7 @@ impl App { } // Handle update check results + #[cfg(feature = "auto-update")] if let Ok(result) = self.workers.update_check_rx.try_recv() { match result.release { Ok(Some(release)) => { @@ -414,6 +423,7 @@ impl App { let pane_state = self.state.workspace(workspace_id).panes.pane(pane); if pane_state.cwd == path { let scan_path = path.clone(); + #[cfg(feature = "sftp")] if let Some(address) = pane_state.remote_address() { let session_id = format!( "{}@{}", @@ -430,7 +440,9 @@ impl App { session_id, }, )); - } else { + continue; + } + { // Background refresh jobs: drop silently when workers are // backlogged rather than blocking the main thread. let _ = self.workers.scan_tx.try_send(ScanRequest { @@ -461,6 +473,7 @@ impl App { other => { // When SSH connects, queue an SFTP home scan BEFORE delegating to state, // so the pane-mode change and scan happen atomically from the UI's perspective. + #[cfg(feature = "sftp")] if let jobs::JobResult::SshConnected { workspace_id, pane, @@ -547,6 +560,7 @@ impl App { } fn dispatch(&mut self, action: Action) -> Result<()> { + #[cfg(feature = "auto-update")] if action == Action::CheckForUpdates { let current_version = env!("CARGO_PKG_VERSION").to_string(); self.state.update_state.set_checking(); @@ -558,6 +572,12 @@ impl App { .try_send(UpdateCheckRequest::CheckLatestRelease { current_version }); return Ok(()); } + #[cfg(not(feature = "auto-update"))] + if action == Action::CheckForUpdates { + self.state + .set_status_error("Update checks are disabled in this build"); + return Ok(()); + } let action_name = format!("{:?}", action); for command in self.state.apply(action)? { @@ -708,9 +728,11 @@ impl App { let _ = self.workers.preview_tx.try_send(PreviewRequest { workspace_id, path, + #[cfg(feature = "syntax-highlight")] syntect_theme: self.state.theme().palette.syntect_theme.to_string(), archive, inner_path: inner, + #[cfg(feature = "image-preview")] picker: self.state.image_picker().clone(), }); } @@ -720,8 +742,10 @@ impl App { collision, } => { let workspace_id = self.state.active_workspace_index(); + #[allow(unused_variables)] let (src_session, dst_session) = self.determine_backends_for_operation(&operation); + #[cfg(feature = "sftp")] if src_session.is_some() || dst_session.is_some() { self.workers .sftp_tx @@ -734,7 +758,9 @@ impl App { collision, })) .context("failed to queue SFTP file operation")?; - } else { + return Ok(()); + } + { self.workers .file_op_tx .send(FileOpRequest { @@ -751,6 +777,7 @@ impl App { } Command::ScanPane { pane, path } => { let workspace_id = self.state.active_workspace_index(); + #[cfg(feature = "sftp")] if let Some(address) = self.state.panes.pane(pane).remote_address() { let session_id = format!( "{}@{}", @@ -767,7 +794,9 @@ impl App { session_id, })) .context("failed to queue SFTP scan job")?; - } else { + return Ok(()); + } + { // For local panes, serve from the scan cache when it is still // fresh (directory mtime unchanged). Cloning the entries // inside the scoped block ends the immutable borrow before @@ -868,6 +897,7 @@ impl App { enable_raw_mode().ok(); } + #[cfg(feature = "sftp")] Command::ConnectSSH { address, auth_method, @@ -888,6 +918,11 @@ impl App { }) .context("failed to queue SSH connect job")?; } + #[cfg(not(feature = "sftp"))] + Command::ConnectSSH { .. } => { + self.state + .set_error_status("SSH support is not enabled in this build"); + } Command::DisconnectSSH { pane } => { self.state.panes.pane_mut(pane).mode = crate::pane::PaneMode::Real; let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); @@ -896,6 +931,7 @@ impl App { path: std::path::PathBuf::from(home), })?; } + #[cfg(feature = "terminal-panel")] Command::SpawnTerminal { cwd, spawn_id } => { self.workers .terminal_tx @@ -908,6 +944,7 @@ impl App { }) .context("failed to queue terminal spawn job")?; } + #[cfg(feature = "terminal-panel")] Command::WriteTerminal(bytes) => { // PTY writes can arrive faster than the worker drains them. Use // try_send so a backlogged terminal worker never stalls the event loop. @@ -919,6 +956,7 @@ impl App { bytes, }); } + #[cfg(feature = "terminal-panel")] Command::ResizeTerminal { cols, rows } => { let _ = self .workers @@ -971,6 +1009,12 @@ impl App { } }); } + #[cfg(not(feature = "terminal-panel"))] + Command::SpawnTerminal { .. } => {} + #[cfg(not(feature = "terminal-panel"))] + Command::WriteTerminal(_) => {} + #[cfg(not(feature = "terminal-panel"))] + Command::ResizeTerminal { .. } => {} } Ok(()) @@ -1360,6 +1404,7 @@ fn route_menu_bar_click( None } +#[allow(unreachable_code)] fn run_update_and_restart(target_tag: Option<&str>) -> Result<()> { println!(); println!("🔄 Installing update from https://github.com/tzero86/Zeta ..."); @@ -1434,47 +1479,50 @@ fn run_update_and_restart(target_tag: Option<&str>) -> Result<()> { } } - // Non-Windows path: run cargo install synchronously then exec() into the new binary. - let status = std::process::Command::new("cargo") - .args(&cargo_args) - .status() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - anyhow::anyhow!( - "`cargo` was not found in PATH.\n\ + #[cfg(not(windows))] + { + // Non-Windows path: run cargo install synchronously then exec() into the new binary. + let status = std::process::Command::new("cargo") + .args(&cargo_args) + .status() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "`cargo` was not found in PATH.\n\ Install Rust from https://rustup.rs then run:\n\ cargo install --git https://github.com/tzero86/Zeta" - ) - } else { - anyhow::anyhow!("failed to run cargo install: {}", e) - } - })?; - - if status.success() { - println!(); - println!("✅ Update installed successfully!"); - println!(); + ) + } else { + anyhow::anyhow!("failed to run cargo install: {}", e) + } + })?; - #[cfg(not(windows))] - { - println!(" Relaunching Zeta..."); + if status.success() { println!(); - relaunch_self()?; + println!("✅ Update installed successfully!"); + println!(); + + #[cfg(not(windows))] + { + println!(" Relaunching Zeta..."); + println!(); + relaunch_self()?; + } + } else { + eprintln!(); + eprintln!( + "❌ Update failed (cargo install exited with {:?})", + status.code() + ); + eprintln!(); + eprintln!(" To install manually, run:"); + eprintln!(" cargo install --git https://github.com/tzero86/Zeta --locked"); + eprintln!(); + return Err(anyhow::anyhow!( + "cargo install failed with exit code {:?}", + status.code() + )); } - } else { - eprintln!(); - eprintln!( - "❌ Update failed (cargo install exited with {:?})", - status.code() - ); - eprintln!(); - eprintln!(" To install manually, run:"); - eprintln!(" cargo install --git https://github.com/tzero86/Zeta --locked"); - eprintln!(); - return Err(anyhow::anyhow!( - "cargo install failed with exit code {:?}", - status.code() - )); } Ok(()) diff --git a/src/fs.rs b/src/fs.rs index d630bc9f..f5c10d32 100644 --- a/src/fs.rs +++ b/src/fs.rs @@ -101,6 +101,7 @@ pub enum FileSystemError { pub mod backend; pub mod local; pub mod scan_diff; +#[cfg(feature = "sftp")] pub mod sftp; #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/src/fs/sftp.rs b/src/fs/sftp.rs index 7e1633f5..adaddaad 100644 --- a/src/fs/sftp.rs +++ b/src/fs/sftp.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "sftp")] + use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/src/highlight.rs b/src/highlight.rs index a03c4658..5bc9ae81 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1,122 +1,124 @@ -use std::sync::OnceLock; - -use ratatui::style::{Color, Modifier}; -use syntect::easy::HighlightLines; -use syntect::highlighting::Style as SyntectStyle; -use syntect::parsing::SyntaxSet; -use syntect::util::LinesWithEndings; - -/// Each token: (foreground color, bold/italic flags, text chunk). -pub type HighlightToken = (Color, Modifier, Box); - -/// One inner Vec per source line, each element is a styled token. -pub type HighlightedLine = Vec; - -/// Files larger than this are returned as plain text (no highlight). -const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; - -static SYNTAX_SET: OnceLock = OnceLock::new(); -static THEME_SET: OnceLock = OnceLock::new(); - -fn syntax_set() -> &'static SyntaxSet { - SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) -} - -fn theme_set() -> &'static two_face::theme::LazyThemeSet { - THEME_SET.get_or_init(|| two_face::theme::LazyThemeSet::from(two_face::theme::extra())) -} - -/// Convert a syntect `Color` to a ratatui `Color`. -fn to_ratatui_color(c: syntect::highlighting::Color) -> Color { - Color::Rgb(c.r, c.g, c.b) -} - -/// Convert a syntect `FontStyle` to a ratatui `Modifier`. -fn to_ratatui_modifier(style: SyntectStyle) -> Modifier { - use syntect::highlighting::FontStyle; - let mut m = Modifier::empty(); - if style.font_style.contains(FontStyle::BOLD) { - m |= Modifier::BOLD; - } - if style.font_style.contains(FontStyle::ITALIC) { - m |= Modifier::ITALIC; - } - m -} - -/// Normalize preview text for terminal-safe rendering. -pub(crate) fn normalize_preview_text(text: &str) -> String { - let mut normalized = String::with_capacity(text.len()); - let mut chars = text.chars().peekable(); - - while let Some(ch) = chars.next() { - match ch { - '\r' => { - if chars.peek() == Some(&'\n') { - chars.next(); - } - normalized.push('\n'); - } - '\n' => normalized.push('\n'), - '\t' => normalized.push_str(" "), - ch if ch.is_control() => {} - ch => normalized.push(ch), - } - } - - normalized -} - -/// Highlight `text` for the given file `extension` (e.g. `"rs"`, `"py"`). -/// -/// Returns `None` when: -/// - the file exceeds `MAX_HIGHLIGHT_BYTES`, or -/// - the requested syntect theme cannot be found. -/// -/// When the extension is unknown the plain-text syntax is used, so the -/// function still returns `Some` (with unstyled tokens) rather than `None`. -/// Callers that receive `None` should fall back to `PreviewContent::Text`. -/// -/// `syntect_theme` is a theme name such as `"base16-ocean.dark"`. -pub fn highlight_text( - text: &str, - extension: Option<&str>, - syntect_theme: &str, -) -> Option> { - let text = normalize_preview_text(text); - - if text.len() > MAX_HIGHLIGHT_BYTES { - return None; - } - - let ss = syntax_set(); - let ts = theme_set(); - - let syntax = extension - .and_then(|ext| ss.find_syntax_by_extension(ext)) - .unwrap_or_else(|| ss.find_syntax_plain_text()); - - let theme = ts - .get(syntect_theme) - .or_else(|| ts.get("base16-ocean.dark"))?; - - let mut h = HighlightLines::new(syntax, theme); - let mut result = Vec::new(); - - for line in LinesWithEndings::from(&text) { - let ranges = h.highlight_line(line, ss).ok()?; - let tokens: HighlightedLine = ranges - .into_iter() - .map(|(style, chunk)| { - let color = to_ratatui_color(style.foreground); - let modifier = to_ratatui_modifier(style); - let text: Box = chunk.trim_end_matches('\n').into(); - (color, modifier, text) - }) - .filter(|(_, _, t)| !t.is_empty()) - .collect(); - result.push(tokens); - } - - Some(result) -} +use ratatui::style::{Color, Modifier}; + +/// Each token: (foreground color, bold/italic flags, text chunk). +pub type HighlightToken = (Color, Modifier, Box); + +/// One inner Vec per source line, each element is a styled token. +pub type HighlightedLine = Vec; + +/// Files larger than this are returned as plain text (no highlight). +#[cfg(feature = "syntax-highlight")] +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; + +/// Normalize preview text for terminal-safe rendering. +pub(crate) fn normalize_preview_text(text: &str) -> String { + let mut normalized = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + normalized.push('\n'); + } + '\n' => normalized.push('\n'), + '\t' => normalized.push_str(" "), + ch if ch.is_control() => {} + ch => normalized.push(ch), + } + } + + normalized +} + +/// Highlight `text` for the given file `extension` (e.g. `"rs"`, `"py"`). +/// +/// When the `syntax-highlight` feature is disabled this always returns `None` +/// so callers fall back to plain `PreviewContent::Text`. +#[cfg(not(feature = "syntax-highlight"))] +pub fn highlight_text( + _text: &str, + _extension: Option<&str>, + _syntect_theme: &str, +) -> Option> { + None +} + +#[cfg(feature = "syntax-highlight")] +pub fn highlight_text( + text: &str, + extension: Option<&str>, + syntect_theme: &str, +) -> Option> { + use std::sync::OnceLock; + use syntect::easy::HighlightLines; + use syntect::highlighting::Style as SyntectStyle; + use syntect::parsing::SyntaxSet; + use syntect::util::LinesWithEndings; + + static SYNTAX_SET: OnceLock = OnceLock::new(); + static THEME_SET: OnceLock = OnceLock::new(); + + fn syntax_set() -> &'static SyntaxSet { + SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines) + } + + fn theme_set() -> &'static two_face::theme::LazyThemeSet { + THEME_SET.get_or_init(|| two_face::theme::LazyThemeSet::from(two_face::theme::extra())) + } + + fn to_ratatui_color(c: syntect::highlighting::Color) -> Color { + Color::Rgb(c.r, c.g, c.b) + } + + fn to_ratatui_modifier(style: SyntectStyle) -> Modifier { + use syntect::highlighting::FontStyle; + let mut m = Modifier::empty(); + if style.font_style.contains(FontStyle::BOLD) { + m |= Modifier::BOLD; + } + if style.font_style.contains(FontStyle::ITALIC) { + m |= Modifier::ITALIC; + } + m + } + + let text = normalize_preview_text(text); + + if text.len() > MAX_HIGHLIGHT_BYTES { + return None; + } + + let ss = syntax_set(); + let ts = theme_set(); + + let syntax = extension + .and_then(|ext| ss.find_syntax_by_extension(ext)) + .unwrap_or_else(|| ss.find_syntax_plain_text()); + + let theme = ts + .get(syntect_theme) + .or_else(|| ts.get("base16-ocean.dark"))?; + + let mut h = HighlightLines::new(syntax, theme); + let mut result = Vec::new(); + + for line in LinesWithEndings::from(&text) { + let ranges = h.highlight_line(line, ss).ok()?; + let tokens: HighlightedLine = ranges + .into_iter() + .map(|(style, chunk)| { + let color = to_ratatui_color(style.foreground); + let modifier = to_ratatui_modifier(style); + let text: Box = chunk.trim_end_matches('\n').into(); + (color, modifier, text) + }) + .filter(|(_, _, t)| !t.is_empty()) + .collect(); + result.push(tokens); + } + + Some(result) +} diff --git a/src/jobs.rs b/src/jobs.rs index 46659935..9de84bae 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -102,6 +102,7 @@ fn open_tar_reader_for_format<'a, R: Read + 'a>( pub type SessionId = String; +#[cfg(feature = "sftp")] #[derive(Clone, Debug)] pub struct SftpScanRequest { pub workspace_id: usize, @@ -110,6 +111,7 @@ pub struct SftpScanRequest { pub session_id: SessionId, } +#[cfg(feature = "sftp")] #[derive(Clone, Debug)] pub struct SftpFileOpRequest { pub workspace_id: usize, @@ -120,6 +122,7 @@ pub struct SftpFileOpRequest { pub collision: CollisionPolicy, } +#[cfg(feature = "sftp")] pub enum SftpRequest { Connect { workspace_id: usize, @@ -138,6 +141,7 @@ pub enum SftpRequest { FileOp(SftpFileOpRequest), } +#[cfg(feature = "sftp")] /// Result of a host-key verification check. #[derive(Debug)] enum HostCheckResult { @@ -149,6 +153,7 @@ enum HostCheckResult { Failure(String), } +#[cfg(feature = "sftp")] /// Internal outcome type for `connect_sftp`. enum SftpConnectOutcome { Connected(SessionId, crate::fs::sftp::SftpBackend), @@ -196,9 +201,11 @@ pub struct FileOpRequest { pub struct PreviewRequest { pub workspace_id: usize, pub path: PathBuf, + #[cfg(feature = "syntax-highlight")] pub syntect_theme: String, pub archive: Option, pub inner_path: Option, + #[cfg(feature = "image-preview")] pub picker: ratatui_image::picker::Picker, } @@ -237,6 +244,7 @@ pub struct DirSizeRequest { pub path: PathBuf, } +#[cfg(feature = "terminal-panel")] #[derive(Clone, Debug)] pub enum TerminalRequest { Spawn { @@ -257,10 +265,12 @@ pub enum TerminalRequest { }, } +#[cfg(feature = "auto-update")] pub enum UpdateCheckRequest { CheckLatestRelease { current_version: String }, } +#[cfg(feature = "auto-update")] pub struct UpdateCheckResult { pub release: Result, crate::update::UpdateError>, } @@ -502,10 +512,14 @@ pub struct WorkerChannels { pub find_tx: Sender, pub watch_tx: Sender, pub archive_tx: Sender, + #[cfg(feature = "sftp")] pub sftp_tx: Sender, + #[cfg(feature = "terminal-panel")] pub terminal_tx: Sender, pub dir_size_tx: Sender, + #[cfg(feature = "auto-update")] pub update_check_tx: Sender, + #[cfg(feature = "auto-update")] pub update_check_rx: Receiver, pub result_tx: Sender, } @@ -517,7 +531,7 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver(512); // Dedicated unbounded channel for PTY output. Isolated from the shared queue so // the reader thread never blocks and a verbose process cannot starve input handling. - let (term_out_tx, term_out_rx) = unbounded::(); + let (_term_out_tx, term_out_rx) = unbounded::(); // --- Scan worker --- let (scan_tx, scan_rx) = bounded::(32); @@ -590,8 +604,29 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver crate::preview::ViewBuffer { + load_image_preview(bytes, path, &req.picker) + }; + #[cfg(not(feature = "image-preview"))] + let load_image = |_bytes: &[u8], _path: &Path| -> crate::preview::ViewBuffer { + crate::preview::ViewBuffer::from_plain("[Image preview disabled]") + }; + let view = if req.archive.is_none() { - load_preview_content(&req.path, &req.syntect_theme, &req.picker) + if is_image_file(&req.path) { + match std::fs::read(&req.path) { + Ok(bytes) => load_image(&bytes, &req.path), + Err(_) => crate::preview::ViewBuffer::from_plain("[empty file]"), + } + } else { + load_preview_content(&req.path, theme) + } } else if let (Some(archive_path), Some(inner_path)) = (req.archive.clone(), req.inner_path.clone()) { @@ -616,12 +651,15 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver crate::preview::ViewBuffer::from_plain( "[empty file]", @@ -663,12 +701,15 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver (WorkerChannels, Receiver, Receiver crate::preview::ViewBuffer::from_plain("[empty file]"), } } else { - load_preview_content(&req.path, &req.syntect_theme, &req.picker) + if is_image_file(&req.path) { + match std::fs::read(&req.path) { + Ok(bytes) => load_image(&bytes, &req.path), + Err(_) => crate::preview::ViewBuffer::from_plain("[empty file]"), + } + } else { + load_preview_content(&req.path, theme) + } }; if result_tx_preview .send(JobResult::PreviewLoaded { @@ -986,8 +1034,10 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver(8); + #[cfg(feature = "sftp")] { let result_tx = result_tx.clone(); thread::Builder::new() @@ -1120,11 +1170,13 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver(16); + #[cfg(feature = "terminal-panel")] { let result_tx = result_tx.clone(); - let term_out_tx = term_out_tx.clone(); + let term_out_tx = _term_out_tx.clone(); thread::Builder::new() .name("zeta-terminal".into()) .spawn(move || { @@ -1158,9 +1210,12 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver(1); + #[cfg(feature = "auto-update")] let (update_check_tx_result, update_check_rx_main) = bounded::(1); + #[cfg(feature = "auto-update")] { thread::Builder::new() .name("zeta-update".into()) @@ -1186,10 +1241,14 @@ pub fn spawn_workers() -> (WorkerChannels, Receiver, Receiver crate::preview:: load_hex_dump_preview_internal(bytes, path) } +#[cfg(feature = "image-preview")] /// Test-only shim. Not part of the public API. /// Exposed as `pub` solely because integration tests compile as a separate crate. #[doc(hidden)] @@ -1532,6 +1592,7 @@ pub fn test_load_image_preview( load_image_preview(bytes, path, picker) } +#[cfg(feature = "image-preview")] fn load_image_preview( bytes: &[u8], path: &Path, @@ -1573,11 +1634,14 @@ fn load_image_preview( )) } -fn load_preview_content( - path: &Path, - syntect_theme: &str, - picker: &ratatui_image::picker::Picker, -) -> crate::preview::ViewBuffer { +fn is_image_file(path: &Path) -> bool { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("bmp") | Some("webp") + ) +} + +fn load_preview_content(path: &Path, _syntect_theme: &str) -> crate::preview::ViewBuffer { const PREVIEW_MAX_BYTES: u64 = 8 * 1024 * 1024; // 8 MB if let Ok(meta) = std::fs::metadata(path) { if meta.len() > PREVIEW_MAX_BYTES { @@ -1591,28 +1655,19 @@ fn load_preview_content( Ok(b) => b, Err(_) => return crate::preview::ViewBuffer::from_plain("[empty file]"), }; - load_preview_from_bytes(&bytes, path, syntect_theme, picker) + load_preview_from_bytes(&bytes, path, _syntect_theme) } fn load_preview_from_bytes( bytes: &[u8], path: &Path, - syntect_theme: &str, - picker: &ratatui_image::picker::Picker, + _syntect_theme: &str, ) -> crate::preview::ViewBuffer { if bytes.is_empty() { return crate::preview::ViewBuffer::from_plain("[empty file]"); } - // Render image files as halfblock art before the binary check intercepts them. let extension = path.extension().and_then(|e| e.to_str()); - let is_image_ext = matches!( - extension, - Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("bmp") | Some("webp") - ); - if is_image_ext { - return load_image_preview(bytes, path, picker); - } // Archive files: show file listing. let is_archive_ext = { @@ -1645,7 +1700,8 @@ fn load_preview_from_bytes( return crate::preview::ViewBuffer::from_markdown(text.into_owned()); } - if let Some(lines) = crate::highlight::highlight_text(&text, extension, syntect_theme) { + #[cfg(feature = "syntax-highlight")] + if let Some(lines) = crate::highlight::highlight_text(&text, extension, _syntect_theme) { return crate::preview::ViewBuffer::from_highlighted(lines); } @@ -1662,6 +1718,7 @@ fn load_preview_from_bytes( crate::preview::ViewBuffer::from_plain(&truncated) } +#[cfg(feature = "sftp")] /// Format raw host key bytes as a colon-separated MD5 fingerprint for display. fn format_md5_fingerprint(bytes: &[u8]) -> String { bytes @@ -1671,11 +1728,13 @@ fn format_md5_fingerprint(bytes: &[u8]) -> String { .join(":") } +#[cfg(feature = "sftp")] /// Format SHA256 fingerprint in OpenSSH format (SHA256:base64). fn format_sha256_fingerprint(bytes: &[u8]) -> String { format!("SHA256:{}", base64_encode(bytes)) } +#[cfg(feature = "sftp")] /// Get both MD5 and SHA256 fingerprints for a host key. fn get_host_key_fingerprints( session: &ssh2::Session, @@ -1693,6 +1752,7 @@ fn get_host_key_fingerprints( }) } +#[cfg(feature = "sftp")] /// Encode bytes as standard base64 (RFC 4648, with padding). /// Avoids an external dependency for this single use-case. fn base64_encode(bytes: &[u8]) -> String { @@ -1720,6 +1780,7 @@ fn base64_encode(bytes: &[u8]) -> String { out } +#[cfg(feature = "sftp")] /// Append the server's host key to `~/.ssh/known_hosts` in OpenSSH format. /// /// Creates `~/.ssh/` (mode 0700 on Unix) and the file if they don't exist. @@ -1781,6 +1842,7 @@ fn persist_host_key(host: &str, port: u16, session: &ssh2::Session) { .and_then(|mut f| f.write_all(entry.as_bytes())); } +#[cfg(feature = "sftp")] /// Verify SSH host key against known_hosts. /// /// Returns a structured `HostCheckResult` instead of `Result<(), String>` so the @@ -1838,6 +1900,7 @@ fn verify_host_key( } } +#[cfg(feature = "sftp")] /// Parse SSH address in format user@host:port or user@host fn parse_ssh_address(address: &str) -> Result<(String, String, u16), String> { let (user, rest) = address @@ -1855,11 +1918,13 @@ fn parse_ssh_address(address: &str) -> Result<(String, String, u16), String> { Ok((user.to_string(), host.to_string(), port)) } +#[cfg(feature = "sftp")] /// Detect if SSH Agent is available by checking SSH_AUTH_SOCK environment variable. fn has_ssh_agent() -> bool { std::env::var("SSH_AUTH_SOCK").is_ok() } +#[cfg(feature = "sftp")] /// Connect to SSH host and create SftpBackend. /// /// When `trust_unknown_host` is true the connection proceeds even when the host @@ -2020,6 +2085,7 @@ fn connect_sftp( SftpConnectOutcome::Connected(session_id, backend) } +#[cfg(feature = "sftp")] /// Execute a file operation with SFTP backends (cross-backend support) fn execute_sftp_file_op( operation: &FileOperation, @@ -2517,6 +2583,7 @@ fn run_extract_archive( } } +#[cfg(feature = "terminal-panel")] /// Terminal worker: handles PTY spawn and raw I/O. /// /// Uses `conpty` on Windows and `portable-pty` on Unix via [`crate::pty::PtySession`]. diff --git a/src/preview.rs b/src/preview.rs index 084cba47..a4299b1a 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -#[cfg(test)] -use image; use ratatui::style::{Color, Modifier}; #[cfg(test)] @@ -74,26 +72,24 @@ pub struct HexDumpData { pub const HEX_DUMP_MAX_BYTES: usize = 4096; -use ratatui_image::protocol::StatefulProtocol; - /// Pre-decoded image with protocol-specific encoding for terminal graphics rendering. /// `ratatui-image` auto-detects Kitty → Sixels → iTerm2 → halfblock based on the terminal. +/// When the `image-preview` feature is disabled the protocol payload is omitted. pub struct ImagePreviewData { pub filename: String, pub orig_width: u32, pub orig_height: u32, - /// Shared protocol state. `Arc` allows O(1) clone (ViewBuffer cache). - /// `Mutex` provides interior mutability so `render_stateful_widget` can - /// mutate the protocol from an immutable `&ViewBuffer` borrow on the UI thread. - protocol: std::sync::Arc>, + #[cfg(feature = "image-preview")] + protocol: std::sync::Arc>, } +#[cfg(feature = "image-preview")] impl ImagePreviewData { pub fn new( filename: String, orig_width: u32, orig_height: u32, - protocol: StatefulProtocol, + protocol: ratatui_image::protocol::StatefulProtocol, ) -> Self { Self { filename, @@ -105,17 +101,31 @@ impl ImagePreviewData { /// Acquire a lock on the stateful protocol for rendering. /// Only called from the UI render thread — contention is impossible. - pub fn lock_protocol(&self) -> std::sync::MutexGuard<'_, StatefulProtocol> { + pub fn lock_protocol( + &self, + ) -> std::sync::MutexGuard<'_, ratatui_image::protocol::StatefulProtocol> { self.protocol.lock().unwrap_or_else(|e| e.into_inner()) } } +#[cfg(not(feature = "image-preview"))] +impl ImagePreviewData { + pub fn new(filename: String, orig_width: u32, orig_height: u32, _protocol: ()) -> Self { + Self { + filename, + orig_width, + orig_height, + } + } +} + impl Clone for ImagePreviewData { fn clone(&self) -> Self { Self { filename: self.filename.clone(), orig_width: self.orig_width, orig_height: self.orig_height, + #[cfg(feature = "image-preview")] protocol: std::sync::Arc::clone(&self.protocol), } } @@ -476,6 +486,7 @@ mod tests { } #[test] + #[cfg(feature = "image-preview")] fn image_preview_data_stores_protocol() { use ratatui_image::picker::Picker; let picker = Picker::halfblocks(); diff --git a/src/pty.rs b/src/pty.rs index 309c51e9..0820b52e 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -6,46 +6,97 @@ use std::io::{self, Read, Write}; use std::path::Path; -#[cfg(not(windows))] -use std::sync::{Arc, Mutex}; /// A running pseudo-terminal session. pub struct PtySession { + #[cfg(feature = "terminal-panel")] inner: PlatformPty, + #[cfg(not(feature = "terminal-panel"))] + _dummy: (), } impl PtySession { /// Spawn a shell inside a new PTY of the given size. pub fn spawn(cwd: &Path, cols: u16, rows: u16) -> io::Result { - let inner = PlatformPty::spawn(cwd, cols, rows)?; - Ok(Self { inner }) + #[cfg(feature = "terminal-panel")] + { + let inner = PlatformPty::spawn(cwd, cols, rows)?; + Ok(Self { inner }) + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = (cwd, cols, rows); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "terminal-panel feature is disabled", + )) + } } /// Take the output reader (moves ownership — call once). pub fn take_reader(&mut self) -> io::Result> { - self.inner.take_reader() + #[cfg(feature = "terminal-panel")] + { + self.inner.take_reader() + } + #[cfg(not(feature = "terminal-panel"))] + { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "terminal-panel feature is disabled", + )) + } } /// Take the input writer (moves ownership — call once). pub fn take_writer(&mut self) -> io::Result> { - self.inner.take_writer() + #[cfg(feature = "terminal-panel")] + { + self.inner.take_writer() + } + #[cfg(not(feature = "terminal-panel"))] + { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "terminal-panel feature is disabled", + )) + } } /// Resize the PTY. pub fn resize(&mut self, cols: u16, rows: u16) -> io::Result<()> { - self.inner.resize(cols, rows) + #[cfg(feature = "terminal-panel")] + { + self.inner.resize(cols, rows) + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = (cols, rows); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "terminal-panel feature is disabled", + )) + } } /// Return a closure that blocks until the child process exits. /// The closure is `Send` so it can run on a dedicated watcher thread. pub fn exit_waiter(&self) -> io::Result> { - self.inner.exit_waiter() + #[cfg(feature = "terminal-panel")] + { + self.inner.exit_waiter() + } + #[cfg(not(feature = "terminal-panel"))] + { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "terminal-panel feature is disabled", + )) + } } } -// --------------------------------------------------------------------------- -// Windows implementation — conpty -// --------------------------------------------------------------------------- +#[cfg(feature = "terminal-panel")] #[cfg(windows)] struct PlatformPty { proc: conpty::Process, @@ -53,6 +104,7 @@ struct PlatformPty { writer_taken: bool, } +#[cfg(feature = "terminal-panel")] #[cfg(windows)] fn which_shell() -> String { // 1. pwsh.exe (PowerShell 7+) — modern, works well with ConPTY @@ -65,6 +117,7 @@ fn which_shell() -> String { std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) } +#[cfg(feature = "terminal-panel")] #[cfg(windows)] impl PlatformPty { fn spawn(cwd: &Path, cols: u16, rows: u16) -> io::Result { @@ -151,12 +204,14 @@ impl PlatformPty { // --------------------------------------------------------------------------- // Unix implementation — portable-pty // --------------------------------------------------------------------------- +#[cfg(feature = "terminal-panel")] #[cfg(not(windows))] struct PlatformPty { master: Option>, - _child: Option>>>, + _child: Option>>>, } +#[cfg(feature = "terminal-panel")] #[cfg(not(windows))] impl PlatformPty { fn spawn(cwd: &Path, cols: u16, rows: u16) -> io::Result { @@ -188,7 +243,7 @@ impl PlatformPty { Ok(Self { master: Some(pair.master), - _child: Some(Arc::new(Mutex::new(child))), + _child: Some(std::sync::Arc::new(std::sync::Mutex::new(child))), }) } diff --git a/src/state/menu.rs b/src/state/menu.rs index 27160cbd..f789dea4 100644 --- a/src/state/menu.rs +++ b/src/state/menu.rs @@ -233,32 +233,45 @@ pub fn menu_items_for(menu: MenuId, ctx: MenuContext) -> Vec { action: Action::SetTheme(ThemePreset::Dracula), }, ], - MenuId::Help => vec![ - MenuItem { - label: "Help", - shortcut: "F1", - mnemonic: 'h', - action: Action::OpenHelpDialog, - }, - MenuItem { - label: "Check for Updates", - shortcut: "", - mnemonic: 'c', - action: Action::CheckForUpdates, - }, - MenuItem { - label: "Apply Update", - shortcut: "", - mnemonic: 'y', - action: Action::ApplyUpdate, - }, - MenuItem { - label: "About Zeta", - shortcut: "Enter", - mnemonic: 'a', - action: Action::OpenAboutDialog, - }, - ], + MenuId::Help => { + #[cfg_attr(not(feature = "auto-update"), allow(unused_mut))] + let mut items = vec![ + MenuItem { + label: "Help", + shortcut: "F1", + mnemonic: 'h', + action: Action::OpenHelpDialog, + }, + MenuItem { + label: "About Zeta", + shortcut: "Enter", + mnemonic: 'a', + action: Action::OpenAboutDialog, + }, + ]; + #[cfg(feature = "auto-update")] + { + items.insert( + 1, + MenuItem { + label: "Check for Updates", + shortcut: "", + mnemonic: 'c', + action: Action::CheckForUpdates, + }, + ); + items.insert( + 2, + MenuItem { + label: "Apply Update", + shortcut: "", + mnemonic: 'y', + action: Action::ApplyUpdate, + }, + ); + } + items + } _ => vec![], } } else { @@ -499,32 +512,45 @@ pub fn menu_items_for(menu: MenuId, ctx: MenuContext) -> Vec { action: Action::SetTheme(ThemePreset::Dracula), }, ], - MenuId::Help => vec![ - MenuItem { - label: "Help", - shortcut: "F1", - mnemonic: 'h', - action: Action::OpenHelpDialog, - }, - MenuItem { - label: "Check for Updates", - shortcut: "", - mnemonic: 'c', - action: Action::CheckForUpdates, - }, - MenuItem { - label: "Apply Update", - shortcut: "", - mnemonic: 'y', - action: Action::ApplyUpdate, - }, - MenuItem { - label: "About Zeta", - shortcut: "Enter", - mnemonic: 'a', - action: Action::OpenAboutDialog, - }, - ], + MenuId::Help => { + #[cfg_attr(not(feature = "auto-update"), allow(unused_mut))] + let mut items = vec![ + MenuItem { + label: "Help", + shortcut: "F1", + mnemonic: 'h', + action: Action::OpenHelpDialog, + }, + MenuItem { + label: "About Zeta", + shortcut: "Enter", + mnemonic: 'a', + action: Action::OpenAboutDialog, + }, + ]; + #[cfg(feature = "auto-update")] + { + items.insert( + 1, + MenuItem { + label: "Check for Updates", + shortcut: "", + mnemonic: 'c', + action: Action::CheckForUpdates, + }, + ); + items.insert( + 2, + MenuItem { + label: "Apply Update", + shortcut: "", + mnemonic: 'y', + action: Action::ApplyUpdate, + }, + ); + } + items + } _ => vec![], } } diff --git a/src/state/mod.rs b/src/state/mod.rs index 8063ed4a..312bfdb3 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -27,8 +27,20 @@ use std::path::{Path, PathBuf}; use std::time::Instant; use anyhow::Result; +#[cfg(feature = "image-preview")] use ratatui_image::picker::Picker; +#[cfg(not(feature = "image-preview"))] +#[derive(Clone, Debug)] +pub struct Picker; + +#[cfg(not(feature = "image-preview"))] +impl Picker { + pub fn halfblocks() -> Self { + Self + } +} + use crate::action::{Action, CollisionPolicy, Command, FileOperation, MenuId, RefreshTarget}; use crate::config::{ key_event_to_string, AppConfig, ConfigSource, IconMode, LoadedConfig, ResolvedTheme, @@ -4125,11 +4137,9 @@ mod tests { use super::{ resolve_prompt_target, AppState, CollisionState, FocusLayer, MessageKind, ModalKind, - ModalState, OverlayState, PaneFocus, PaneLayout, PaneSetState, PreviewState, PromptKind, - PromptState, UpdateState, WorkspaceState, + ModalState, OverlayState, PaneFocus, PaneLayout, PaneSetState, Picker, PreviewState, + PromptKind, PromptState, UpdateState, WorkspaceState, }; - use ratatui_image::picker::Picker; - // ------------------------------------------------------------------- // Tests for StatusMessage // ------------------------------------------------------------------- diff --git a/src/state/terminal.rs b/src/state/terminal.rs index 63f8e04c..25504beb 100644 --- a/src/state/terminal.rs +++ b/src/state/terminal.rs @@ -2,13 +2,100 @@ use crate::action::{Action, Command}; use anyhow::Result; use std::fmt; use std::path::PathBuf; +#[cfg(feature = "terminal-panel")] use std::sync::{Arc, Mutex}; +#[cfg(feature = "terminal-panel")] +use vt100::Parser as VtParser; + +/// Platform-specific parser wrapper. When `terminal-panel` is disabled this +/// is a zero-cost no-op stub so `TerminalState` can remain in the workspace +/// without pulling the `vt100` crate. +pub struct TerminalParser { + #[cfg(feature = "terminal-panel")] + inner: Arc>, + #[cfg(not(feature = "terminal-panel"))] + _dummy: (), +} + +impl TerminalParser { + pub fn new(rows: u16, cols: u16) -> Self { + #[cfg(feature = "terminal-panel")] + { + Self { + inner: Arc::new(Mutex::new(VtParser::new(rows, cols, 0))), + } + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = (rows, cols); + Self { _dummy: () } + } + } + + pub fn reset(&self, rows: u16, cols: u16) { + #[cfg(feature = "terminal-panel")] + if let Ok(mut p) = self.inner.lock() { + *p = VtParser::new(rows, cols, 0); + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = (rows, cols); + } + } + + pub fn set_size(&self, rows: u16, cols: u16) { + #[cfg(feature = "terminal-panel")] + if let Ok(mut p) = self.inner.lock() { + p.set_size(rows, cols); + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = (rows, cols); + } + } + + pub fn process(&self, bytes: &[u8]) { + #[cfg(feature = "terminal-panel")] + if let Ok(mut p) = self.inner.lock() { + p.process(bytes); + } + #[cfg(not(feature = "terminal-panel"))] + { + let _ = bytes; + } + } + + #[cfg(feature = "terminal-panel")] + pub fn lock(&self) -> Option> { + self.inner.lock().ok() + } + + #[cfg(not(feature = "terminal-panel"))] + pub fn lock(&self) -> Option<()> { + None + } +} + +impl Clone for TerminalParser { + fn clone(&self) -> Self { + #[cfg(feature = "terminal-panel")] + { + Self { + inner: Arc::clone(&self.inner), + } + } + #[cfg(not(feature = "terminal-panel"))] + { + Self { _dummy: () } + } + } +} pub struct TerminalState { pub open: bool, pub focused: bool, pub spawned: bool, - pub parser: Arc>, + pub parser: TerminalParser, pub rows: u16, pub cols: u16, pub bytes_received: u64, @@ -21,7 +108,7 @@ impl Default for TerminalState { open: false, focused: false, spawned: false, - parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))), + parser: TerminalParser::new(24, 80), rows: 24, cols: 80, bytes_received: 0, @@ -55,9 +142,7 @@ impl TerminalState { self.focused = false; self.spawned = false; self.bytes_received = 0; - if let Ok(mut parser) = self.parser.lock() { - *parser = vt100::Parser::new(self.rows, self.cols, 0); - } + self.parser.reset(self.rows, self.cols); } pub fn toggle(&mut self, cwd: PathBuf) -> Vec { @@ -67,10 +152,7 @@ impl TerminalState { if !self.spawned { self.spawned = true; self.bytes_received = 0; - // Clear current screen by creating a new parser - if let Ok(mut parser) = self.parser.lock() { - *parser = vt100::Parser::new(self.rows, self.cols, 0); - } + self.parser.reset(self.rows, self.cols); self.spawn_id += 1; vec![Command::SpawnTerminal { cwd, @@ -91,17 +173,13 @@ impl TerminalState { } self.rows = rows; self.cols = cols; - if let Ok(mut parser) = self.parser.lock() { - parser.set_size(rows, cols); - } + self.parser.set_size(rows, cols); vec![Command::ResizeTerminal { cols, rows }] } pub fn process_output(&mut self, bytes: &[u8]) { self.bytes_received += bytes.len() as u64; - if let Ok(mut parser) = self.parser.lock() { - parser.process(bytes); - } + self.parser.process(bytes); } pub fn apply(&mut self, action: &Action, cwd: PathBuf) -> Result> { diff --git a/src/ui/preview.rs b/src/ui/preview.rs index 487c23f4..22407093 100644 --- a/src/ui/preview.rs +++ b/src/ui/preview.rs @@ -356,6 +356,7 @@ pub fn render_preview_panel(frame: &mut Frame<'_>, area: Rect, args: RenderPrevi } } +#[cfg(feature = "image-preview")] fn render_image_preview( frame: &mut Frame<'_>, area: Rect, @@ -403,6 +404,29 @@ fn render_image_preview( frame.render_stateful_widget(image_widget, image_area, &mut *proto); } +#[cfg(not(feature = "image-preview"))] +fn render_image_preview( + frame: &mut Frame<'_>, + area: Rect, + view: &ViewBuffer, + palette: ThemePalette, +) { + let Some(data) = &view.image_data else { + return; + }; + let msg = format!( + " [ Image preview disabled — rebuild with --features image-preview ] {} ({}×{}px) ", + data.filename, data.orig_width, data.orig_height + ); + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + if x < area.x + area.width && y < area.y + area.height { + frame + .buffer_mut() + .set_string(x, y, msg, Style::default().fg(palette.text_muted)); + } +} + fn render_archive_preview( frame: &mut Frame<'_>, area: Rect, diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs index aecf3dd3..6875e9f2 100644 --- a/src/ui/terminal.rs +++ b/src/ui/terminal.rs @@ -2,7 +2,9 @@ use crate::config::ThemePalette; use crate::state::terminal::TerminalState; use crate::ui::styles::{panel_title_focused_style, panel_title_unfocused_style}; use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +#[cfg(feature = "terminal-panel")] +use ratatui::style::Color; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders}; use ratatui::Frame; @@ -10,7 +12,7 @@ use ratatui::Frame; pub fn render_terminal( frame: &mut Frame<'_>, area: Rect, - terminal: &TerminalState, + _terminal: &TerminalState, palette: ThemePalette, focused: bool, ) { @@ -40,7 +42,22 @@ pub fn render_terminal( let inner = block.inner(area); frame.render_widget(block, area); - if let Ok(parser) = terminal.parser.lock() { + #[cfg(feature = "terminal-panel")] + render_vt100_content(frame, inner, _terminal, palette, focused); + + #[cfg(not(feature = "terminal-panel"))] + render_disabled_placeholder(frame, inner, palette); +} + +#[cfg(feature = "terminal-panel")] +fn render_vt100_content( + frame: &mut Frame<'_>, + inner: Rect, + terminal: &TerminalState, + palette: ThemePalette, + focused: bool, +) { + if let Some(parser) = terminal.parser.lock() { let screen = parser.screen(); let (rows, cols) = screen.size(); let (cursor_row, cursor_col) = screen.cursor_position(); @@ -128,6 +145,19 @@ pub fn render_terminal( } } +#[cfg(not(feature = "terminal-panel"))] +fn render_disabled_placeholder(frame: &mut Frame<'_>, inner: Rect, palette: ThemePalette) { + let msg = " [ Terminal panel disabled — rebuild with --features terminal-panel ] "; + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + let y = inner.y + inner.height / 2; + if x < inner.x + inner.width && y < inner.y + inner.height { + frame + .buffer_mut() + .set_string(x, y, msg, Style::default().fg(palette.text_muted)); + } +} + +#[cfg(feature = "terminal-panel")] fn map_vt100_color(color: vt100::Color) -> Option { match color { vt100::Color::Default => None, diff --git a/src/update.rs b/src/update.rs index 0544ad1f..ba8c412f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1,167 +1,178 @@ -use serde_json; -use std::cmp::Ordering; -use thiserror::Error; - -#[derive(Debug, Clone)] -pub struct Release { - pub version: String, - pub tag_name: String, - pub body: String, - pub prerelease: bool, - pub published_at: String, -} - -#[derive(Debug, Clone)] -pub enum UpdateStatus { - Checking, - Available(Release), - Current, - Error(String), -} - -#[derive(Debug, Error)] -pub enum UpdateError { - #[error("Network error: {0}")] - NetworkError(String), - #[error("Failed to parse JSON response: {0}")] - JsonError(String), - #[error("Version mismatch: current {current}, available {available}")] - VersionMismatch { current: String, available: String }, - #[error("Failed to download binary: {0}")] - DownloadError(String), - #[error("Failed to install update: {0}")] - InstallError(String), -} - -/// Compare semantic versions: returns true if `available > current`. -/// Splits by dots, compares numeric parts left-to-right. -fn is_newer_version(current: &str, available: &str) -> bool { - let current_parts: Vec<&str> = current.split('.').collect(); - let available_parts: Vec<&str> = available.split('.').collect(); - - for i in 0..std::cmp::max(current_parts.len(), available_parts.len()) { - let curr_num = current_parts - .get(i) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let avail_num = available_parts - .get(i) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - - match avail_num.cmp(&curr_num) { - Ordering::Greater => return true, - Ordering::Less => return false, - Ordering::Equal => continue, - } - } - false -} - -/// Parse version from GitHub tag (e.g., "v0.5.0" -> "0.5.0", "0.5.0" -> "0.5.0"). -/// Enforces strict semantic version shape: MAJOR.MINOR[.PATCH[...]] -/// Rejects malformed tags like "v1..0", "v.", "release-2026.04", or "foo1". -fn parse_version_tag(tag: &str) -> Option { - let v = tag.trim_start_matches('v'); - - // Must match pattern: digits, optionally followed by (dot + digits) repeated - // Valid: "0.5.0", "1", "1.0", "1.0.0.1" - // Invalid: "1..0", ".", ".1", "1.", "foo1", "1a.0" - if v.is_empty() { - return None; - } - - // Split by dots and validate each segment is non-empty and all digits - v.split('.') - .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_numeric())) - .then(|| v.to_string()) -} - -pub struct UpdateChecker; - -impl UpdateChecker { - /// Query GitHub API for the latest release of Zeta. - /// Returns Release if found and newer, None if current, error otherwise. - pub fn check_latest_release(current_version: &str) -> Result, UpdateError> { - let url = "https://api.github.com/repos/tzero86/Zeta/releases/latest"; - - let mut resp = ureq::get(url) - .header("User-Agent", &format!("Zeta/{}", current_version)) - .config() - .timeout_global(Some(std::time::Duration::from_secs(5))) - .build() - .call() - .map_err(|e: ureq::Error| UpdateError::NetworkError(e.to_string()))?; - - let body = resp - .body_mut() - .read_to_string() - .map_err(|e: ureq::Error| UpdateError::NetworkError(e.to_string()))?; - - let json: serde_json::Value = - serde_json::from_str(&body).map_err(|e| UpdateError::JsonError(format!("{}", e)))?; - - let tag_name = json["tag_name"] - .as_str() - .ok_or_else(|| UpdateError::JsonError("Missing tag_name".to_string()))?; - - let version = parse_version_tag(tag_name) - .ok_or_else(|| UpdateError::JsonError("Invalid version format".to_string()))?; - - if !is_newer_version(current_version, &version) { - return Ok(None); // Already on latest - } - - let body = json["body"].as_str().unwrap_or("").to_string(); - let prerelease = json["prerelease"].as_bool().unwrap_or(false); - let published_at = json["published_at"].as_str().unwrap_or("").to_string(); - - // Skip pre-release versions to avoid offering unstable builds to stable users. - if prerelease { - return Ok(None); - } - - Ok(Some(Release { - version, - tag_name: tag_name.to_string(), - body, - prerelease, - published_at, - })) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_newer_version() { - assert!(is_newer_version("0.4.5", "0.5.0")); - assert!(is_newer_version("0.4.5", "0.4.6")); - assert!(is_newer_version("0.4.5", "1.0.0")); - assert!(!is_newer_version("0.5.0", "0.4.5")); - assert!(!is_newer_version("0.5.0", "0.5.0")); - } - - #[test] - fn test_parse_version_tag() { - // Valid semantic version tags - assert_eq!(parse_version_tag("v0.5.0"), Some("0.5.0".to_string())); - assert_eq!(parse_version_tag("0.5.0"), Some("0.5.0".to_string())); - assert_eq!(parse_version_tag("1"), Some("1".to_string())); // single segment - assert_eq!(parse_version_tag("v1.0"), Some("1.0".to_string())); // two segments - assert_eq!(parse_version_tag("1.2.3.4"), Some("1.2.3.4".to_string())); // four segments - - // Reject malformed tags with empty segments or invalid characters - assert_eq!(parse_version_tag("v1..0"), None); // double dots (empty segment) - assert_eq!(parse_version_tag("v.1.0"), None); // starts with dot - assert_eq!(parse_version_tag("v1.0."), None); // ends with dot - assert_eq!(parse_version_tag("v0.5.0-rc1"), None); // pre-release with dash - assert_eq!(parse_version_tag("release-2026.04"), None); // mixed alphanumeric - assert_eq!(parse_version_tag("foo1"), None); // alphanumeric without dot - assert_eq!(parse_version_tag("invalid"), None); // pure text - assert_eq!(parse_version_tag("v"), None); // empty after stripping 'v' - assert_eq!(parse_version_tag("v."), None); // just a dot - } -} +#[cfg(feature = "auto-update")] +use std::cmp::Ordering; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct Release { + pub version: String, + pub tag_name: String, + pub body: String, + pub prerelease: bool, + pub published_at: String, +} + +#[derive(Debug, Clone)] +pub enum UpdateStatus { + Checking, + Available(Release), + Current, + Error(String), +} + +#[derive(Debug, Error)] +pub enum UpdateError { + #[error("Network error: {0}")] + NetworkError(String), + #[error("Failed to parse JSON response: {0}")] + JsonError(String), + #[error("Version mismatch: current {current}, available {available}")] + VersionMismatch { current: String, available: String }, + #[error("Failed to download binary: {0}")] + DownloadError(String), + #[error("Failed to install update: {0}")] + InstallError(String), +} + +/// Compare semantic versions: returns true if `available > current`. +/// Splits by dots, compares numeric parts left-to-right. +#[cfg(feature = "auto-update")] +fn is_newer_version(current: &str, available: &str) -> bool { + let current_parts: Vec<&str> = current.split('.').collect(); + let available_parts: Vec<&str> = available.split('.').collect(); + + for i in 0..std::cmp::max(current_parts.len(), available_parts.len()) { + let curr_num = current_parts + .get(i) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let avail_num = available_parts + .get(i) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + match avail_num.cmp(&curr_num) { + Ordering::Greater => return true, + Ordering::Less => return false, + Ordering::Equal => continue, + } + } + false +} + +/// Parse version from GitHub tag (e.g., "v0.5.0" -> "0.5.0", "0.5.0" -> "0.5.0"). +/// Enforces strict semantic version shape: MAJOR.MINOR[.PATCH[...]] +/// Rejects malformed tags like "v1..0", "v.", "release-2026.04", or "foo1". +#[cfg(feature = "auto-update")] +fn parse_version_tag(tag: &str) -> Option { + let v = tag.trim_start_matches('v'); + + // Must match pattern: digits, optionally followed by (dot + digits) repeated + // Valid: "0.5.0", "1", "1.0", "1.0.0.1" + // Invalid: "1..0", ".", ".1", "1.", "foo1", "1a.0" + if v.is_empty() { + return None; + } + + // Split by dots and validate each segment is non-empty and all digits + v.split('.') + .all(|segment| !segment.is_empty() && segment.chars().all(|c| c.is_numeric())) + .then(|| v.to_string()) +} + +pub struct UpdateChecker; + +impl UpdateChecker { + /// Query GitHub API for the latest release of Zeta. + /// When the `auto-update` feature is disabled this always returns `Ok(None)`. + #[cfg(not(feature = "auto-update"))] + pub fn check_latest_release(_current_version: &str) -> Result, UpdateError> { + Ok(None) + } + + /// Query GitHub API for the latest release of Zeta. + /// Returns Release if found and newer, None if current, error otherwise. + #[cfg(feature = "auto-update")] + pub fn check_latest_release(current_version: &str) -> Result, UpdateError> { + let url = "https://api.github.com/repos/tzero86/Zeta/releases/latest"; + + let mut resp = ureq::get(url) + .header("User-Agent", &format!("Zeta/{}", current_version)) + .config() + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build() + .call() + .map_err(|e: ureq::Error| UpdateError::NetworkError(e.to_string()))?; + + let body = resp + .body_mut() + .read_to_string() + .map_err(|e: ureq::Error| UpdateError::NetworkError(e.to_string()))?; + + let json: serde_json::Value = + serde_json::from_str(&body).map_err(|e| UpdateError::JsonError(format!("{}", e)))?; + + let tag_name = json["tag_name"] + .as_str() + .ok_or_else(|| UpdateError::JsonError("Missing tag_name".to_string()))?; + + let version = parse_version_tag(tag_name) + .ok_or_else(|| UpdateError::JsonError("Invalid version format".to_string()))?; + + if !is_newer_version(current_version, &version) { + return Ok(None); // Already on latest + } + + let body = json["body"].as_str().unwrap_or("").to_string(); + let prerelease = json["prerelease"].as_bool().unwrap_or(false); + let published_at = json["published_at"].as_str().unwrap_or("").to_string(); + + // Skip pre-release versions to avoid offering unstable builds to stable users. + if prerelease { + return Ok(None); + } + + Ok(Some(Release { + version, + tag_name: tag_name.to_string(), + body, + prerelease, + published_at, + })) + } +} + +#[cfg(test)] +#[cfg(feature = "auto-update")] +mod tests { + use super::*; + + #[test] + fn test_is_newer_version() { + assert!(is_newer_version("0.4.5", "0.5.0")); + assert!(is_newer_version("0.4.5", "0.4.6")); + assert!(is_newer_version("0.4.5", "1.0.0")); + assert!(!is_newer_version("0.5.0", "0.4.5")); + assert!(!is_newer_version("0.5.0", "0.5.0")); + } + + #[test] + fn test_parse_version_tag() { + // Valid semantic version tags + assert_eq!(parse_version_tag("v0.5.0"), Some("0.5.0".to_string())); + assert_eq!(parse_version_tag("0.5.0"), Some("0.5.0".to_string())); + assert_eq!(parse_version_tag("1"), Some("1".to_string())); // single segment + assert_eq!(parse_version_tag("v1.0"), Some("1.0".to_string())); // two segments + assert_eq!(parse_version_tag("1.2.3.4"), Some("1.2.3.4".to_string())); // four segments + + // Reject malformed tags with empty segments or invalid characters + assert_eq!(parse_version_tag("v1..0"), None); // double dots (empty segment) + assert_eq!(parse_version_tag("v.1.0"), None); // starts with dot + assert_eq!(parse_version_tag("v1.0."), None); // ends with dot + assert_eq!(parse_version_tag("v0.5.0-rc1"), None); // pre-release with dash + assert_eq!(parse_version_tag("release-2026.04"), None); // mixed alphanumeric + assert_eq!(parse_version_tag("foo1"), None); // alphanumeric without dot + assert_eq!(parse_version_tag("invalid"), None); // pure text + assert_eq!(parse_version_tag("v"), None); // empty after stripping 'v' + assert_eq!(parse_version_tag("v."), None); // just a dot + } +} diff --git a/tests/preview_enhancements.rs b/tests/preview_enhancements.rs index f5fe6679..786be28d 100644 --- a/tests/preview_enhancements.rs +++ b/tests/preview_enhancements.rs @@ -7,6 +7,7 @@ use std::io::Write; // Helpers // --------------------------------------------------------------------------- +#[cfg(feature = "image-preview")] fn png_1x1_bytes() -> Vec { // Generate a valid 1×1 RGBA PNG using the image crate. let img = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])); @@ -36,6 +37,7 @@ fn make_zip_bytes(entries: &[(&str, &[u8])]) -> Vec { // Image tests // --------------------------------------------------------------------------- +#[cfg(feature = "image-preview")] #[test] fn png_file_produces_image_buffer_not_hex_dump() { let picker = ratatui_image::picker::Picker::halfblocks(); @@ -45,6 +47,7 @@ fn png_file_produces_image_buffer_not_hex_dump() { assert!(!vb.is_hex_dump()); } +#[cfg(feature = "image-preview")] #[test] fn halfblocks_picker_always_succeeds() { let picker = ratatui_image::picker::Picker::halfblocks();