diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0ed6e4..5cef76c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run tests - run: cargo test + run: cargo test --target x86_64-unknown-linux-gnu build-wasm: runs-on: ubuntu-latest diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..4804fee --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,174 @@ +# Architecture + +**Analysis Date:** 2026-01-13 + +## Pattern Overview + +**Overall:** Plugin-based architecture with layered state management and event-driven processing + +**Key Characteristics:** +- Zellij plugin using WASM (WebAssembly) with WASI sandbox +- Event-driven: Zellij sends events → Plugin processes → State updates → UI renders +- Single executable with two-screen UI (Main list, NewSession creation) +- Stateless request handling within plugin instance + +## Layers + +**Plugin Layer:** +- Purpose: Zellij plugin trait implementation, event dispatcher +- Contains: `ZellijPlugin` trait methods (`load`, `update`, `pipe`, `render`) +- Location: `src/main.rs` +- Depends on: State layer for coordination +- Used by: Zellij runtime + +**State Layer:** +- Purpose: Central state management, business logic coordination +- Contains: `PluginState` struct, key handlers, item combination logic +- Location: `src/state.rs` +- Depends on: Config, Session, Zoxide, NewSessionInfo, UI modules +- Used by: Plugin layer + +**Session Layer:** +- Purpose: Zellij session operations, lifecycle management, stability tracking +- Contains: `SessionManager`, `SessionItem`, `SessionAction` +- Location: `src/session/manager.rs`, `src/session/types.rs` +- Depends on: No internal dependencies (isolated) +- Used by: State layer + +**Zoxide Layer:** +- Purpose: Directory discovery and fuzzy search +- Contains: `ZoxideDirectory`, `SearchEngine` +- Location: `src/zoxide/directory.rs`, `src/zoxide/search.rs` +- Depends on: Session types (for `SessionItem`) +- Used by: State layer + +**UI Layer:** +- Purpose: Terminal rendering +- Contains: `PluginRenderer`, `Theme`, `Colors`, components +- Location: `src/ui/renderer.rs`, `src/ui/theme.rs`, `src/ui/components.rs` +- Depends on: Session types, State for display data +- Used by: Plugin layer (render method) + +**Configuration Layer:** +- Purpose: Zellij layout-based configuration parsing +- Contains: `Config` struct +- Location: `src/config.rs` +- Depends on: None +- Used by: State layer + +## Data Flow + +**Plugin Initialization:** + +1. `ZellijPlugin::load()` in `src/main.rs:16` → Registers plugin with Zellij +2. Requests permissions (RunCommands, ReadApplicationState, ChangeApplicationState, MessageAndLaunchOtherPlugins) +3. Subscribes to events (ModeUpdate, SessionUpdate, Key, RunCommandResult, PermissionRequestResult) + +**Permission Grant & Zoxide Query:** + +1. User receives PermissionRequestResult → `update()` in `src/main.rs:50` +2. `fetch_zoxide_directories()` runs `zoxide query -l -s` command +3. Results returned via RunCommandResult event + +**Zoxide Output Processing:** + +1. `process_zoxide_output()` in `src/main.rs:161` parses score+path lines +2. `generate_smart_session_names()` creates context-aware names (`src/main.rs:197-235`) +3. Names conflict-resolved with parent context and truncated to 29 chars +4. `update_zoxide_directories()` in `src/state.rs:102` stores directories + +**Session Updates:** + +1. Zellij sends SessionUpdate event with current and resurrectable sessions +2. `update_sessions()` in `src/state.rs:57` uses stability tracking (MISSING_THRESHOLD=3) +3. Prevents UI flickering from Zellij's inconsistent event timing + +**User Interaction:** + +1. Key event in `update()` → `handle_key()` in `src/state.rs:108` +2. Routes to main screen or new session screen handlers +3. Actions trigger session switches, deletions, or new session creation + +**State Management:** +- File-based state for previous session: `/tmp/zsm-previous-session` +- Each plugin instance has isolated state (per Zellij session) +- State does not transfer between sessions automatically + +## Key Abstractions + +**SessionManager:** +- Purpose: Orchestrates Zellij session operations with stability tracking +- Examples: `update_sessions_stable()`, `execute_action()`, `generate_incremented_name()` +- Location: `src/session/manager.rs` +- Pattern: Stability threshold (MISSING_THRESHOLD=3) prevents UI flicker from inconsistent Zellij events + +**SessionItem:** +- Purpose: Represents displayable items in the session list +- Examples: `ExistingSession`, `ResurrectableSession`, `Directory` +- Location: `src/session/types.rs` +- Pattern: Enum with variant data + +**SearchEngine:** +- Purpose: Fuzzy matching for search functionality +- Examples: `search()`, `update_search()`, `get_results()` +- Location: `src/zoxide/search.rs` +- Pattern: Uses `SkimMatcherV2` from `fuzzy-matcher` crate + +**PluginState:** +- Purpose: Central orchestrator holding all plugin state +- Examples: `combined_items()`, `display_items()`, `handle_key()` +- Location: `src/state.rs` +- Pattern: Singleton state container, coordinates between all layers + +## Entry Points + +**Plugin Entry:** +- Location: `src/main.rs:13` - `register_plugin!(PluginState)` macro +- Triggers: Zellij loads plugin WASM binary +- Responsibilities: Register plugin with Zellij runtime + +**ZellijPlugin Implementation:** +- Location: `src/main.rs:15-152` +- Triggers: Zellij events (permissions, keys, session updates) +- Responsibilities: Event dispatch, zoxide command execution, rendering + +**Renderer Entry:** +- Location: `src/ui/renderer.rs:14` +- Triggers: Zellij render call +- Responsibilities: Determine active screen, render UI, display overlays + +## Error Handling + +**Strategy:** Uses `Option` for nullable values, UI error display for user feedback + +**Patterns:** +- Validation errors shown via `.set_error()` method (`src/state.rs:26`) +- Error cleared on next keypress +- Permission denial shows error, prevents zoxide fetch +- Invalid session names blocked with descriptive messages + +## Cross-Cutting Concerns + +**Logging:** +- Plugin logs to Zellij's plugin log output +- No external logging framework + +**Validation:** +- Session name validation at creation time (`src/state.rs:614-622`) +- Max 108 bytes, no `/` characters +- Path validation for zoxide results + +**Smart Session Naming:** +- Complex algorithm spanning `src/main.rs:197-502` +- Conflict detection, context-aware naming, truncation to 29 chars +- Respects Unix socket path limits + +**WASM Sandbox Handling:** +- Direct filesystem writes use sandboxed paths +- Shelling out via `run_command` for persistent file operations +- Inter-plugin communication via `pipe_message_to_plugin` + +--- + +*Architecture analysis: 2026-01-13* +*Update when major patterns change* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..204c201 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,165 @@ +# Codebase Concerns + +**Analysis Date:** 2026-01-13 + +## Tech Debt + +**Code Duplication - Range Calculation:** +- Issue: Duplicate range-to-render logic +- Location: `src/new_session_info.rs:271` - TODO comment: "merge with similar function in zoxide_directories" +- Why: Rapid development, similar patterns emerged independently +- Impact: Maintenance burden, risk of divergent behavior +- Fix approach: Extract shared utility function + +**Complex Smart Naming Algorithm:** +- Issue: 300+ lines of interrelated functions for session name generation +- Location: `src/main.rs:197-502` - `generate_smart_session_names`, `generate_context_aware_name`, `normalize_path`, `apply_smart_truncation`, `abbreviate_segment` +- Why: Feature complexity (conflict detection, context-awareness, truncation limits) +- Impact: Difficult to test, maintain, and debug +- Fix approach: Extract to dedicated module, add comprehensive unit tests + +## Known Bugs + +**None identified during analysis.** + +The codebase appears stable with no obvious bugs. Session stability tracking specifically addresses Zellij's inconsistent event behavior. + +## Security Considerations + +**Shell Command Injection (Medium Risk):** +- Risk: Session names interpolated into shell commands without escaping +- Location: `src/state.rs:675-686` (write_previous_session function) +- Code: `format!("echo '{}' > /tmp/zsm-previous-session", session_name)` +- Current mitigation: Session name validation blocks `/` characters (`src/state.rs:619-621`) +- Recommendations: Use shell-safe escaping, or write directly if WASM sandbox permits + +**World-Writable Temp File:** +- Risk: `/tmp/zsm-previous-session` is world-readable/writable +- Location: `src/state.rs:675, 690` +- Current mitigation: File only contains session name (low sensitivity) +- Recommendations: Use user-specific path or XDG_RUNTIME_DIR + +## Performance Bottlenecks + +**None identified.** + +The codebase handles small datasets (typically <100 directories/sessions). No performance issues expected at current scale. + +**Potential Future Concern - Combined Items Rebuild:** +- Location: `src/state.rs:524-529` (update_search_if_needed) +- Pattern: `combined_items()` rebuilds full list on every search update +- Impact: Low with current dataset sizes +- Improvement path: Cache combined items, use incremental updates + +## Fragile Areas + +**Smart Session Naming Algorithm:** +- Location: `src/main.rs:197-502` +- Why fragile: Complex interdependent functions, many edge cases (Unicode, symlinks, deep paths) +- Common failures: Edge cases with unusual path structures +- Safe modification: Add comprehensive unit tests before changes +- Test coverage: **None** - HIGH RISK + +**WASM Sandbox Limitations:** +- Location: `src/state.rs:675-702` (file I/O workarounds) +- Why fragile: Relies on shelling out to bypass WASM restrictions +- Common failures: File persistence issues, race conditions between plugin instances +- Safe modification: Understand WASM sandbox constraints before changes +- Test coverage: Cannot test in isolation (WASM-specific) + +## Scaling Limits + +**Not applicable.** + +Plugin runs locally with user's directory history. No cloud services or shared resources. + +## Dependencies at Risk + +**All dependencies current (as of Jan 2026):** +- `zellij-tile 0.43.1` - Actively maintained with Zellij +- `zellij-utils 0.43.1` - Same lifecycle as zellij-tile +- `serde 1.0.164` - Stable, widely used +- `fuzzy-matcher 0.3.7` - Stable, minimal updates expected +- `uuid 1.8.0` - Stable, widely used +- `humantime 2.2.0` - Stable, low update frequency + +No immediate risks identified. + +## Missing Critical Features + +**None identified.** + +The plugin provides complete session/directory management functionality as designed. + +## Test Coverage Gaps + +**Smart Naming Algorithm (HIGH PRIORITY):** +- What's not tested: 503 lines of logic in `src/main.rs` - conflict detection, context naming, truncation +- Location: `src/main.rs:197-502` +- Risk: Naming bugs affect user experience, edge cases unknown +- Priority: HIGH +- Difficulty to test: Medium - need to mock or construct path data + +**State Coordination Logic:** +- What's not tested: 709 lines in `src/state.rs` - display logic, selection, item combination +- Location: `src/state.rs` +- Risk: State management bugs could cause UI issues +- Priority: Medium +- Difficulty to test: Medium - need to simulate events + +**Fuzzy Search Engine:** +- What's not tested: 204 lines in `src/zoxide/search.rs` +- Location: `src/zoxide/search.rs` +- Risk: Search ranking issues +- Priority: Low (uses well-tested fuzzy-matcher crate) +- Difficulty to test: Low - pure function logic + +**UI Rendering:** +- What's not tested: 431 lines in `src/ui/renderer.rs` +- Location: `src/ui/renderer.rs` +- Risk: Display issues +- Priority: Low (visual verification during development) +- Difficulty to test: High - Zellij rendering API + +## Minor Issues + +**Error Messages Clear on Any Keypress:** +- Location: `src/state.rs:108-113` +- Behavior: Any keypress clears error, even if user didn't read it +- UX impact: Users might miss validation errors +- Recommendation: Only clear errors on meaningful actions + +**Unicode Truncation Edge Case:** +- Location: `src/main.rs:343-344` +- Problem: `.len()` counts bytes, not characters; multibyte UTF-8 could truncate mid-character +- Impact: Low probability (requires non-ASCII paths) +- Recommendation: Use `.chars().count()` or check byte boundaries + +**Configuration Delimiter Fragility:** +- Location: `src/config.rs:43-51` +- Issue: `base_paths` uses `|` delimiter with no escape mechanism +- Impact: Cannot use `|` in paths (edge case) +- Recommendation: Document limitation or add escape support + +--- + +## Summary + +**Overall Assessment:** Clean, well-structured codebase with no critical issues. + +**Priorities:** +1. **HIGH**: Add unit tests for smart naming algorithm (`src/main.rs:197-502`) +2. **MEDIUM**: Address shell command injection concern in session persistence +3. **LOW**: Extract duplicate range calculation logic + +**Positive Notes:** +- No `unsafe` blocks +- No unchecked `unwrap()` or `expect()` calls +- Good inline documentation +- CI/CD properly configured with tests, clippy, and fmt checks +- Error handling is defensive and informative + +--- + +*Concerns audit: 2026-01-13* +*Update as issues are fixed or new ones discovered* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..deb7004 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,156 @@ +# Coding Conventions + +**Analysis Date:** 2026-01-13 + +## Naming Patterns + +**Files:** +- snake_case for all module files: `manager.rs`, `types.rs`, `renderer.rs` +- `mod.rs` for module exports +- UPPERCASE.md for documentation: `README.md`, `CLAUDE.md` + +**Functions:** +- snake_case for all functions: `update_sessions_stable`, `handle_key`, `generate_smart_session_names` +- No special prefix for async functions (Rust convention) +- Descriptive names preferred: `generate_context_aware_name` over `gen_name` + +**Variables:** +- snake_case for all variables: `session_name`, `current_length`, `result_segments` +- No underscore prefix convention for private members + +**Types:** +- PascalCase for structs: `SessionManager`, `PluginState`, `PluginRenderer`, `ZoxideDirectory` +- PascalCase for enums: `SessionItem`, `SessionAction`, `ActiveScreen` +- No I prefix for traits (Rust convention) + +**Constants:** +- UPPER_SNAKE_CASE: `MISSING_THRESHOLD` (`src/session/manager.rs:7`) + +## Code Style + +**Formatting:** +- rustfmt with default settings (no custom rustfmt.toml) +- Enforced in CI via `cargo fmt --check` +- 4 space indentation (Rust default) + +**Linting:** +- Clippy with strict mode: `cargo clippy -- -D warnings` +- Enforced in CI on WASM target (`.github/workflows/ci.yml:53`) +- All warnings treated as errors + +## Import Organization + +**Order:** +1. Standard library imports (`std::*`) +2. External crate imports (`zellij_tile::*`, `serde::*`) +3. Internal module imports (`crate::*`) + +**Grouping:** +- Blank line between groups +- `use` statements at top of file +- Re-exports via `pub use` in `mod.rs` files + +**Path Aliases:** +- `crate::` for internal absolute paths +- No custom path aliases + +## Error Handling + +**Patterns:** +- Use `Option` for nullable values +- No custom error types (domain is small) +- Validation errors shown via UI `.set_error()` method + +**Error Types:** +- Throw validation errors to user via `self.set_error()` (`src/state.rs:616, 620, 626`) +- Permission errors handled at plugin level (`src/main.rs:79-86`) +- No panic-inducing `unwrap()` without context + +## Logging + +**Framework:** +- No external logging framework +- Plugin logs to Zellij's plugin log output + +**Patterns:** +- Inline comments for debugging complex logic +- No structured logging + +## Comments + +**When to Comment:** +- Explain "why" for complex algorithms: `src/main.rs:215-235` (smart naming explanation) +- Document business constraints: `src/main.rs:338-345` (Unix socket path limits) +- Note Zellij-specific behaviors: `CLAUDE.md` (WASM sandbox limitations) + +**Doc Comments:** +- `///` doc comments on all public items +- Example: `src/session/manager.rs:24-26` + ```rust + /// Update session list with stability tracking + /// Returns true if the visible session list changed + ``` + +**TODO Comments:** +- Format: `// TODO: description` +- Example: `src/new_session_info.rs:271` - `// TODO: merge with similar function` + +## Function Design + +**Size:** +- Most functions under 50 lines +- Complex algorithms documented inline: `apply_smart_truncation` (62 lines, well-commented) + +**Parameters:** +- Use `&self` for methods +- Prefer `&str` over `String` for read-only strings +- Use references to avoid cloning + +**Return Values:** +- Explicit return statements +- Use `Option` for nullable returns +- Return `bool` for operations that may or may not change state + +## Module Design + +**Exports:** +- Named exports via `pub use` in `mod.rs` +- Example from `src/session/mod.rs`: + ```rust + pub mod manager; + pub mod types; + + pub use manager::SessionManager; + pub use types::{SessionAction, SessionItem}; + ``` + +**Barrel Files:** +- Each module directory has `mod.rs` for public API +- Internal helpers kept private (not in `pub use`) + +## Derive Macros + +**Common Patterns:** +- `#[derive(Debug, Clone)]` on data types +- `#[derive(Default)]` when zero-initialization makes sense +- `#[derive(PartialEq)]` for comparison-needed types +- Example: `src/state.rs:40` - `#[derive(Debug, Clone, Copy, Default, PartialEq)]` + +## Testing Conventions + +**Location:** +- Inline `#[cfg(test)]` modules in source files +- Example: `src/session/manager.rs:201` + +**Naming:** +- Test function names describe behavior: `test_new_session_added_immediately` +- Prefix with `test_` + +**Structure:** +- Arrange-Act-Assert pattern +- Helper functions for complex setup: `make_session()` in `src/session/manager.rs:205` + +--- + +*Convention analysis: 2026-01-13* +*Update when patterns change* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..72242e2 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,117 @@ +# External Integrations + +**Analysis Date:** 2026-01-13 + +## APIs & External Services + +**No Remote API Services:** +- The plugin does not make HTTP requests or communicate with external cloud services +- All operations are local within the Zellij plugin sandbox + +## CLI Tool Integrations + +**Zoxide (Smart Directory Navigation):** +- External command execution: `zoxide query -l -s` (`src/main.rs:158`) +- Parses zoxide output format: `" "` (`src/main.rs:169-180`) +- Requires zoxide to be installed and used for directory history +- Failure handling with error messaging (`src/main.rs:79-86`) + +## Zellij Plugin System Integration + +**Permissions Requested:** +- `PermissionType::RunCommands` - Execute shell commands (zoxide) (`src/main.rs:21`) +- `PermissionType::ReadApplicationState` - Read current sessions/layouts (`src/main.rs:22`) +- `PermissionType::ChangeApplicationState` - Create and switch sessions (`src/main.rs:23`) +- `PermissionType::MessageAndLaunchOtherPlugins` - Launch filepicker plugin (`src/main.rs:24`) + +**Event Subscriptions:** +- `EventType::ModeUpdate` - Theme/color updates +- `EventType::SessionUpdate` - Session changes +- `EventType::Key` - Keyboard input +- `EventType::RunCommandResult` - Command execution results +- `EventType::PermissionRequestResult` - Permission grant/deny responses + +**Plugin Communication:** +- Filepicker plugin integration via `pipe_message` for folder selection (`src/main.rs:102-147`) +- Request ID tracking with UUID validation for plugin-to-plugin communication (`src/state.rs`) + +## Data Storage + +**Databases:** +- None - No database connections + +**File Storage:** +- Sandboxed within WASI - Direct filesystem operations use sandboxed paths +- `/host` - Working directory of focused terminal (read-only) +- `/data` - Plugin-specific folder +- `/tmp` - Sandboxed temporary directory (NOT real `/tmp`) + +**Caching:** +- None - No caching layer + +## Quick-Switch Feature Storage + +**Previous Session Tracking:** +- Writes current session name to `/tmp/zsm-previous-session` via `run_command` (`src/state.rs:675`) +- Reads previous session on plugin open for instant toggle (`src/main.rs:87-94`) +- Async file operations due to WASM sandbox limitations +- World-writable location, shared across sessions + +## Authentication & Identity + +**Auth Provider:** +- None - No authentication required + +**OAuth Integrations:** +- None + +## Monitoring & Observability + +**Error Tracking:** +- None - No external error tracking service + +**Analytics:** +- None - No telemetry or analytics + +**Logs:** +- Plugin logs to Zellij's plugin log output +- No external logging service + +## CI/CD & Deployment + +**Hosting:** +- GitHub Releases for binary distribution +- Installed to `~/.config/zellij/plugins/` by users + +**CI Pipeline:** +- GitHub Actions (`.github/workflows/ci.yml`) +- Jobs: test, build-wasm, clippy, fmt +- release-please automation for versioning (`.github/workflows/release-please.yml`) +- Automated WASM binary build and upload to GitHub Releases + +## Environment Configuration + +**Development:** +- No environment variables required +- Configuration via Zellij layout KDL files (`plugin.kdl`, `zellij.kdl`) +- zoxide must be installed for directory listing + +**Staging:** +- Not applicable (local plugin) + +**Production:** +- No secrets management required +- Plugin runs with user's local permissions + +## Webhooks & Callbacks + +**Incoming:** +- None + +**Outgoing:** +- None + +--- + +*Integration audit: 2026-01-13* +*Update when adding/removing external services* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..b64b53d --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,75 @@ +# Technology Stack + +**Analysis Date:** 2026-01-13 + +## Languages + +**Primary:** +- Rust 2021 edition - All application code (`Cargo.toml`) + +**Secondary:** +- None (pure Rust codebase) + +## Runtime + +**Environment:** +- WASM/WASI target: `wasm32-wasip1` (`.cargo/config.toml`) +- Compiles to WebAssembly for execution within Zellij WASM sandbox +- WASM binary output: `target/wasm32-wasip1/debug/zsm.wasm` or `target/wasm32-wasip1/release/zsm.wasm` + +**Package Manager:** +- Cargo - Rust package manager +- Lockfile: `Cargo.lock` present + +## Frameworks + +**Core:** +- `zellij-tile 0.43.1` - Zellij plugin trait implementation and event handling (`Cargo.toml`) +- `zellij-utils 0.43.1` - Zellij utilities library (`Cargo.toml`) + +**Testing:** +- Rust built-in test framework (`#[test]`, `#[cfg(test)]`) +- No external test crate required + +**Build/Dev:** +- rustfmt - Code formatting (enforced in CI) +- clippy - Linting with `-D warnings` strict mode +- cargo - Build, test, and package management + +## Key Dependencies + +**Critical:** +- `fuzzy-matcher 0.3.7` - Fuzzy string matching for directory/session search (`Cargo.toml`) +- `serde 1.0.164` - Serialization framework with derive macros (`Cargo.toml`) + +**Infrastructure:** +- `uuid 1.8.0` - UUID generation (v4) for plugin request tracking (`Cargo.toml`) +- `humantime 2.2.0` - Human-readable time formatting (`Cargo.toml`) + +## Configuration + +**Environment:** +- No environment variables required +- Configuration via Zellij layout options (KDL format) +- Settings passed through plugin configuration map: `default_layout`, `session_separator`, `show_resurrectable_sessions`, `base_paths`, `show_all_sessions` + +**Build:** +- `.cargo/config.toml` - Default WASM target configuration +- `Cargo.toml` - Dependency and edition configuration + +## Platform Requirements + +**Development:** +- macOS/Linux (any platform with Rust toolchain) +- rustup with `wasm32-wasip1` target: `rustup target add wasm32-wasip1` +- zoxide CLI for testing (optional but recommended) + +**Production:** +- Runs inside Zellij terminal multiplexer +- Distributed as `.wasm` binary via GitHub Releases +- SHA256 checksum provided for release verification + +--- + +*Stack analysis: 2026-01-13* +*Update after major dependency changes* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..e9a5422 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,150 @@ +# Codebase Structure + +**Analysis Date:** 2026-01-13 + +## Directory Layout + +``` +zsm/ +├── .cargo/ # Cargo configuration +│ └── config.toml # Default WASM target +├── .github/ # GitHub configuration +│ └── workflows/ # CI/CD pipelines +│ ├── ci.yml # Test, build, lint, format +│ └── release-please.yml # Automated releases +├── src/ # Source code +│ ├── main.rs # Plugin entry point +│ ├── state.rs # Central state management +│ ├── config.rs # Configuration parsing +│ ├── new_session_info.rs # Session creation workflow +│ ├── session/ # Session management module +│ ├── ui/ # UI rendering module +│ └── zoxide/ # Zoxide integration module +├── target/ # Build artifacts (gitignored) +├── Cargo.toml # Project manifest +├── Cargo.lock # Dependency lock +├── CLAUDE.md # Developer instructions +├── README.md # User documentation +├── CHANGELOG.md # Version history +├── LICENSE # MIT license +├── plugin.kdl # Example plugin config +└── zellij.kdl # Development layout +``` + +## Directory Purposes + +**src/** +- Purpose: All Rust source code +- Contains: Plugin implementation, modules, types +- Key files: `main.rs` (entry), `state.rs` (orchestrator) +- Subdirectories: `session/`, `ui/`, `zoxide/` + +**src/session/** +- Purpose: Session management and operations +- Contains: `SessionManager`, `SessionItem`, `SessionAction` types +- Key files: `manager.rs` (operations + tests), `types.rs` (enums) +- Subdirectories: None + +**src/ui/** +- Purpose: Terminal UI rendering +- Contains: `PluginRenderer`, `Theme`, `Colors`, component helpers +- Key files: `renderer.rs` (main rendering), `theme.rs` (colors), `components.rs` (helpers) +- Subdirectories: None + +**src/zoxide/** +- Purpose: Zoxide CLI integration and search +- Contains: `ZoxideDirectory`, `SearchEngine` +- Key files: `directory.rs` (struct), `search.rs` (fuzzy matching) +- Subdirectories: None + +**.github/workflows/** +- Purpose: CI/CD automation +- Contains: GitHub Actions workflow files +- Key files: `ci.yml` (tests/linting), `release-please.yml` (releases) +- Subdirectories: None + +## Key File Locations + +**Entry Points:** +- `src/main.rs` - Plugin entry, `register_plugin!` macro, `ZellijPlugin` trait implementation + +**Configuration:** +- `Cargo.toml` - Rust project manifest, dependencies +- `.cargo/config.toml` - Default build target (wasm32-wasip1) +- `src/config.rs` - Runtime configuration parsing from Zellij layout + +**Core Logic:** +- `src/state.rs` - Central `PluginState` struct, key handling, item coordination (709 lines) +- `src/main.rs` - Event handling, zoxide execution, smart naming algorithm (503 lines) +- `src/session/manager.rs` - Session operations, stability tracking (337 lines) + +**UI:** +- `src/ui/renderer.rs` - Main rendering logic, screen dispatch (431 lines) +- `src/ui/components.rs` - UI component rendering helpers +- `src/ui/theme.rs` - Color theming with indexed colors + +**Testing:** +- `src/session/manager.rs` - Inline `#[cfg(test)]` module with 7 unit tests + +**Documentation:** +- `README.md` - User-facing installation and usage guide +- `CLAUDE.md` - Developer instructions, architecture overview +- `CHANGELOG.md` - Version history + +## Naming Conventions + +**Files:** +- snake_case for all Rust files: `manager.rs`, `types.rs`, `renderer.rs` +- `mod.rs` for module public exports +- UPPERCASE.md for important docs: `README.md`, `CLAUDE.md`, `CHANGELOG.md` + +**Directories:** +- snake_case: `session/`, `ui/`, `zoxide/` +- Plural for collections: `workflows/` + +**Special Patterns:** +- `mod.rs` - Re-exports public API for each module +- Test modules inline with `#[cfg(test)]` attribute + +## Where to Add New Code + +**New Feature:** +- Primary code: Depends on feature domain (`src/session/`, `src/ui/`, `src/zoxide/`) +- State integration: `src/state.rs` +- Tests: Inline in source file with `#[cfg(test)]` +- Config if needed: `src/config.rs` + +**New Session Operation:** +- Implementation: `src/session/manager.rs` +- Types: `src/session/types.rs` +- Tests: `src/session/manager.rs` (in `mod tests`) + +**New UI Component:** +- Implementation: `src/ui/components.rs` or `src/ui/renderer.rs` +- Theming: `src/ui/theme.rs` + +**New Zoxide Feature:** +- Directory handling: `src/zoxide/directory.rs` +- Search logic: `src/zoxide/search.rs` + +**Utilities:** +- Consider adding to existing modules or `src/state.rs` +- No dedicated utils directory exists + +## Special Directories + +**target/** +- Purpose: Build output and artifacts +- Source: Generated by Cargo +- Committed: No (in .gitignore) +- Key output: `target/wasm32-wasip1/release/zsm.wasm` + +**.planning/** +- Purpose: GSD planning documents (this codebase map) +- Source: Created by `/gsd:map-codebase` +- Committed: Depends on project preference + +--- + +*Structure analysis: 2026-01-13* +*Update when directory structure changes* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..8dde4f6 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,234 @@ +# Testing Patterns + +**Analysis Date:** 2026-01-13 + +## Test Framework + +**Runner:** +- Rust built-in test framework +- No external test crate + +**Assertion Library:** +- Built-in `assert!`, `assert_eq!`, `assert_ne!` +- Standard Rust matchers + +**Run Commands:** +```bash +cargo test # Run all tests (native target) +cargo test --target x86_64-unknown-linux-gnu # Explicit native target (CI) +cargo test -- --nocapture # Show println! output +cargo test test_name # Single test +``` + +**Important Note:** +Tests must run on native target, NOT WASM. The `.cargo/config.toml` sets default target to wasm32-wasip1, so CI explicitly specifies native target. + +## Test File Organization + +**Location:** +- Inline `#[cfg(test)]` modules in source files +- No separate `tests/` directory + +**Naming:** +- Test module: `mod tests { }` within source file +- Test functions: `fn test_()` + +**Structure:** +``` +src/ + session/ + manager.rs # Contains #[cfg(test)] mod tests { 7 tests } + types.rs # No tests (simple enum definitions) + state.rs # No tests (complex, would benefit from tests) + main.rs # No tests (complex smart naming algorithm) + ui/ + renderer.rs # No tests (UI rendering) + zoxide/ + search.rs # No tests (fuzzy search logic) +``` + +## Test Structure + +**Suite Organization:** +```rust +#[cfg(test)] +mod tests { + use super::*; + + // Helper function for test setup + fn make_session(name: &str, is_current: bool) -> SessionInfo { + // ... construct complex test object + } + + #[test] + fn test_new_session_added_immediately() { + // arrange + let mut manager = SessionManager::default(); + + // act + let changed = manager.update_sessions_stable(vec![make_session("test", false)]); + + // assert + assert!(changed); + assert_eq!(manager.sessions().len(), 1); + assert_eq!(manager.sessions()[0].name, "test"); + } +} +``` + +**Patterns:** +- Arrange-Act-Assert (AAA) pattern +- Helper functions for complex object construction +- Clear test names describing expected behavior +- One assertion focus per test (but multiple `assert!` OK) + +## Mocking + +**Framework:** +- No mocking framework used +- Test helpers create real objects with test data + +**Patterns:** +```rust +// Helper creates SessionInfo with all required fields +fn make_session(name: &str, is_current: bool) -> SessionInfo { + use std::collections::BTreeMap; + use zellij_tile::prelude::PaneManifest; + SessionInfo { + name: name.to_string(), + is_current_session: is_current, + tabs: Vec::new(), + panes: PaneManifest { panes: BTreeMap::new() }, + connected_clients: 0, + available_layouts: Vec::new(), + // ... other required fields + } +} +``` + +**What to Mock:** +- SessionInfo objects (via helper functions) +- Not applicable: No external API calls to mock + +**What NOT to Mock:** +- Zellij plugin runtime (not available in test context) +- WASM-specific features (tests run on native target) + +## Fixtures and Factories + +**Test Data:** +- Factory function pattern: `make_session()` in `src/session/manager.rs:205` +- Inline test data for simple cases + +**Location:** +- Factory functions in test module alongside tests +- No shared fixtures directory + +## Coverage + +**Requirements:** +- No enforced coverage target +- Coverage tracked for awareness only + +**Configuration:** +- Not explicitly configured +- Could use `cargo tarpaulin` or similar + +**Current State:** +- Only `src/session/manager.rs` has tests (7 tests) +- Other modules lack test coverage (state, main, ui, zoxide) + +## Test Types + +**Unit Tests:** +- Scope: Test single function/method in isolation +- Location: `src/session/manager.rs` (7 tests) +- Focus: `SessionManager::update_sessions_stable()` behavior +- Speed: Fast (<1s total) + +**Integration Tests:** +- Not currently implemented +- Would require Zellij plugin test harness + +**E2E Tests:** +- Not applicable (plugin runs inside Zellij) +- Manual testing via development layout (`zellij.kdl`) + +## Common Patterns + +**Testing State Changes:** +```rust +#[test] +fn test_session_removed_after_threshold_missing_updates() { + let mut manager = SessionManager::default(); + + // Add initial session + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Simulate threshold number of missing updates + for _ in 0..3 { + manager.update_sessions_stable(vec![]); + } + + // Assert session removed + assert_eq!(manager.sessions().len(), 0); +} +``` + +**Testing Boolean Returns:** +```rust +#[test] +fn test_is_current_session_update_triggers_change() { + let mut manager = SessionManager::default(); + manager.update_sessions_stable(vec![make_session("test", false)]); + + // Updating is_current should trigger change + let changed = manager.update_sessions_stable(vec![make_session("test", true)]); + + assert!(changed); +} +``` + +**Snapshot Testing:** +- Not used in this codebase +- Would require external crate + +## CI Testing + +**Pipeline (`.github/workflows/ci.yml`):** +```yaml +test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --target x86_64-unknown-linux-gnu +``` + +**Notes:** +- Tests run on Linux CI (ubuntu-latest) +- Explicit native target required (overrides .cargo/config.toml) +- Separate job for WASM compilation verification + +## Test Gaps + +**Missing Coverage:** +- `src/state.rs` (709 lines) - Central orchestration logic +- `src/main.rs` (503 lines) - Smart naming algorithm (HIGH PRIORITY) +- `src/ui/renderer.rs` (431 lines) - Rendering logic +- `src/zoxide/search.rs` (204 lines) - Fuzzy search + +**Risks:** +- Smart naming algorithm has many edge cases untested +- Session creation flow untested +- UI rendering behavior untested + +**Recommendations:** +- Add unit tests for `generate_smart_session_names()` in `src/main.rs` +- Add tests for `SearchEngine` in `src/zoxide/search.rs` +- Consider snapshot tests for complex output + +--- + +*Testing analysis: 2026-01-13* +*Update when test patterns change* diff --git a/CLAUDE.md b/CLAUDE.md index ed5936d..c6375da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,43 @@ Note: `SessionInfo` from `zellij-tile` has many required fields. See `manager.rs ## CI GitHub Actions workflow (`.github/workflows/ci.yml`) runs on PRs and pushes to main: -- `test` - Runs unit tests on native target +- `test` - Runs unit tests on native target (must explicitly specify `--target x86_64-unknown-linux-gnu` because `.cargo/config.toml` defaults to wasm32-wasip1) - `build-wasm` - Verifies WASM compilation - `clippy` - Lints with `-D warnings` - `fmt` - Checks formatting + +## WASM Sandbox Limitations + +Zellij plugins run in a WASI sandbox with restricted filesystem access: + +- **Cannot directly write to real filesystem** - `std::fs::write()` writes to a sandboxed virtual filesystem +- **Mapped paths** (per [Zellij docs](https://zellij.dev/documentation/plugin-api-file-system.html)): + - `/host` - Working directory of last focused terminal + - `/data` - Plugin-specific folder (but NOT shared across sessions despite docs) + - `/tmp` - Sandboxed temp directory (NOT the real /tmp) + +**To persist data across sessions**, use `run_command` to shell out: +```rust +run_command(&["sh", "-c", "echo 'data' > /tmp/myfile"], context); +``` + +## Plugin Instance Behavior + +**Each Zellij session has its own plugin instance** with separate state: +- Switching from session A to B means interacting with B's plugin instance +- State does not transfer between sessions automatically +- To share state, must use external storage (files via `run_command`) + +**Plugin reloading**: +- `zellij action start-or-reload-plugin "file:/path/to/plugin.wasm"` - Reloads in current session only +- Must reload separately in each session, or close/reopen the plugin pane +- Closing the pane (not just hiding) and reopening loads the new binary + +## Quick-Switch Feature + +The plugin supports quick-switching to the previous session: +- When switching sessions, writes current session name to `/tmp/zsm-previous-session` via `run_command` +- When plugin opens, reads that file and pre-selects the previous session +- Press Enter to instantly toggle back + +Implementation in `state.rs`: `write_previous_session()` and `request_previous_session_read()` use async `run_command` because direct filesystem access is sandboxed. diff --git a/src/main.rs b/src/main.rs index 2f01664..a2a5f9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,13 @@ impl ZellijPlugin for PluginState { )); should_render = true; } + } else if context.contains_key("zsm_read_previous") { + let stdout_str = String::from_utf8_lossy(&stdout); + let previous = stdout_str.trim().to_string(); + if !previous.is_empty() { + self.set_previous_session(Some(previous)); + } + should_render = true; } } _ => (), diff --git a/src/state.rs b/src/state.rs index f8d461b..a59cc23 100644 --- a/src/state.rs +++ b/src/state.rs @@ -28,6 +28,8 @@ pub struct PluginState { colors: Option, /// Current session name current_session_name: Option, + /// Previous session name (for quick-switch) + previous_session_name: Option, /// Request IDs for plugin communication request_ids: Vec, /// Selected index in main list (when not searching) @@ -53,10 +55,20 @@ impl PluginState { /// Update session information with stability tracking /// Returns true if the session list actually changed pub fn update_sessions(&mut self, sessions: Vec) -> bool { - // Store current session name and layouts from the incoming data + // Find the new current session from incoming data for session in &sessions { if session.is_current_session { - self.current_session_name = Some(session.name.clone()); + let new_current = Some(session.name.clone()); + + // Track session change for quick-switch + if self.current_session_name != new_current { + // Request async read of previous session + self.request_previous_session_read(); + // Reset selection so it will be initialized to previous session + self.selected_index = None; + } + + self.current_session_name = new_current; self.new_session_info .update_layout_list(session.available_layouts.clone()); break; @@ -228,14 +240,31 @@ impl PluginState { } /// Get selected index for main screen - pub fn selected_index(&self) -> Option { + /// Lazily initializes to previous session for quick-switch if no selection yet + pub fn selected_index(&mut self) -> Option { if self.search_engine.is_searching() { self.search_engine.selected_index() } else { + // Initialize selection to previous session if not set + if self.selected_index.is_none() { + self.selected_index = self.find_previous_session_index(); + } self.selected_index } } + /// Find the index of the previous session in the display list + fn find_previous_session_index(&self) -> Option { + let previous = self.previous_session_name.as_ref()?; + let items = self.display_items(); + + items.iter().position(|item| match item { + SessionItem::ExistingSession { name, .. } => name == previous, + SessionItem::ResurrectableSession { name, .. } => name == previous, + _ => false, + }) + } + /// Get colors pub fn colors(&self) -> Option { self.colors @@ -453,6 +482,10 @@ impl PluginState { if let Some((is_session, name, path)) = selected_item_data { if is_session { + // Write current session as "previous" before switching + if let Some(ref current) = self.current_session_name { + Self::write_previous_session(current); + } // Switch to existing session self.session_manager .execute_action(SessionAction::Switch(name)); @@ -594,6 +627,11 @@ impl PluginState { return; } + // Write current session as "previous" before switching + if let Some(ref current) = self.current_session_name { + Self::write_previous_session(current); + } + // Create session with default layout if configured match &self.config.default_layout { Some(layout_name) => { @@ -632,4 +670,40 @@ impl PluginState { hide_self(); } + + /// Write the current session as the previous session via shell command + fn write_previous_session(session_name: &str) { + use zellij_tile::prelude::run_command; + let mut context = BTreeMap::new(); + context.insert("zsm_internal".to_string(), "write".to_string()); + run_command( + &[ + "sh", + "-c", + &format!("echo '{}' > /tmp/zsm-previous-session", session_name), + ], + context, + ); + } + + /// Request async read of previous session + pub fn request_previous_session_read(&self) { + use zellij_tile::prelude::run_command; + let mut context = BTreeMap::new(); + context.insert("zsm_read_previous".to_string(), "true".to_string()); + run_command( + &[ + "sh", + "-c", + "cat /tmp/zsm-previous-session 2>/dev/null || echo ''", + ], + context, + ); + } + + /// Set previous session (called from async result) + pub fn set_previous_session(&mut self, name: Option) { + self.previous_session_name = name; + self.selected_index = None; + } } diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index a952ab4..95e60f2 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -11,7 +11,7 @@ pub struct PluginRenderer; impl PluginRenderer { /// Render the main plugin interface - pub fn render(state: &PluginState, rows: usize, cols: usize) { + pub fn render(state: &mut PluginState, rows: usize, cols: usize) { let (x, y, width, height) = Self::calculate_main_size(rows, cols); match state.active_screen() { @@ -19,7 +19,7 @@ impl PluginRenderer { Self::render_main_screen(state, x, y, width, height); } ActiveScreen::NewSession => { - Self::render_new_session_screen(state, x, y, width, height); + Self::render_new_session_screen(&*state, x, y, width, height); } } @@ -32,7 +32,13 @@ impl PluginRenderer { } /// Render the main screen with directory/session list - fn render_main_screen(state: &PluginState, x: usize, y: usize, width: usize, height: usize) { + fn render_main_screen( + state: &mut PluginState, + x: usize, + y: usize, + width: usize, + height: usize, + ) { let theme = state.colors().map(Theme::new); // Render title @@ -56,7 +62,7 @@ impl PluginRenderer { // Render main content let table_rows = height.saturating_sub(6); let table = if state.search_engine().is_searching() { - Self::render_search_results(state, table_rows, width, &theme) + Self::render_search_results(&*state, table_rows, width, &theme) } else { Self::render_all_items(state, table_rows, width, &theme) }; @@ -136,7 +142,7 @@ impl PluginRenderer { /// Render all items table fn render_all_items( - state: &PluginState, + state: &mut PluginState, table_rows: usize, table_width: usize, theme: &Option,