diff --git a/.gitignore b/.gitignore index fa245198f..d62350659 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ proptest-regressions/ # Flatpak build artifacts .flatpak-builder/ repo/ +/dist/ +/pkg/ diff --git a/Cargo.lock b/Cargo.lock index d9e5e7841..762415bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,32 @@ dependencies = [ "vsimd", ] +[[package]] +name = "beamterm-data" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9440270ceba7fccbd5aab22ffba8abafd13b63d30bc42c417b622fcc9643d7" +dependencies = [ + "compact_str 0.9.0", + "miniz_oxide", +] + +[[package]] +name = "beamterm-renderer" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "100e6159f2cd12ca83f89adaa812966d221616510c0a09afd4e1ea8e0f704bfb" +dependencies = [ + "beamterm-data", + "compact_str 0.9.0", + "console_error_panic_hook", + "js-sys", + "thiserror 2.0.17", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -509,6 +535,20 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -530,6 +570,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1104,6 +1154,17 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1187,17 +1248,21 @@ dependencies = [ "anyhow", "arboard", "async-trait", + "bitflags 2.10.0", "chrono", "clap", + "console_error_panic_hook", "crossterm 0.29.0", "ctor", "deno_ast", "deno_core", "deno_error", "dirs", + "getrandom 0.2.16", "ignore", "include_dir", "insta", + "js-sys", "libc", "libloading 0.9.0", "lru 0.16.2", @@ -1208,6 +1273,7 @@ dependencies = [ "proptest", "pulldown-cmark", "ratatui", + "ratzilla", "regex", "rust-i18n", "schemars", @@ -1245,6 +1311,9 @@ dependencies = [ "ureq", "url", "vt100", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "windows-sys 0.61.2", ] @@ -1390,8 +1459,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2122,6 +2193,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.3" @@ -2210,28 +2290,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "onig" -version = "6.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" -dependencies = [ - "bitflags 2.10.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -2653,11 +2711,27 @@ dependencies = [ "lru 0.12.5", "paste", "strum 0.26.3", + "time", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", ] +[[package]] +name = "ratzilla" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f4bf83094f5e2cce7f8d90a853f76d7254b6763d25b9cdfe11d0e76e16522" +dependencies = [ + "beamterm-renderer", + "bitvec", + "compact_str 0.9.0", + "console_error_panic_hook", + "ratatui", + "thiserror 2.0.17", + "web-sys", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3782,10 +3856,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", + "fancy-regex", "flate2", "fnv", "once_cell", - "onig", "plist", "regex-syntax", "serde", @@ -3943,7 +4017,9 @@ checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -4628,6 +4704,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -4740,6 +4829,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index ac4ce65da..0fc114711 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ required-features = ["dev-bins", "runtime"] [lib] name = "fresh" path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] [features] default = ["plugins", "runtime", "embed-plugins"] @@ -41,6 +42,7 @@ dev-bins = [] runtime = [ "dep:crossterm", "dep:ratatui", + "ratatui/crossterm", # Enable ratatui's crossterm backend for native "dep:chrono", "dep:clap", "dep:tracing", @@ -89,6 +91,29 @@ runtime = [ ] # Schema-only feature for minimal builds (just schema generation) schema-only = [] +# WASM browser build - uses Ratzilla for browser rendering +wasm = [ + "dep:ratzilla", + "dep:ratatui", # ratatui without crossterm for WASM + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:console_error_panic_hook", + "dep:web-sys", + "dep:js-sys", + "dep:getrandom", + "dep:bitflags", + # Core dependencies needed for WASM + "dep:unicode-width", + "dep:unicode-segmentation", + "dep:regex", + "dep:chrono", + "dep:async-trait", + # Syntax highlighting - syntect is pure Rust and works in WASM + "dep:syntect", + # Model layer dependencies (anyhow for Result, tracing for debug) + "dep:anyhow", + "dep:tracing", +] [dependencies] # Always required (for schema generation and runtime) @@ -100,7 +125,8 @@ once_cell = "1.20" # Runtime dependencies (optional, enabled by "runtime" feature) crossterm = { version = "0.29.0", features = ["osc52"], optional = true } -ratatui = { version = "0.29.0", optional = true } +# ratatui without default features (crossterm) - enable crossterm feature in runtime +ratatui = { version = "0.29.0", default-features = false, optional = true } chrono = { version = "0.4", default-features = false, features = ["std", "clock"], optional = true } clap = { version = "4.5", default-features = false, features = ["derive", "std", "help", "usage", "error-context"], optional = true } tracing = { version = "0.1", optional = true } @@ -144,7 +170,8 @@ dirs = { version = "6.0", optional = true } pulldown-cmark = { version = "0.13", default-features = false, optional = true } sha2 = { version = "0.10", optional = true } arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"], optional = true } -syntect = { version = "5.3", optional = true } +# Note: Using fancy-regex backend (pure Rust) instead of onig (C library) for WASM compatibility +syntect = { version = "5.3", default-features = false, features = ["parsing", "default-syntaxes", "regex-fancy", "plist-load", "yaml-load"], optional = true } ureq = { version = "2.12", default-features = false, features = ["tls"], optional = true } unicode-width = { version = "0.2", optional = true } unicode-segmentation = { version = "1.12", optional = true } @@ -158,6 +185,26 @@ include_dir = { version = "0.7", optional = true } tempfile = { version = "3.24", optional = true } trash = { version = "5.2.5", optional = true } +# WASM browser build dependencies (optional) +ratzilla = { version = "0.2", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +wasm-bindgen-futures = { version = "0.4", optional = true } +console_error_panic_hook = { version = "0.1", optional = true } +js-sys = { version = "0.3", optional = true } +getrandom = { version = "0.2", features = ["js"], optional = true } +bitflags = { version = "2.6", optional = true } + +[dependencies.web-sys] +version = "0.3" +optional = true +features = [ + "Window", + "Document", + "Element", + "HtmlElement", + "console", +] + [dev-dependencies] proptest = "1.9" tempfile = "3.24.0" @@ -182,6 +229,14 @@ split-debuginfo = "packed" inherits = "release" lto = "thin" +# WASM release build profile - optimized for size +[profile.wasm-release] +inherits = "release" +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +panic = "abort" # Smaller binary, no unwinding + [package.metadata.dist] # Point dist at the changelog so GitHub Releases get populated changelog = "CHANGELOG.md" @@ -235,3 +290,7 @@ WRAPPER chmod 755 /usr/bin/fresh fi """ + +# wasm-pack configuration +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/docs/design/WASM_BROWSER_MODE.md b/docs/design/WASM_BROWSER_MODE.md new file mode 100644 index 000000000..755d95a09 --- /dev/null +++ b/docs/design/WASM_BROWSER_MODE.md @@ -0,0 +1,723 @@ +# WASM Browser Mode Design + +This document outlines the design for running Fresh editor in a web browser using WebAssembly (WASM), with maximum code sharing between native and WASM builds. + +## Overview + +The goal is to compile Fresh to WASM and run it in a browser, sharing ~85% of the codebase with the native version. This enables: +- Running Fresh in any modern browser without installation +- Single codebase - bug fixes and features apply to both platforms +- Embedding Fresh in web-based IDEs or documentation sites +- Demo/playground functionality for the project website + +## Current Status + +### Completed Work + +The following modules have been ungated and are now shared between native and WASM: + +**Model layer (`src/model/`):** +- `buffer.rs` - Text buffer with FileSystem trait abstraction +- `cursor.rs` - Cursor state and operations +- `filesystem.rs` - FileSystem trait with StdFileSystem (native) and NoopFileSystem (WASM) +- `piece_tree.rs` - Piece table data structure +- `piece_tree_diff.rs` - Diff operations +- `marker_tree.rs` - Marker tree for selections/highlights +- `marker.rs` - Marker types +- `control_event.rs` - Control events +- `document_model.rs` - Document model +- `edit.rs` - Edit operations +- `event.rs` - Event system (streaming features gated internally) +- `line_diff.rs` - Line diffing + +**Primitives layer (`src/primitives/`):** +- `line_iterator.rs` - Line iteration utilities +- `snippet.rs` - Snippet handling +- `text_property.rs` - Text properties +- `word_navigation.rs` - Word navigation logic +- `grammar_registry.rs` - TextMate grammar registry (file loading gated internally) +- `highlight_engine.rs` - Syntax highlighting via syntect (TextMate grammars) +- `ansi.rs`, `ansi_background.rs` - ANSI parsing +- `display_width.rs` - Unicode display width +- `grapheme.rs` - Grapheme handling +- `line_wrapping.rs` - Line wrapping logic +- `syntect_highlighter.rs` - Syntect integration +- `visual_layout.rs` - Visual layout calculations + +**View layer (`src/view/`):** +- `overlay.rs` - Overlay rendering +- `markdown.rs` - Markdown parsing and rendering +- `color_support.rs` - Color support detection +- `composite_view.rs` - Composite view rendering +- `dimming.rs` - Text dimming effects +- `margin.rs` - Line number margins +- `scroll_sync.rs` - Scroll synchronization +- `text_content.rs` - Text content rendering +- `theme.rs` - Theme system +- `virtual_text.rs` - Virtual text (inline hints) + +### Key Design Patterns Used + +#### 1. FileSystem Trait Abstraction + +The `Buffer` type accepts a `FileSystem` implementation, allowing WASM to use a no-op implementation: + +```rust +// src/model/filesystem.rs +pub trait FileSystem: Send + Sync + std::fmt::Debug { + fn read_to_string(&self, path: &Path) -> io::Result; + fn write(&self, path: &Path, contents: &str) -> io::Result<()>; + fn exists(&self, path: &Path) -> bool; + // ... more methods +} + +// Native: real filesystem +pub struct StdFileSystem; + +// WASM: no-op implementation +pub struct NoopFileSystem; +``` + +#### 2. Dependency Injection for WASM Compatibility + +Instead of gating entire modules, we use dependency injection to make modules work in both environments: + +```rust +// src/primitives/grammar_registry.rs + +/// User-provided grammar (can be passed from JavaScript in WASM) +pub struct UserGrammar { + pub content: String, + pub extensions: Vec, + pub scope_name: String, +} + +impl GrammarRegistry { + /// Core constructor - works in both native and WASM + pub fn new(user_grammars: Vec) -> Self { ... } + + /// Convenience for WASM (no user grammars) + pub fn builtin_only() -> Self { Self::new(vec![]) } + + /// Native only: loads from ~/.config/fresh/grammars/ + #[cfg(feature = "runtime")] + pub fn load_from_config_dir() -> Self { ... } + + /// Auto-selects based on feature + pub fn for_editor() -> Arc { + #[cfg(feature = "runtime")] + { Arc::new(Self::load_from_config_dir()) } + #[cfg(not(feature = "runtime"))] + { Arc::new(Self::builtin_only()) } + } +} +``` + +#### 3. Internal Feature Gating + +For modules with optional features (like debug streaming), gate only the specific code that needs it: + +```rust +// src/model/event.rs + +pub struct EventLog { + events: Vec, + + /// Optional file for streaming events (runtime-only, for debugging) + #[cfg(feature = "runtime")] + stream_file: Option, +} + +impl EventLog { + pub fn new() -> Self { + Self { + events: Vec::new(), + #[cfg(feature = "runtime")] + stream_file: None, + } + } + + /// Enable streaming to file (runtime-only) + #[cfg(feature = "runtime")] + pub fn enable_streaming>(&mut self, path: P) -> io::Result<()> { ... } +} +``` + +#### 4. Abstract Input Events (PLANNED) + +Controls currently use crossterm input types directly. To make them WASM-compatible, we'll abstract input into platform-agnostic types: + +```rust +// src/model/input_events.rs (WASM-compatible) + +/// Platform-agnostic key codes +pub enum KeyCode { + Enter, Esc, Backspace, Delete, + Left, Right, Up, Down, Home, End, + Tab, BackTab, + Char(char), + F(u8), + // ... +} + +/// Key modifiers +pub struct KeyModifiers { + pub ctrl: bool, + pub shift: bool, + pub alt: bool, +} + +/// Platform-agnostic key event +pub struct KeyEvent { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} + +/// Mouse button +pub enum MouseButton { Left, Right, Middle } + +/// Mouse event kind +pub enum MouseEventKind { + Down(MouseButton), + Up(MouseButton), + Moved, + ScrollUp, + ScrollDown, +} + +/// Platform-agnostic mouse event +pub struct MouseEvent { + pub kind: MouseEventKind, + pub column: u16, + pub row: u16, + pub modifiers: KeyModifiers, +} +``` + +Then controls use these abstract types, with platform-specific conversion: + +```rust +// Native (runtime-only): convert crossterm events +impl From for KeyEvent { ... } +impl From for MouseEvent { ... } + +// WASM: convert from JavaScript events (via wasm-bindgen) +impl From for KeyEvent { ... } +impl From for MouseEvent { ... } +``` + +This allows `view/controls/*` to be fully WASM-compatible while keeping input handling in one place. + +#### 5. Abstract FsManager for File Browser (PLANNED) + +The `file_tree` and `file_browser` views import from `crate::services::fs`: + +```rust +// Current (runtime-only) +use crate::services::fs::{FsEntry, FsManager}; +``` + +The architecture already has: +- `FsBackend` trait (async, already abstract!) +- `FsEntry`, `FsMetadata` data types (pure structs) +- `FsManager` uses tokio primitives (the only blocker) + +**The only blocker is tokio primitives**, not the design: + +```rust +// Current FsManager +use tokio::sync::{oneshot, Mutex}; // <-- tokio-specific + +pub struct FsManager { + backend: Arc, // Already abstract! + pending_dir_requests: Arc>, // tokio Mutex +} +``` + +**Solution**: Replace tokio primitives with `futures` equivalents: + +```rust +// src/services/fs/manager.rs (becomes WASM-compatible) +use futures::lock::Mutex; // instead of tokio::sync::Mutex +use futures::channel::oneshot; // instead of tokio::sync::oneshot + +pub struct FsManager { + backend: Arc, + pending_dir_requests: Arc>, +} +``` + +Then: +- `FsManager` becomes WASM-compatible (uses `futures` crate, works with `wasm-bindgen-futures`) +- `FsBackend` remains the platform-specific part: + - Native: `StdFsBackend` (real filesystem via tokio) + - WASM: `WasmFsBackend` (in-memory or JS-backed) +- `file_tree`, `file_browser` can be ungated + +This is cleaner than moving data types - the abstraction boundary is already correct, just needs runtime-agnostic primitives. + +## Architecture: Shared `editor-core` + +The key insight is to extract platform-agnostic code into modules that compile for both native and WASM targets, with platform-specific code gated behind feature flags. + +``` +fresh/ +├── src/ +│ ├── lib.rs # Exports all modules +│ │ +│ ├── core/ # SHARED: Platform-agnostic editor core +│ │ ├── buffer.rs # Text buffer, undo/redo, selections +│ │ ├── editor_state.rs # Editor state machine +│ │ ├── input.rs # Key/mouse event handling (abstract) +│ │ └── mod.rs +│ │ +│ ├── view/ # SHARED: All rendering and UI +│ │ ├── theme.rs # Theme system (loads embedded or from disk) +│ │ ├── render.rs # Main render function +│ │ ├── widgets/ # All UI widgets +│ │ └── mod.rs +│ │ +│ ├── model/ # SHARED: Data structures +│ │ ├── buffer.rs # Buffer operations (gated file I/O) +│ │ └── mod.rs +│ │ +│ ├── highlight/ # SHARED: Syntax highlighting +│ │ ├── syntect.rs # syntect-based highlighting (works in WASM) +│ │ ├── tree_sitter.rs # tree-sitter (native only, optional) +│ │ └── mod.rs +│ │ +│ ├── services/ # NATIVE ONLY: System services +│ │ ├── fs.rs # File system (native implementation) +│ │ ├── lsp.rs # LSP client +│ │ ├── terminal.rs # PTY/terminal emulation +│ │ └── mod.rs +│ │ +│ ├── app/ # NATIVE ONLY: Native application +│ │ ├── mod.rs # Crossterm-based main loop +│ │ └── ... +│ │ +│ └── wasm/ # WASM ONLY: Browser entry point +│ ├── mod.rs # Ratzilla integration, main loop +│ ├── fs_backend.rs # In-memory filesystem +│ └── event_adapter.rs # Ratzilla → internal event conversion +│ +└── web/ + └── styles.css # Browser styling +``` + +## Code Sharing Analysis + +### Shared Between Native and WASM (~85%) + +| Component | Description | +|-----------|-------------| +| `view/theme.rs` | Theme definitions, color parsing, embedded themes | +| `view/render.rs` | All rendering to ratatui Frame | +| `view/widgets/*` | Tab bar, status bar, popups, file browser UI, etc. | +| `model/buffer.rs` | Text buffer core (undo/redo, cursors, selections) | +| `core/editor_state.rs` | Editor state machine, mode handling | +| `core/input.rs` | Key/mouse event processing (abstract types) | +| `highlight/syntect.rs` | Syntax highlighting via syntect (pure Rust) | +| `config.rs` | Configuration system | +| `types.rs` | Core type definitions | + +### Native Only (~10%) + +| Component | Why Native Only | +|-----------|-----------------| +| `services/fs.rs` | Real filesystem access | +| `services/lsp.rs` | LSP protocol, subprocess spawning | +| `services/terminal.rs` | PTY, alacritty_terminal | +| `highlight/tree_sitter.rs` | C-based grammars (optional) | +| `app/mod.rs` | Crossterm event loop | + +### WASM Only (~5%) + +| Component | Purpose | +|-----------|---------| +| `wasm/mod.rs` | Ratzilla backend, browser main loop | +| `wasm/fs_backend.rs` | In-memory + IndexedDB filesystem | +| `wasm/event_adapter.rs` | Ratzilla event → internal event types | + +## Syntax Highlighting Strategy + +### GrammarRegistry (Both platforms) + +The `GrammarRegistry` manages TextMate grammars via syntect. It's been refactored to work in both native and WASM using dependency injection: + +```rust +// src/primitives/grammar_registry.rs + +/// User-provided grammar definition (can come from JavaScript in WASM) +pub struct UserGrammar { + pub content: String, // sublime-syntax or tmLanguage content + pub extensions: Vec, // file extensions (without dot) + pub scope_name: String, // e.g., "source.rust" +} + +impl GrammarRegistry { + /// Core constructor - works everywhere + /// Loads: syntect defaults (100+ languages) + embedded grammars + user grammars + pub fn new(user_grammars: Vec) -> Self { ... } + + /// WASM convenience - just built-in grammars + pub fn builtin_only() -> Self { Self::new(vec![]) } + + /// Native only - loads user grammars from ~/.config/fresh/grammars/ + #[cfg(feature = "runtime")] + pub fn load_from_config_dir() -> Self { ... } + + /// Auto-selects appropriate constructor based on feature + pub fn for_editor() -> Arc { ... } +} +``` + +**WASM usage:** +```rust +// In WASM, use built-in grammars (100+ languages from syntect) +let registry = GrammarRegistry::builtin_only(); + +// Or pass grammars from JavaScript +let registry = GrammarRegistry::new(vec![ + UserGrammar { + content: toml_grammar_string, + extensions: vec!["toml".to_string()], + scope_name: "source.toml".to_string(), + } +]); +``` + +### syntect (Both platforms) + +[syntect](https://github.com/trishume/syntect) is pure Rust and compiles to WASM. We use `fancy-regex` backend instead of `onig` for WASM compatibility: + +```toml +# Cargo.toml +syntect = { version = "5", default-features = false, features = [ + "default-syntaxes", + "default-themes", + "regex-fancy", # Pure Rust regex (WASM-compatible) +] } +``` + +### tree-sitter (Native only) + +tree-sitter grammars require C compilation, not available in WASM. Use syntect as the universal highlighter, with tree-sitter as a native-only enhancement for advanced features: + +```rust +// src/primitives/mod.rs + +// Tree-sitter modules are runtime-only (require native C code) +#[cfg(feature = "runtime")] +pub mod tree_sitter_scope; +#[cfg(feature = "runtime")] +pub mod tree_sitter_stack; +``` + +## Feature Flags + +```toml +# Cargo.toml + +[features] +default = ["runtime"] + +# Full native runtime with all features +runtime = [ + "dep:crossterm", + "dep:tokio", + "tree-sitter", + "lsp", + "terminal", + # ... other native deps +] + +# Tree-sitter highlighting (native only, optional) +tree-sitter = [ + "dep:tree-sitter", + "dep:tree-sitter-highlight", + # ... grammar deps +] + +# LSP support (native only) +lsp = ["dep:lsp-types", "dep:url"] + +# Terminal emulator (native only) +terminal = ["dep:alacritty_terminal", "dep:portable-pty"] + +# WASM browser build +wasm = [ + "dep:ratzilla", + "dep:wasm-bindgen", + "dep:wasm-bindgen-futures", + "dep:console_error_panic_hook", + "dep:web-sys", + "dep:js-sys", + "syntect-wasm", +] + +# syntect for WASM (embedded assets) +syntect-wasm = ["dep:syntect"] + +[dependencies] +# Always available +syntect = { version = "5.3", optional = true, default-features = false, features = ["default-syntaxes", "default-themes"] } +ratatui = { version = "0.29", default-features = false } + +# Native only +crossterm = { version = "0.29", optional = true } +tokio = { version = "1", optional = true, features = ["full"] } + +# WASM only +ratzilla = { version = "0.2", optional = true } +wasm-bindgen = { version = "0.2", optional = true } +``` + +## Implementation Plan + +### Phase 1: Core Infrastructure ✅ COMPLETED + +1. ✅ **FileSystem trait abstraction** + - Created `FileSystem` trait in `model/filesystem.rs` + - Implemented `StdFileSystem` for native, `NoopFileSystem` for WASM + - Buffer now stores filesystem internally + +2. ✅ **Buffer and piece_tree WASM-compatible** + - `model/buffer.rs`, `model/piece_tree.rs` now compile for WASM + - File I/O abstracted via FileSystem trait + +3. ✅ **Ungate pure Rust model modules** + - `marker_tree.rs`, `marker.rs`, `overlay.rs` + - `control_event.rs`, `document_model.rs`, `edit.rs`, `line_diff.rs` + - `event.rs` (with streaming features gated internally) + +### Phase 2: Primitives Layer ✅ COMPLETED + +4. ✅ **Ungate pure Rust primitives** + - `line_iterator.rs`, `snippet.rs`, `text_property.rs`, `word_navigation.rs` + +5. ✅ **GrammarRegistry refactored for WASM** + - Dependency injection pattern: `new(user_grammars)` constructor + - `builtin_only()` for WASM, `load_from_config_dir()` for native + - File loading methods gated internally + +6. ✅ **syntect with fancy-regex for WASM** + - Replaced `onig` (C library) with `fancy-regex` (pure Rust) + - All 100+ built-in syntaxes available in WASM + +### Phase 3: View Layer (IN PROGRESS) + +7. ✅ **Ungate pure view rendering modules** + - `markdown.rs`, `theme.rs`, `overlay.rs`, `margin.rs`, `virtual_text.rs` + - `color_support.rs`, `composite_view.rs`, `dimming.rs`, `scroll_sync.rs`, `text_content.rs` + +8. ⬜ **Abstract input events for controls** + - Create `model/input_events.rs` with platform-agnostic types + - Update `view/controls/*` to use abstract types + - Add crossterm conversion (runtime-only) + - This unblocks: `text_input`, `checkbox`, `button`, `dropdown`, etc. + +9. ⬜ **Abstract FsManager with runtime-agnostic primitives** + - Replace `tokio::sync::{Mutex, oneshot}` with `futures::{lock::Mutex, channel::oneshot}` + - Move `FsManager`, `FsEntry`, `FsMetadata` to WASM-compatible location + - Keep `FsBackend` implementations platform-specific (StdFsBackend, WasmFsBackend) + - This unblocks: `file_tree`, `file_browser`, and entire `services/fs` module + +10. ⬜ **Ungate pure UI components** (quick wins) + - `ui/scrollbar.rs` - pure ratatui widgets, no dependencies + - `ui/scroll_panel.rs` - only uses `view/theme` (already ungated) + - `ui/text_edit.rs` - only uses `primitives/word_navigation` (already ungated) + - `ui/menu.rs` - mostly pure (uses config, theme) + + **UI Module Dependency Analysis:** + ``` + Pure (can ungate now): + ├── scrollbar.rs → pure ratatui + ├── scroll_panel.rs → view/theme ✓ + ├── text_edit.rs → primitives/word_navigation ✓ + └── menu.rs → config, view/theme ✓ + + Blocked by crossterm/input (needs #8): + └── menu_input.rs → crossterm, input/handler + + Blocked by EditorState (needs #11): + ├── tabs.rs → app::BufferMetadata, state::EditorState + ├── status_bar.rs → app::WarningLevel, state::EditorState + └── split_rendering.rs → app, state, services::plugins + + Blocked by file_tree (needs #9): + └── file_explorer.rs → view/file_tree (gated) + + Blocked by prompt (needs #8): + └── suggestions.rs → input/commands, view/prompt (gated) + + Blocked by services (needs #13): + ├── view_pipeline.rs → services::plugins::api + └── file_browser.rs → app::file_open, app::HoverTarget + ``` + +11. ⬜ **Abstract EditorState access for UI components** + + Many UI components (tabs, status_bar, split_rendering, file_browser) need access to EditorState, but EditorState contains runtime-only fields: + - `indent_calculator: IndentCalculator` - tree-sitter based + - `semantic_highlight_cache` - tree-sitter based + - Imports from gated modules (popup, semantic_highlight) + + **Solution**: Create `EditorStateView` trait with only the fields UI needs: + ```rust + // src/model/editor_state_view.rs (WASM-compatible) + pub trait EditorStateView { + fn buffer(&self) -> &Buffer; + fn cursors(&self) -> &Cursors; + fn mode(&self) -> &str; + fn highlighter(&self) -> &HighlightEngine; + fn overlays(&self) -> &OverlayManager; + // ... other pure view data + } + ``` + + Then UI components use `impl EditorStateView` instead of `&EditorState`: + - Trait is WASM-compatible (pure Rust types) + - Concrete impl can be runtime-gated + - UI components become ungatable + +12. ⬜ **Factor out shared types from app/types.rs** + + `BufferMetadata`, `HoverTarget`, `ViewLineMapping` are used by UI but contain runtime deps: + - `lsp_types::Uri` - LSP dependency + - `crate::input::keybindings::Action` - input handling + + **Solution**: Extract pure data types to `model/buffer_types.rs`: + ```rust + // Remove lsp_types::Uri, use String for URI + // Remove Action references, or make Action abstract + ``` + +13. ⬜ **Abstract plugins API for view_pipeline** + + `view_pipeline.rs` and `split_rendering.rs` use `crate::services::plugins::api`: + - Needs trait abstraction or stubbed implementation for WASM + +### Phase 4: State Sharing (PLANNED) + +14. ⬜ **Share EditorState between native and WASM** + - Extract platform-agnostic state from current Editor + - Create shared state machine + +### Phase 5: Full Integration (PLANNED) + +15. ⬜ **WASM entry point using shared core** + - Update `wasm/mod.rs` to use shared modules + - Replace WasmEditorState with shared EditorState + +16. ⬜ **JavaScript interop** + - Expose APIs for file loading, grammar injection + - Event handling from browser + +17. ⬜ **Testing and polish** + - Visual parity verification + - Performance optimization + - Browser compatibility testing + +## Module Gating Pattern + +```rust +// Example: src/view/theme.rs + +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; + +// Always available +#[derive(Debug, Clone)] +pub struct Theme { + pub editor_bg: Color, + pub editor_fg: Color, + // ... all color fields +} + +impl Theme { + /// Default theme (always available) + pub fn default() -> Self { + Self::dracula() + } + + /// Built-in Dracula theme + pub fn dracula() -> Self { + Self { + editor_bg: Color::Rgb(40, 42, 54), + editor_fg: Color::Rgb(248, 248, 242), + // ... + } + } + + /// List of embedded theme names + pub fn embedded_themes() -> &'static [&'static str] { + &["dracula", "monokai", "solarized-dark", "solarized-light"] + } +} + +// Native only: file-based theme loading +#[cfg(feature = "runtime")] +impl Theme { + pub fn from_file>(path: P) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + Self::from_json(&content) + } + + pub fn available_themes() -> Vec { + // Scan themes directory + // ... + } +} +``` + +## Build Commands + +```bash +# Native build (default) +cargo build --release + +# WASM build +./scripts/wasm-build.sh +# or manually: +wasm-pack build --target web --no-default-features --features wasm + +# Test both +cargo test # Native tests +wasm-pack test --headless --firefox # WASM tests +``` + +## Expected Outcomes + +### Code Sharing +- **~85% shared** between native and WASM +- Single source of truth for rendering, themes, buffer operations +- Bug fixes automatically apply to both platforms + +### Feature Parity + +| Feature | Native | WASM | +|---------|--------|------| +| Text editing | ✅ | ✅ | +| Undo/redo | ✅ | ✅ | +| Themes | ✅ | ✅ | +| Syntax highlighting | ✅ (tree-sitter + syntect) | ✅ (syntect) | +| Multiple buffers | ✅ | ✅ | +| Split views | ✅ | ✅ | +| File browser UI | ✅ | ✅ | +| Search/replace | ✅ | ✅ | +| Mouse support | ✅ | ✅ | +| LSP | ✅ | ❌ (maybe via proxy) | +| Terminal emulator | ✅ | ❌ | +| Real filesystem | ✅ | ❌ (in-memory/IndexedDB) | +| Plugins | ✅ | ❌ (future: wasm plugins) | + +### Binary Size +- Native: ~15-20MB (with all features) +- WASM: ~2-3MB (compressed), ~5-8MB uncompressed + +## References + +- [Ratzilla](https://github.com/orhun/ratzilla) - Browser terminal backend +- [syntect](https://github.com/trishume/syntect) - Pure Rust syntax highlighting +- [wasm-pack](https://rustwasm.github.io/wasm-pack/) - WASM build tool diff --git a/scripts/wasm-build.sh b/scripts/wasm-build.sh new file mode 100755 index 000000000..db8512b0f --- /dev/null +++ b/scripts/wasm-build.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# WASM build script using wasm-pack +# Creates a deployable WASM bundle in dist/ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" + +echo "=== Fresh Editor WASM Build ===" +echo "" + +# Check if wasm-pack is installed +if ! command -v wasm-pack &> /dev/null; then + echo "wasm-pack not found. Installing..." + cargo install wasm-pack +fi + +# Check if wasm32 target is installed +if ! rustup target list --installed | grep -q "wasm32-unknown-unknown"; then + echo "Installing wasm32-unknown-unknown target..." + rustup target add wasm32-unknown-unknown +fi + +# Clean previous build +echo "Cleaning previous build..." +rm -rf pkg/ dist/ + +# Build with wasm-pack +echo "Building WASM with wasm-pack..." +wasm-pack build --target web --no-default-features --features wasm + +# Create dist directory with all assets +echo "Assembling dist directory..." +mkdir -p dist + +# Copy wasm-pack output +cp pkg/*.js dist/ +cp pkg/*.wasm dist/ +cp pkg/*.d.ts dist/ 2>/dev/null || true + +# Create a simple HTML loader that works with wasm-pack output +cat > dist/index.html << 'HTMLEOF' + + + + + + Fresh Editor + + + +
+
+
+

Loading Fresh Editor...

+
+ + + +HTMLEOF + +# Copy styles +cp web/styles.css dist/ + +echo "" +echo "=== Build Complete ===" +echo "" +echo "Output directory: dist/" +echo "" +echo "File sizes:" +ls -lh dist/*.wasm 2>/dev/null || echo " (no WASM files found)" +ls -lh dist/*.js 2>/dev/null || echo " (no JS files found)" +echo "" +echo "To test locally:" +echo " cd dist && python3 -m http.server 8080" +echo " Open http://localhost:8080" +echo "" diff --git a/scripts/wasm-setup.sh b/scripts/wasm-setup.sh new file mode 100755 index 000000000..bf66d93a5 --- /dev/null +++ b/scripts/wasm-setup.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Setup script for Fresh Editor WASM build environment +# This script installs the required tools for building the WASM version + +set -e + +echo "=== Fresh Editor WASM Build Setup ===" +echo "" + +# Check if Rust is installed +if ! command -v cargo &> /dev/null; then + echo "Error: Rust/Cargo not found. Please install Rust first:" + echo " curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" + exit 1 +fi + +echo "Rust version: $(rustc --version)" +echo "" + +# Add wasm32 target +echo "Adding wasm32-unknown-unknown target..." +rustup target add wasm32-unknown-unknown + +# Install wasm-pack +echo "" +echo "Installing wasm-pack..." +if ! command -v wasm-pack &> /dev/null; then + cargo install wasm-pack +else + echo "wasm-pack is already installed: $(wasm-pack --version)" +fi + +# Check for wasm-opt (optional) +echo "" +echo "Checking for wasm-opt (optional, for smaller builds)..." +if command -v wasm-opt &> /dev/null; then + echo "wasm-opt is installed: $(wasm-opt --version)" +else + echo "wasm-opt not found. For smaller WASM files, install binaryen:" + echo " Ubuntu/Debian: sudo apt install binaryen" + echo " macOS: brew install binaryen" +fi + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "To build:" +echo " ./scripts/wasm-build.sh" +echo "" +echo "To test locally:" +echo " cd dist && python3 -m http.server 8080" +echo "" diff --git a/src/config.rs b/src/config.rs index 81231696d..9d18a40ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -155,6 +155,7 @@ impl CursorStyle { ]; /// Convert to crossterm cursor style + #[cfg(feature = "runtime")] pub fn to_crossterm_style(self) -> crossterm::cursor::SetCursorStyle { use crossterm::cursor::SetCursorStyle; match self { @@ -265,6 +266,7 @@ impl Default for LineEndingOption { impl LineEndingOption { /// Convert to the buffer's LineEnding type + #[cfg(feature = "runtime")] pub fn to_line_ending(&self) -> crate::model::buffer::LineEnding { match self { Self::Lf => crate::model::buffer::LineEnding::LF, @@ -1070,6 +1072,7 @@ impl MenuItem { } /// Generate menu items for a dynamic source + #[cfg(feature = "runtime")] pub fn generate_dynamic_items(source: &str) -> Vec { match source { "copy_with_theme" => { @@ -1094,6 +1097,15 @@ impl MenuItem { }], } } + + /// Generate menu items for a dynamic source (WASM/non-runtime builds) + #[cfg(not(feature = "runtime"))] + pub fn generate_dynamic_items(source: &str) -> Vec { + // Dynamic menu items not available in non-runtime builds + vec![MenuItem::Label { + info: format!("Dynamic source '{}' not available", source), + }] + } } impl Default for Config { @@ -2258,7 +2270,14 @@ impl Config { languages } + /// Create default LSP configurations (non-runtime builds return empty) + #[cfg(not(feature = "runtime"))] + fn default_lsp_config() -> HashMap { + HashMap::new() + } + /// Create default LSP configurations + #[cfg(feature = "runtime")] fn default_lsp_config() -> HashMap { let mut lsp = HashMap::new(); diff --git a/src/input/action.rs b/src/input/action.rs new file mode 100644 index 000000000..935105445 --- /dev/null +++ b/src/input/action.rs @@ -0,0 +1,705 @@ +//! Pure action and context types (WASM-compatible) +//! +//! These types define editor actions and contexts without any +//! platform-specific dependencies. + +use std::collections::HashMap; + +/// Context in which a keybinding is active +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KeyContext { + /// Global bindings that work in all contexts (checked first with highest priority) + Global, + /// Normal editing mode + Normal, + /// Prompt/minibuffer is active + Prompt, + /// Popup window is visible + Popup, + /// File explorer has focus + FileExplorer, + /// Menu bar is active + Menu, + /// Terminal has focus + Terminal, + /// Settings modal is active + Settings, +} + +impl KeyContext { + /// Check if a context should allow input + pub fn allows_text_input(&self) -> bool { + matches!(self, Self::Normal | Self::Prompt) + } + + /// Parse context from a "when" string + pub fn from_when_clause(when: &str) -> Option { + Some(match when.trim() { + "global" => Self::Global, + "prompt" => Self::Prompt, + "popup" => Self::Popup, + "fileExplorer" | "file_explorer" => Self::FileExplorer, + "normal" => Self::Normal, + "menu" => Self::Menu, + "terminal" => Self::Terminal, + "settings" => Self::Settings, + _ => return None, + }) + } + + /// Convert context to "when" clause string + pub fn to_when_clause(self) -> &'static str { + match self { + Self::Global => "global", + Self::Normal => "normal", + Self::Prompt => "prompt", + Self::Popup => "popup", + Self::FileExplorer => "fileExplorer", + Self::Menu => "menu", + Self::Terminal => "terminal", + Self::Settings => "settings", + } + } +} + +/// High-level actions that can be performed in the editor +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Action { + // Character input + InsertChar(char), + InsertNewline, + InsertTab, + + // Basic movement + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + MoveWordLeft, + MoveWordRight, + MoveLineStart, + MoveLineEnd, + MovePageUp, + MovePageDown, + MoveDocumentStart, + MoveDocumentEnd, + + // Selection movement (extends selection while moving) + SelectLeft, + SelectRight, + SelectUp, + SelectDown, + SelectWordLeft, + SelectWordRight, + SelectLineStart, + SelectLineEnd, + SelectDocumentStart, + SelectDocumentEnd, + SelectPageUp, + SelectPageDown, + SelectAll, + SelectWord, + SelectLine, + ExpandSelection, + + // Block/rectangular selection (column-wise) + BlockSelectLeft, + BlockSelectRight, + BlockSelectUp, + BlockSelectDown, + + // Editing + DeleteBackward, + DeleteForward, + DeleteWordBackward, + DeleteWordForward, + DeleteLine, + DeleteToLineEnd, + DeleteToLineStart, + TransposeChars, + OpenLine, + + // View + Recenter, + + // Selection + SetMark, + + // Clipboard + Copy, + CopyWithTheme(String), + Cut, + Paste, + + // Vi-style yank (copy without selection, then restore cursor) + YankWordForward, + YankWordBackward, + YankToLineEnd, + YankToLineStart, + + // Multi-cursor + AddCursorAbove, + AddCursorBelow, + AddCursorNextMatch, + RemoveSecondaryCursors, + + // File operations + Save, + SaveAs, + Open, + SwitchProject, + New, + Close, + CloseTab, + Quit, + Revert, + ToggleAutoRevert, + FormatBuffer, + + // Navigation + GotoLine, + GoToMatchingBracket, + JumpToNextError, + JumpToPreviousError, + + // Smart editing + SmartHome, + DedentSelection, + ToggleComment, + + // Bookmarks + SetBookmark(char), + JumpToBookmark(char), + ClearBookmark(char), + ListBookmarks, + + // Search options + ToggleSearchCaseSensitive, + ToggleSearchWholeWord, + ToggleSearchRegex, + ToggleSearchConfirmEach, + + // Macros + StartMacroRecording, + StopMacroRecording, + PlayMacro(char), + ToggleMacroRecording(char), + ShowMacro(char), + ListMacros, + PromptRecordMacro, + PromptPlayMacro, + PlayLastMacro, + + // Bookmarks (prompt-based) + PromptSetBookmark, + PromptJumpToBookmark, + + // Undo/redo + Undo, + Redo, + + // View + ScrollUp, + ScrollDown, + ShowHelp, + ShowKeyboardShortcuts, + ShowWarnings, + ShowLspStatus, + ClearWarnings, + CommandPalette, + ToggleLineWrap, + ToggleComposeMode, + SetComposeWidth, + SelectTheme, + SelectKeybindingMap, + SelectCursorStyle, + SelectLocale, + + // Buffer/tab navigation + NextBuffer, + PrevBuffer, + SwitchToPreviousTab, + SwitchToTabByName, + + // Tab scrolling + ScrollTabsLeft, + ScrollTabsRight, + + // Position history navigation + NavigateBack, + NavigateForward, + + // Split view operations + SplitHorizontal, + SplitVertical, + CloseSplit, + NextSplit, + PrevSplit, + IncreaseSplitSize, + DecreaseSplitSize, + ToggleMaximizeSplit, + + // Prompt mode actions + PromptConfirm, + PromptCancel, + PromptBackspace, + PromptDelete, + PromptMoveLeft, + PromptMoveRight, + PromptMoveStart, + PromptMoveEnd, + PromptSelectPrev, + PromptSelectNext, + PromptPageUp, + PromptPageDown, + PromptAcceptSuggestion, + PromptMoveWordLeft, + PromptMoveWordRight, + // Advanced prompt editing (word operations, clipboard) + PromptDeleteWordForward, + PromptDeleteWordBackward, + PromptDeleteToLineEnd, + PromptCopy, + PromptCut, + PromptPaste, + // Prompt selection actions + PromptMoveLeftSelecting, + PromptMoveRightSelecting, + PromptMoveHomeSelecting, + PromptMoveEndSelecting, + PromptSelectWordLeft, + PromptSelectWordRight, + PromptSelectAll, + + // File browser actions + FileBrowserToggleHidden, + + // Popup mode actions + PopupSelectNext, + PopupSelectPrev, + PopupPageUp, + PopupPageDown, + PopupConfirm, + PopupCancel, + + // File explorer operations + ToggleFileExplorer, + // Menu bar visibility + ToggleMenuBar, + FocusFileExplorer, + FocusEditor, + FileExplorerUp, + FileExplorerDown, + FileExplorerPageUp, + FileExplorerPageDown, + FileExplorerExpand, + FileExplorerCollapse, + FileExplorerOpen, + FileExplorerRefresh, + FileExplorerNewFile, + FileExplorerNewDirectory, + FileExplorerDelete, + FileExplorerRename, + FileExplorerToggleHidden, + FileExplorerToggleGitignored, + + // LSP operations + LspCompletion, + LspGotoDefinition, + LspReferences, + LspRename, + LspHover, + LspSignatureHelp, + LspCodeActions, + LspRestart, + LspStop, + ToggleInlayHints, + ToggleMouseHover, + + // View toggles + ToggleLineNumbers, + ToggleMouseCapture, + ToggleDebugHighlights, // Debug mode: show highlight/overlay byte ranges + SetBackground, + SetBackgroundBlend, + + // Buffer settings (per-buffer overrides) + SetTabSize, + SetLineEnding, + ToggleIndentationStyle, + ToggleTabIndicators, + ResetBufferSettings, + + // Config operations + DumpConfig, + + // Search and replace + Search, + FindInSelection, + FindNext, + FindPrevious, + FindSelectionNext, // Quick find next occurrence of selection (Ctrl+F3) + FindSelectionPrevious, // Quick find previous occurrence of selection (Ctrl+Shift+F3) + Replace, + QueryReplace, // Interactive replace (y/n/!/q for each match) + + // Menu navigation + MenuActivate, // Open menu bar (Alt or F10) + MenuClose, // Close menu (Esc) + MenuLeft, // Navigate to previous menu + MenuRight, // Navigate to next menu + MenuUp, // Navigate to previous item in menu + MenuDown, // Navigate to next item in menu + MenuExecute, // Execute selected menu item (Enter) + MenuOpen(String), // Open a specific menu by name (e.g., "File", "Edit") + + // Keybinding map switching + SwitchKeybindingMap(String), // Switch to a named keybinding map (e.g., "default", "emacs", "vscode") + + // Plugin custom actions + PluginAction(String), + + // Settings operations + OpenSettings, // Open the settings modal + CloseSettings, // Close the settings modal + SettingsSave, // Save settings changes + SettingsReset, // Reset current setting to default + SettingsToggleFocus, // Toggle focus between category and settings panels + SettingsActivate, // Activate/toggle the current setting + SettingsSearch, // Start search in settings + SettingsHelp, // Show settings help overlay + SettingsIncrement, // Increment number value or next dropdown option + SettingsDecrement, // Decrement number value or previous dropdown option + + // Terminal operations + OpenTerminal, // Open a new terminal in the current split + CloseTerminal, // Close the current terminal + FocusTerminal, // Focus the terminal buffer (if viewing terminal, focus input) + TerminalEscape, // Escape from terminal mode back to editor + ToggleKeyboardCapture, // Toggle keyboard capture mode (all keys go to terminal) + TerminalPaste, // Paste clipboard contents into terminal as a single batch + + // Shell command operations + ShellCommand, // Run shell command on buffer/selection, output to new buffer + ShellCommandReplace, // Run shell command on buffer/selection, replace content + + // Case conversion + ToUpperCase, // Convert selection to uppercase + ToLowerCase, // Convert selection to lowercase + + // Input calibration + CalibrateInput, // Open the input calibration wizard + + // No-op + None, +} + +impl Action { + fn with_char( + args: &HashMap, + make_action: impl FnOnce(char) -> Self, + ) -> Option { + if let Some(serde_json::Value::String(value)) = args.get("char") { + value.chars().next().map(make_action) + } else { + None + } + } + + /// Parse action from string (used when loading from config) + pub fn from_str(s: &str, args: &HashMap) -> Option { + Some(match s { + "insert_char" => return Self::with_char(args, Self::InsertChar), + "insert_newline" => Self::InsertNewline, + "insert_tab" => Self::InsertTab, + + "move_left" => Self::MoveLeft, + "move_right" => Self::MoveRight, + "move_up" => Self::MoveUp, + "move_down" => Self::MoveDown, + "move_word_left" => Self::MoveWordLeft, + "move_word_right" => Self::MoveWordRight, + "move_line_start" => Self::MoveLineStart, + "move_line_end" => Self::MoveLineEnd, + "move_page_up" => Self::MovePageUp, + "move_page_down" => Self::MovePageDown, + "move_document_start" => Self::MoveDocumentStart, + "move_document_end" => Self::MoveDocumentEnd, + + "select_left" => Self::SelectLeft, + "select_right" => Self::SelectRight, + "select_up" => Self::SelectUp, + "select_down" => Self::SelectDown, + "select_word_left" => Self::SelectWordLeft, + "select_word_right" => Self::SelectWordRight, + "select_line_start" => Self::SelectLineStart, + "select_line_end" => Self::SelectLineEnd, + "select_document_start" => Self::SelectDocumentStart, + "select_document_end" => Self::SelectDocumentEnd, + "select_page_up" => Self::SelectPageUp, + "select_page_down" => Self::SelectPageDown, + "select_all" => Self::SelectAll, + "select_word" => Self::SelectWord, + "select_line" => Self::SelectLine, + "expand_selection" => Self::ExpandSelection, + + // Block/rectangular selection + "block_select_left" => Self::BlockSelectLeft, + "block_select_right" => Self::BlockSelectRight, + "block_select_up" => Self::BlockSelectUp, + "block_select_down" => Self::BlockSelectDown, + + "delete_backward" => Self::DeleteBackward, + "delete_forward" => Self::DeleteForward, + "delete_word_backward" => Self::DeleteWordBackward, + "delete_word_forward" => Self::DeleteWordForward, + "delete_line" => Self::DeleteLine, + "delete_to_line_end" => Self::DeleteToLineEnd, + "delete_to_line_start" => Self::DeleteToLineStart, + "transpose_chars" => Self::TransposeChars, + "open_line" => Self::OpenLine, + "recenter" => Self::Recenter, + "set_mark" => Self::SetMark, + + "copy" => Self::Copy, + "copy_with_theme" => { + // Empty theme = open theme picker prompt + let theme = args.get("theme").and_then(|v| v.as_str()).unwrap_or(""); + Self::CopyWithTheme(theme.to_string()) + } + "cut" => Self::Cut, + "paste" => Self::Paste, + + // Vi-style yank actions + "yank_word_forward" => Self::YankWordForward, + "yank_word_backward" => Self::YankWordBackward, + "yank_to_line_end" => Self::YankToLineEnd, + "yank_to_line_start" => Self::YankToLineStart, + + "add_cursor_above" => Self::AddCursorAbove, + "add_cursor_below" => Self::AddCursorBelow, + "add_cursor_next_match" => Self::AddCursorNextMatch, + "remove_secondary_cursors" => Self::RemoveSecondaryCursors, + + "save" => Self::Save, + "save_as" => Self::SaveAs, + "open" => Self::Open, + "switch_project" => Self::SwitchProject, + "new" => Self::New, + "close" => Self::Close, + "close_tab" => Self::CloseTab, + "quit" => Self::Quit, + "revert" => Self::Revert, + "toggle_auto_revert" => Self::ToggleAutoRevert, + "format_buffer" => Self::FormatBuffer, + "goto_line" => Self::GotoLine, + "goto_matching_bracket" => Self::GoToMatchingBracket, + "jump_to_next_error" => Self::JumpToNextError, + "jump_to_previous_error" => Self::JumpToPreviousError, + + "smart_home" => Self::SmartHome, + "dedent_selection" => Self::DedentSelection, + "toggle_comment" => Self::ToggleComment, + + "set_bookmark" => return Self::with_char(args, Self::SetBookmark), + "jump_to_bookmark" => return Self::with_char(args, Self::JumpToBookmark), + "clear_bookmark" => return Self::with_char(args, Self::ClearBookmark), + + "list_bookmarks" => Self::ListBookmarks, + + "toggle_search_case_sensitive" => Self::ToggleSearchCaseSensitive, + "toggle_search_whole_word" => Self::ToggleSearchWholeWord, + "toggle_search_regex" => Self::ToggleSearchRegex, + "toggle_search_confirm_each" => Self::ToggleSearchConfirmEach, + + "start_macro_recording" => Self::StartMacroRecording, + "stop_macro_recording" => Self::StopMacroRecording, + "play_macro" => return Self::with_char(args, Self::PlayMacro), + "toggle_macro_recording" => return Self::with_char(args, Self::ToggleMacroRecording), + + "show_macro" => return Self::with_char(args, Self::ShowMacro), + + "list_macros" => Self::ListMacros, + "prompt_record_macro" => Self::PromptRecordMacro, + "prompt_play_macro" => Self::PromptPlayMacro, + "play_last_macro" => Self::PlayLastMacro, + "prompt_set_bookmark" => Self::PromptSetBookmark, + "prompt_jump_to_bookmark" => Self::PromptJumpToBookmark, + + "undo" => Self::Undo, + "redo" => Self::Redo, + + "scroll_up" => Self::ScrollUp, + "scroll_down" => Self::ScrollDown, + "show_help" => Self::ShowHelp, + "keyboard_shortcuts" => Self::ShowKeyboardShortcuts, + "show_warnings" => Self::ShowWarnings, + "show_lsp_status" => Self::ShowLspStatus, + "clear_warnings" => Self::ClearWarnings, + "command_palette" => Self::CommandPalette, + "toggle_line_wrap" => Self::ToggleLineWrap, + "toggle_compose_mode" => Self::ToggleComposeMode, + "set_compose_width" => Self::SetComposeWidth, + + "next_buffer" => Self::NextBuffer, + "prev_buffer" => Self::PrevBuffer, + + "navigate_back" => Self::NavigateBack, + "navigate_forward" => Self::NavigateForward, + + "split_horizontal" => Self::SplitHorizontal, + "split_vertical" => Self::SplitVertical, + "close_split" => Self::CloseSplit, + "next_split" => Self::NextSplit, + "prev_split" => Self::PrevSplit, + "increase_split_size" => Self::IncreaseSplitSize, + "decrease_split_size" => Self::DecreaseSplitSize, + "toggle_maximize_split" => Self::ToggleMaximizeSplit, + + "prompt_confirm" => Self::PromptConfirm, + "prompt_cancel" => Self::PromptCancel, + "prompt_backspace" => Self::PromptBackspace, + "prompt_move_left" => Self::PromptMoveLeft, + "prompt_move_right" => Self::PromptMoveRight, + "prompt_move_start" => Self::PromptMoveStart, + "prompt_move_end" => Self::PromptMoveEnd, + "prompt_select_prev" => Self::PromptSelectPrev, + "prompt_select_next" => Self::PromptSelectNext, + "prompt_page_up" => Self::PromptPageUp, + "prompt_page_down" => Self::PromptPageDown, + "prompt_accept_suggestion" => Self::PromptAcceptSuggestion, + "prompt_delete_word_forward" => Self::PromptDeleteWordForward, + "prompt_delete_word_backward" => Self::PromptDeleteWordBackward, + "prompt_delete_to_line_end" => Self::PromptDeleteToLineEnd, + "prompt_copy" => Self::PromptCopy, + "prompt_cut" => Self::PromptCut, + "prompt_paste" => Self::PromptPaste, + "prompt_move_left_selecting" => Self::PromptMoveLeftSelecting, + "prompt_move_right_selecting" => Self::PromptMoveRightSelecting, + "prompt_move_home_selecting" => Self::PromptMoveHomeSelecting, + "prompt_move_end_selecting" => Self::PromptMoveEndSelecting, + "prompt_select_word_left" => Self::PromptSelectWordLeft, + "prompt_select_word_right" => Self::PromptSelectWordRight, + "prompt_select_all" => Self::PromptSelectAll, + "file_browser_toggle_hidden" => Self::FileBrowserToggleHidden, + "prompt_move_word_left" => Self::PromptMoveWordLeft, + "prompt_move_word_right" => Self::PromptMoveWordRight, + "prompt_delete" => Self::PromptDelete, + + "popup_select_next" => Self::PopupSelectNext, + "popup_select_prev" => Self::PopupSelectPrev, + "popup_page_up" => Self::PopupPageUp, + "popup_page_down" => Self::PopupPageDown, + "popup_confirm" => Self::PopupConfirm, + "popup_cancel" => Self::PopupCancel, + + "toggle_file_explorer" => Self::ToggleFileExplorer, + "toggle_menu_bar" => Self::ToggleMenuBar, + "focus_file_explorer" => Self::FocusFileExplorer, + "focus_editor" => Self::FocusEditor, + "file_explorer_up" => Self::FileExplorerUp, + "file_explorer_down" => Self::FileExplorerDown, + "file_explorer_page_up" => Self::FileExplorerPageUp, + "file_explorer_page_down" => Self::FileExplorerPageDown, + "file_explorer_expand" => Self::FileExplorerExpand, + "file_explorer_collapse" => Self::FileExplorerCollapse, + "file_explorer_open" => Self::FileExplorerOpen, + "file_explorer_refresh" => Self::FileExplorerRefresh, + "file_explorer_new_file" => Self::FileExplorerNewFile, + "file_explorer_new_directory" => Self::FileExplorerNewDirectory, + "file_explorer_delete" => Self::FileExplorerDelete, + "file_explorer_rename" => Self::FileExplorerRename, + "file_explorer_toggle_hidden" => Self::FileExplorerToggleHidden, + "file_explorer_toggle_gitignored" => Self::FileExplorerToggleGitignored, + + "lsp_completion" => Self::LspCompletion, + "lsp_goto_definition" => Self::LspGotoDefinition, + "lsp_references" => Self::LspReferences, + "lsp_rename" => Self::LspRename, + "lsp_hover" => Self::LspHover, + "lsp_signature_help" => Self::LspSignatureHelp, + "lsp_code_actions" => Self::LspCodeActions, + "lsp_restart" => Self::LspRestart, + "lsp_stop" => Self::LspStop, + "toggle_inlay_hints" => Self::ToggleInlayHints, + "toggle_mouse_hover" => Self::ToggleMouseHover, + + "toggle_line_numbers" => Self::ToggleLineNumbers, + "toggle_mouse_capture" => Self::ToggleMouseCapture, + "toggle_debug_highlights" => Self::ToggleDebugHighlights, + "set_background" => Self::SetBackground, + "set_background_blend" => Self::SetBackgroundBlend, + "select_theme" => Self::SelectTheme, + "select_keybinding_map" => Self::SelectKeybindingMap, + "select_locale" => Self::SelectLocale, + + // Buffer settings + "set_tab_size" => Self::SetTabSize, + "set_line_ending" => Self::SetLineEnding, + "toggle_indentation_style" => Self::ToggleIndentationStyle, + "toggle_tab_indicators" => Self::ToggleTabIndicators, + "reset_buffer_settings" => Self::ResetBufferSettings, + + "dump_config" => Self::DumpConfig, + + "search" => Self::Search, + "find_in_selection" => Self::FindInSelection, + "find_next" => Self::FindNext, + "find_previous" => Self::FindPrevious, + "find_selection_next" => Self::FindSelectionNext, + "find_selection_previous" => Self::FindSelectionPrevious, + "replace" => Self::Replace, + "query_replace" => Self::QueryReplace, + + "menu_activate" => Self::MenuActivate, + "menu_close" => Self::MenuClose, + "menu_left" => Self::MenuLeft, + "menu_right" => Self::MenuRight, + "menu_up" => Self::MenuUp, + "menu_down" => Self::MenuDown, + "menu_execute" => Self::MenuExecute, + "menu_open" => { + let name = args.get("name")?.as_str()?; + Self::MenuOpen(name.to_string()) + } + + "switch_keybinding_map" => { + let map_name = args.get("map")?.as_str()?; + Self::SwitchKeybindingMap(map_name.to_string()) + } + + // Terminal actions + "open_terminal" => Self::OpenTerminal, + "close_terminal" => Self::CloseTerminal, + "focus_terminal" => Self::FocusTerminal, + "terminal_escape" => Self::TerminalEscape, + "toggle_keyboard_capture" => Self::ToggleKeyboardCapture, + "terminal_paste" => Self::TerminalPaste, + + // Shell command actions + "shell_command" => Self::ShellCommand, + "shell_command_replace" => Self::ShellCommandReplace, + + // Case conversion + "to_upper_case" => Self::ToUpperCase, + "to_lower_case" => Self::ToLowerCase, + + // Input calibration + "calibrate_input" => Self::CalibrateInput, + + // Settings actions + "open_settings" => Self::OpenSettings, + "close_settings" => Self::CloseSettings, + "settings_save" => Self::SettingsSave, + "settings_reset" => Self::SettingsReset, + "settings_toggle_focus" => Self::SettingsToggleFocus, + "settings_activate" => Self::SettingsActivate, + "settings_search" => Self::SettingsSearch, + "settings_help" => Self::SettingsHelp, + "settings_increment" => Self::SettingsIncrement, + "settings_decrement" => Self::SettingsDecrement, + + _ => return None, + }) + } +} diff --git a/src/input/command_registry.rs b/src/input/command_registry.rs index 16269cee9..0e32f3260 100644 --- a/src/input/command_registry.rs +++ b/src/input/command_registry.rs @@ -3,10 +3,9 @@ //! This module allows plugins to register custom commands dynamically //! while maintaining the built-in command set. +use crate::input::action::{Action, KeyContext}; use crate::input::commands::{get_all_commands, Command, Suggestion}; use crate::input::fuzzy::fuzzy_match; -use crate::input::keybindings::Action; -use crate::input::keybindings::KeyContext; use std::sync::{Arc, RwLock}; /// Registry for managing editor commands diff --git a/src/input/commands.rs b/src/input/commands.rs index 9aaf66d0d..4f5c75dfc 100644 --- a/src/input/commands.rs +++ b/src/input/commands.rs @@ -1,6 +1,6 @@ //! Command palette system for executing editor actions by name -use crate::input::keybindings::{Action, KeyContext}; +use crate::input::action::{Action, KeyContext}; use rust_i18n::t; /// Source of a command (builtin or from a plugin) diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 87b82b3ae..462002bf6 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -1,3 +1,6 @@ +// Re-export for backwards compatibility +pub use crate::input::action::{Action, KeyContext}; + use crate::config::Config; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use rust_i18n::t; @@ -147,705 +150,6 @@ pub fn terminal_key_equivalents( equivalents } -/// Context in which a keybinding is active -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KeyContext { - /// Global bindings that work in all contexts (checked first with highest priority) - Global, - /// Normal editing mode - Normal, - /// Prompt/minibuffer is active - Prompt, - /// Popup window is visible - Popup, - /// File explorer has focus - FileExplorer, - /// Menu bar is active - Menu, - /// Terminal has focus - Terminal, - /// Settings modal is active - Settings, -} - -impl KeyContext { - /// Check if a context should allow input - pub fn allows_text_input(&self) -> bool { - matches!(self, Self::Normal | Self::Prompt) - } - - /// Parse context from a "when" string - pub fn from_when_clause(when: &str) -> Option { - Some(match when.trim() { - "global" => Self::Global, - "prompt" => Self::Prompt, - "popup" => Self::Popup, - "fileExplorer" | "file_explorer" => Self::FileExplorer, - "normal" => Self::Normal, - "menu" => Self::Menu, - "terminal" => Self::Terminal, - "settings" => Self::Settings, - _ => return None, - }) - } - - /// Convert context to "when" clause string - pub fn to_when_clause(self) -> &'static str { - match self { - Self::Global => "global", - Self::Normal => "normal", - Self::Prompt => "prompt", - Self::Popup => "popup", - Self::FileExplorer => "fileExplorer", - Self::Menu => "menu", - Self::Terminal => "terminal", - Self::Settings => "settings", - } - } -} - -/// High-level actions that can be performed in the editor -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum Action { - // Character input - InsertChar(char), - InsertNewline, - InsertTab, - - // Basic movement - MoveLeft, - MoveRight, - MoveUp, - MoveDown, - MoveWordLeft, - MoveWordRight, - MoveLineStart, - MoveLineEnd, - MovePageUp, - MovePageDown, - MoveDocumentStart, - MoveDocumentEnd, - - // Selection movement (extends selection while moving) - SelectLeft, - SelectRight, - SelectUp, - SelectDown, - SelectWordLeft, - SelectWordRight, - SelectLineStart, - SelectLineEnd, - SelectDocumentStart, - SelectDocumentEnd, - SelectPageUp, - SelectPageDown, - SelectAll, - SelectWord, - SelectLine, - ExpandSelection, - - // Block/rectangular selection (column-wise) - BlockSelectLeft, - BlockSelectRight, - BlockSelectUp, - BlockSelectDown, - - // Editing - DeleteBackward, - DeleteForward, - DeleteWordBackward, - DeleteWordForward, - DeleteLine, - DeleteToLineEnd, - DeleteToLineStart, - TransposeChars, - OpenLine, - - // View - Recenter, - - // Selection - SetMark, - - // Clipboard - Copy, - CopyWithTheme(String), - Cut, - Paste, - - // Vi-style yank (copy without selection, then restore cursor) - YankWordForward, - YankWordBackward, - YankToLineEnd, - YankToLineStart, - - // Multi-cursor - AddCursorAbove, - AddCursorBelow, - AddCursorNextMatch, - RemoveSecondaryCursors, - - // File operations - Save, - SaveAs, - Open, - SwitchProject, - New, - Close, - CloseTab, - Quit, - Revert, - ToggleAutoRevert, - FormatBuffer, - - // Navigation - GotoLine, - GoToMatchingBracket, - JumpToNextError, - JumpToPreviousError, - - // Smart editing - SmartHome, - DedentSelection, - ToggleComment, - - // Bookmarks - SetBookmark(char), - JumpToBookmark(char), - ClearBookmark(char), - ListBookmarks, - - // Search options - ToggleSearchCaseSensitive, - ToggleSearchWholeWord, - ToggleSearchRegex, - ToggleSearchConfirmEach, - - // Macros - StartMacroRecording, - StopMacroRecording, - PlayMacro(char), - ToggleMacroRecording(char), - ShowMacro(char), - ListMacros, - PromptRecordMacro, - PromptPlayMacro, - PlayLastMacro, - - // Bookmarks (prompt-based) - PromptSetBookmark, - PromptJumpToBookmark, - - // Undo/redo - Undo, - Redo, - - // View - ScrollUp, - ScrollDown, - ShowHelp, - ShowKeyboardShortcuts, - ShowWarnings, - ShowLspStatus, - ClearWarnings, - CommandPalette, - ToggleLineWrap, - ToggleComposeMode, - SetComposeWidth, - SelectTheme, - SelectKeybindingMap, - SelectCursorStyle, - SelectLocale, - - // Buffer/tab navigation - NextBuffer, - PrevBuffer, - SwitchToPreviousTab, - SwitchToTabByName, - - // Tab scrolling - ScrollTabsLeft, - ScrollTabsRight, - - // Position history navigation - NavigateBack, - NavigateForward, - - // Split view operations - SplitHorizontal, - SplitVertical, - CloseSplit, - NextSplit, - PrevSplit, - IncreaseSplitSize, - DecreaseSplitSize, - ToggleMaximizeSplit, - - // Prompt mode actions - PromptConfirm, - PromptCancel, - PromptBackspace, - PromptDelete, - PromptMoveLeft, - PromptMoveRight, - PromptMoveStart, - PromptMoveEnd, - PromptSelectPrev, - PromptSelectNext, - PromptPageUp, - PromptPageDown, - PromptAcceptSuggestion, - PromptMoveWordLeft, - PromptMoveWordRight, - // Advanced prompt editing (word operations, clipboard) - PromptDeleteWordForward, - PromptDeleteWordBackward, - PromptDeleteToLineEnd, - PromptCopy, - PromptCut, - PromptPaste, - // Prompt selection actions - PromptMoveLeftSelecting, - PromptMoveRightSelecting, - PromptMoveHomeSelecting, - PromptMoveEndSelecting, - PromptSelectWordLeft, - PromptSelectWordRight, - PromptSelectAll, - - // File browser actions - FileBrowserToggleHidden, - - // Popup mode actions - PopupSelectNext, - PopupSelectPrev, - PopupPageUp, - PopupPageDown, - PopupConfirm, - PopupCancel, - - // File explorer operations - ToggleFileExplorer, - // Menu bar visibility - ToggleMenuBar, - FocusFileExplorer, - FocusEditor, - FileExplorerUp, - FileExplorerDown, - FileExplorerPageUp, - FileExplorerPageDown, - FileExplorerExpand, - FileExplorerCollapse, - FileExplorerOpen, - FileExplorerRefresh, - FileExplorerNewFile, - FileExplorerNewDirectory, - FileExplorerDelete, - FileExplorerRename, - FileExplorerToggleHidden, - FileExplorerToggleGitignored, - - // LSP operations - LspCompletion, - LspGotoDefinition, - LspReferences, - LspRename, - LspHover, - LspSignatureHelp, - LspCodeActions, - LspRestart, - LspStop, - ToggleInlayHints, - ToggleMouseHover, - - // View toggles - ToggleLineNumbers, - ToggleMouseCapture, - ToggleDebugHighlights, // Debug mode: show highlight/overlay byte ranges - SetBackground, - SetBackgroundBlend, - - // Buffer settings (per-buffer overrides) - SetTabSize, - SetLineEnding, - ToggleIndentationStyle, - ToggleTabIndicators, - ResetBufferSettings, - - // Config operations - DumpConfig, - - // Search and replace - Search, - FindInSelection, - FindNext, - FindPrevious, - FindSelectionNext, // Quick find next occurrence of selection (Ctrl+F3) - FindSelectionPrevious, // Quick find previous occurrence of selection (Ctrl+Shift+F3) - Replace, - QueryReplace, // Interactive replace (y/n/!/q for each match) - - // Menu navigation - MenuActivate, // Open menu bar (Alt or F10) - MenuClose, // Close menu (Esc) - MenuLeft, // Navigate to previous menu - MenuRight, // Navigate to next menu - MenuUp, // Navigate to previous item in menu - MenuDown, // Navigate to next item in menu - MenuExecute, // Execute selected menu item (Enter) - MenuOpen(String), // Open a specific menu by name (e.g., "File", "Edit") - - // Keybinding map switching - SwitchKeybindingMap(String), // Switch to a named keybinding map (e.g., "default", "emacs", "vscode") - - // Plugin custom actions - PluginAction(String), - - // Settings operations - OpenSettings, // Open the settings modal - CloseSettings, // Close the settings modal - SettingsSave, // Save settings changes - SettingsReset, // Reset current setting to default - SettingsToggleFocus, // Toggle focus between category and settings panels - SettingsActivate, // Activate/toggle the current setting - SettingsSearch, // Start search in settings - SettingsHelp, // Show settings help overlay - SettingsIncrement, // Increment number value or next dropdown option - SettingsDecrement, // Decrement number value or previous dropdown option - - // Terminal operations - OpenTerminal, // Open a new terminal in the current split - CloseTerminal, // Close the current terminal - FocusTerminal, // Focus the terminal buffer (if viewing terminal, focus input) - TerminalEscape, // Escape from terminal mode back to editor - ToggleKeyboardCapture, // Toggle keyboard capture mode (all keys go to terminal) - TerminalPaste, // Paste clipboard contents into terminal as a single batch - - // Shell command operations - ShellCommand, // Run shell command on buffer/selection, output to new buffer - ShellCommandReplace, // Run shell command on buffer/selection, replace content - - // Case conversion - ToUpperCase, // Convert selection to uppercase - ToLowerCase, // Convert selection to lowercase - - // Input calibration - CalibrateInput, // Open the input calibration wizard - - // No-op - None, -} - -impl Action { - fn with_char( - args: &HashMap, - make_action: impl FnOnce(char) -> Self, - ) -> Option { - if let Some(serde_json::Value::String(value)) = args.get("char") { - value.chars().next().map(make_action) - } else { - None - } - } - - /// Parse action from string (used when loading from config) - pub fn from_str(s: &str, args: &HashMap) -> Option { - Some(match s { - "insert_char" => return Self::with_char(args, Self::InsertChar), - "insert_newline" => Self::InsertNewline, - "insert_tab" => Self::InsertTab, - - "move_left" => Self::MoveLeft, - "move_right" => Self::MoveRight, - "move_up" => Self::MoveUp, - "move_down" => Self::MoveDown, - "move_word_left" => Self::MoveWordLeft, - "move_word_right" => Self::MoveWordRight, - "move_line_start" => Self::MoveLineStart, - "move_line_end" => Self::MoveLineEnd, - "move_page_up" => Self::MovePageUp, - "move_page_down" => Self::MovePageDown, - "move_document_start" => Self::MoveDocumentStart, - "move_document_end" => Self::MoveDocumentEnd, - - "select_left" => Self::SelectLeft, - "select_right" => Self::SelectRight, - "select_up" => Self::SelectUp, - "select_down" => Self::SelectDown, - "select_word_left" => Self::SelectWordLeft, - "select_word_right" => Self::SelectWordRight, - "select_line_start" => Self::SelectLineStart, - "select_line_end" => Self::SelectLineEnd, - "select_document_start" => Self::SelectDocumentStart, - "select_document_end" => Self::SelectDocumentEnd, - "select_page_up" => Self::SelectPageUp, - "select_page_down" => Self::SelectPageDown, - "select_all" => Self::SelectAll, - "select_word" => Self::SelectWord, - "select_line" => Self::SelectLine, - "expand_selection" => Self::ExpandSelection, - - // Block/rectangular selection - "block_select_left" => Self::BlockSelectLeft, - "block_select_right" => Self::BlockSelectRight, - "block_select_up" => Self::BlockSelectUp, - "block_select_down" => Self::BlockSelectDown, - - "delete_backward" => Self::DeleteBackward, - "delete_forward" => Self::DeleteForward, - "delete_word_backward" => Self::DeleteWordBackward, - "delete_word_forward" => Self::DeleteWordForward, - "delete_line" => Self::DeleteLine, - "delete_to_line_end" => Self::DeleteToLineEnd, - "delete_to_line_start" => Self::DeleteToLineStart, - "transpose_chars" => Self::TransposeChars, - "open_line" => Self::OpenLine, - "recenter" => Self::Recenter, - "set_mark" => Self::SetMark, - - "copy" => Self::Copy, - "copy_with_theme" => { - // Empty theme = open theme picker prompt - let theme = args.get("theme").and_then(|v| v.as_str()).unwrap_or(""); - Self::CopyWithTheme(theme.to_string()) - } - "cut" => Self::Cut, - "paste" => Self::Paste, - - // Vi-style yank actions - "yank_word_forward" => Self::YankWordForward, - "yank_word_backward" => Self::YankWordBackward, - "yank_to_line_end" => Self::YankToLineEnd, - "yank_to_line_start" => Self::YankToLineStart, - - "add_cursor_above" => Self::AddCursorAbove, - "add_cursor_below" => Self::AddCursorBelow, - "add_cursor_next_match" => Self::AddCursorNextMatch, - "remove_secondary_cursors" => Self::RemoveSecondaryCursors, - - "save" => Self::Save, - "save_as" => Self::SaveAs, - "open" => Self::Open, - "switch_project" => Self::SwitchProject, - "new" => Self::New, - "close" => Self::Close, - "close_tab" => Self::CloseTab, - "quit" => Self::Quit, - "revert" => Self::Revert, - "toggle_auto_revert" => Self::ToggleAutoRevert, - "format_buffer" => Self::FormatBuffer, - "goto_line" => Self::GotoLine, - "goto_matching_bracket" => Self::GoToMatchingBracket, - "jump_to_next_error" => Self::JumpToNextError, - "jump_to_previous_error" => Self::JumpToPreviousError, - - "smart_home" => Self::SmartHome, - "dedent_selection" => Self::DedentSelection, - "toggle_comment" => Self::ToggleComment, - - "set_bookmark" => return Self::with_char(args, Self::SetBookmark), - "jump_to_bookmark" => return Self::with_char(args, Self::JumpToBookmark), - "clear_bookmark" => return Self::with_char(args, Self::ClearBookmark), - - "list_bookmarks" => Self::ListBookmarks, - - "toggle_search_case_sensitive" => Self::ToggleSearchCaseSensitive, - "toggle_search_whole_word" => Self::ToggleSearchWholeWord, - "toggle_search_regex" => Self::ToggleSearchRegex, - "toggle_search_confirm_each" => Self::ToggleSearchConfirmEach, - - "start_macro_recording" => Self::StartMacroRecording, - "stop_macro_recording" => Self::StopMacroRecording, - "play_macro" => return Self::with_char(args, Self::PlayMacro), - "toggle_macro_recording" => return Self::with_char(args, Self::ToggleMacroRecording), - - "show_macro" => return Self::with_char(args, Self::ShowMacro), - - "list_macros" => Self::ListMacros, - "prompt_record_macro" => Self::PromptRecordMacro, - "prompt_play_macro" => Self::PromptPlayMacro, - "play_last_macro" => Self::PlayLastMacro, - "prompt_set_bookmark" => Self::PromptSetBookmark, - "prompt_jump_to_bookmark" => Self::PromptJumpToBookmark, - - "undo" => Self::Undo, - "redo" => Self::Redo, - - "scroll_up" => Self::ScrollUp, - "scroll_down" => Self::ScrollDown, - "show_help" => Self::ShowHelp, - "keyboard_shortcuts" => Self::ShowKeyboardShortcuts, - "show_warnings" => Self::ShowWarnings, - "show_lsp_status" => Self::ShowLspStatus, - "clear_warnings" => Self::ClearWarnings, - "command_palette" => Self::CommandPalette, - "toggle_line_wrap" => Self::ToggleLineWrap, - "toggle_compose_mode" => Self::ToggleComposeMode, - "set_compose_width" => Self::SetComposeWidth, - - "next_buffer" => Self::NextBuffer, - "prev_buffer" => Self::PrevBuffer, - - "navigate_back" => Self::NavigateBack, - "navigate_forward" => Self::NavigateForward, - - "split_horizontal" => Self::SplitHorizontal, - "split_vertical" => Self::SplitVertical, - "close_split" => Self::CloseSplit, - "next_split" => Self::NextSplit, - "prev_split" => Self::PrevSplit, - "increase_split_size" => Self::IncreaseSplitSize, - "decrease_split_size" => Self::DecreaseSplitSize, - "toggle_maximize_split" => Self::ToggleMaximizeSplit, - - "prompt_confirm" => Self::PromptConfirm, - "prompt_cancel" => Self::PromptCancel, - "prompt_backspace" => Self::PromptBackspace, - "prompt_move_left" => Self::PromptMoveLeft, - "prompt_move_right" => Self::PromptMoveRight, - "prompt_move_start" => Self::PromptMoveStart, - "prompt_move_end" => Self::PromptMoveEnd, - "prompt_select_prev" => Self::PromptSelectPrev, - "prompt_select_next" => Self::PromptSelectNext, - "prompt_page_up" => Self::PromptPageUp, - "prompt_page_down" => Self::PromptPageDown, - "prompt_accept_suggestion" => Self::PromptAcceptSuggestion, - "prompt_delete_word_forward" => Self::PromptDeleteWordForward, - "prompt_delete_word_backward" => Self::PromptDeleteWordBackward, - "prompt_delete_to_line_end" => Self::PromptDeleteToLineEnd, - "prompt_copy" => Self::PromptCopy, - "prompt_cut" => Self::PromptCut, - "prompt_paste" => Self::PromptPaste, - "prompt_move_left_selecting" => Self::PromptMoveLeftSelecting, - "prompt_move_right_selecting" => Self::PromptMoveRightSelecting, - "prompt_move_home_selecting" => Self::PromptMoveHomeSelecting, - "prompt_move_end_selecting" => Self::PromptMoveEndSelecting, - "prompt_select_word_left" => Self::PromptSelectWordLeft, - "prompt_select_word_right" => Self::PromptSelectWordRight, - "prompt_select_all" => Self::PromptSelectAll, - "file_browser_toggle_hidden" => Self::FileBrowserToggleHidden, - "prompt_move_word_left" => Self::PromptMoveWordLeft, - "prompt_move_word_right" => Self::PromptMoveWordRight, - "prompt_delete" => Self::PromptDelete, - - "popup_select_next" => Self::PopupSelectNext, - "popup_select_prev" => Self::PopupSelectPrev, - "popup_page_up" => Self::PopupPageUp, - "popup_page_down" => Self::PopupPageDown, - "popup_confirm" => Self::PopupConfirm, - "popup_cancel" => Self::PopupCancel, - - "toggle_file_explorer" => Self::ToggleFileExplorer, - "toggle_menu_bar" => Self::ToggleMenuBar, - "focus_file_explorer" => Self::FocusFileExplorer, - "focus_editor" => Self::FocusEditor, - "file_explorer_up" => Self::FileExplorerUp, - "file_explorer_down" => Self::FileExplorerDown, - "file_explorer_page_up" => Self::FileExplorerPageUp, - "file_explorer_page_down" => Self::FileExplorerPageDown, - "file_explorer_expand" => Self::FileExplorerExpand, - "file_explorer_collapse" => Self::FileExplorerCollapse, - "file_explorer_open" => Self::FileExplorerOpen, - "file_explorer_refresh" => Self::FileExplorerRefresh, - "file_explorer_new_file" => Self::FileExplorerNewFile, - "file_explorer_new_directory" => Self::FileExplorerNewDirectory, - "file_explorer_delete" => Self::FileExplorerDelete, - "file_explorer_rename" => Self::FileExplorerRename, - "file_explorer_toggle_hidden" => Self::FileExplorerToggleHidden, - "file_explorer_toggle_gitignored" => Self::FileExplorerToggleGitignored, - - "lsp_completion" => Self::LspCompletion, - "lsp_goto_definition" => Self::LspGotoDefinition, - "lsp_references" => Self::LspReferences, - "lsp_rename" => Self::LspRename, - "lsp_hover" => Self::LspHover, - "lsp_signature_help" => Self::LspSignatureHelp, - "lsp_code_actions" => Self::LspCodeActions, - "lsp_restart" => Self::LspRestart, - "lsp_stop" => Self::LspStop, - "toggle_inlay_hints" => Self::ToggleInlayHints, - "toggle_mouse_hover" => Self::ToggleMouseHover, - - "toggle_line_numbers" => Self::ToggleLineNumbers, - "toggle_mouse_capture" => Self::ToggleMouseCapture, - "toggle_debug_highlights" => Self::ToggleDebugHighlights, - "set_background" => Self::SetBackground, - "set_background_blend" => Self::SetBackgroundBlend, - "select_theme" => Self::SelectTheme, - "select_keybinding_map" => Self::SelectKeybindingMap, - "select_locale" => Self::SelectLocale, - - // Buffer settings - "set_tab_size" => Self::SetTabSize, - "set_line_ending" => Self::SetLineEnding, - "toggle_indentation_style" => Self::ToggleIndentationStyle, - "toggle_tab_indicators" => Self::ToggleTabIndicators, - "reset_buffer_settings" => Self::ResetBufferSettings, - - "dump_config" => Self::DumpConfig, - - "search" => Self::Search, - "find_in_selection" => Self::FindInSelection, - "find_next" => Self::FindNext, - "find_previous" => Self::FindPrevious, - "find_selection_next" => Self::FindSelectionNext, - "find_selection_previous" => Self::FindSelectionPrevious, - "replace" => Self::Replace, - "query_replace" => Self::QueryReplace, - - "menu_activate" => Self::MenuActivate, - "menu_close" => Self::MenuClose, - "menu_left" => Self::MenuLeft, - "menu_right" => Self::MenuRight, - "menu_up" => Self::MenuUp, - "menu_down" => Self::MenuDown, - "menu_execute" => Self::MenuExecute, - "menu_open" => { - let name = args.get("name")?.as_str()?; - Self::MenuOpen(name.to_string()) - } - - "switch_keybinding_map" => { - let map_name = args.get("map")?.as_str()?; - Self::SwitchKeybindingMap(map_name.to_string()) - } - - // Terminal actions - "open_terminal" => Self::OpenTerminal, - "close_terminal" => Self::CloseTerminal, - "focus_terminal" => Self::FocusTerminal, - "terminal_escape" => Self::TerminalEscape, - "toggle_keyboard_capture" => Self::ToggleKeyboardCapture, - "terminal_paste" => Self::TerminalPaste, - - // Shell command actions - "shell_command" => Self::ShellCommand, - "shell_command_replace" => Self::ShellCommandReplace, - - // Case conversion - "to_upper_case" => Self::ToUpperCase, - "to_lower_case" => Self::ToLowerCase, - - // Input calibration - "calibrate_input" => Self::CalibrateInput, - - // Settings actions - "open_settings" => Self::OpenSettings, - "close_settings" => Self::CloseSettings, - "settings_save" => Self::SettingsSave, - "settings_reset" => Self::SettingsReset, - "settings_toggle_focus" => Self::SettingsToggleFocus, - "settings_activate" => Self::SettingsActivate, - "settings_search" => Self::SettingsSearch, - "settings_help" => Self::SettingsHelp, - "settings_increment" => Self::SettingsIncrement, - "settings_decrement" => Self::SettingsDecrement, - - _ => return None, - }) - } -} - /// Result of chord resolution #[derive(Debug, Clone, PartialEq)] pub enum ChordResolution { diff --git a/src/input/mod.rs b/src/input/mod.rs index 9a578eae2..54a583712 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,16 +1,32 @@ //! Input pipeline //! //! This module handles the input-to-action-to-event translation. +//! +//! Pure modules are WASM-compatible. Runtime-only modules depend on crossterm. -pub mod actions; -pub mod buffer_mode; +// Pure modules (WASM-compatible) +pub mod action; pub mod command_registry; pub mod commands; -pub mod composite_router; pub mod fuzzy; -pub mod handler; pub mod input_history; +pub mod position_history; + +// Re-export pure types at module level +pub use action::{Action, KeyContext}; + +// Runtime-only modules (depend on crossterm or state) +#[cfg(feature = "runtime")] +pub mod actions; +#[cfg(feature = "runtime")] +pub mod buffer_mode; +#[cfg(feature = "runtime")] +pub mod composite_router; +#[cfg(feature = "runtime")] +pub mod handler; +#[cfg(feature = "runtime")] pub mod key_translator; +#[cfg(feature = "runtime")] pub mod keybindings; +#[cfg(feature = "runtime")] pub mod multi_cursor; -pub mod position_history; diff --git a/src/lib.rs b/src/lib.rs index c1638334a..98adb1a67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,19 +18,16 @@ pub mod types; pub mod config_io; #[cfg(feature = "runtime")] pub mod session; -#[cfg(feature = "runtime")] -pub mod state; -// Organized modules (runtime-only) -#[cfg(feature = "runtime")] +// Modules with internal gating (pure types ungated, runtime code gated internally) pub mod app; -#[cfg(feature = "runtime")] pub mod input; -#[cfg(feature = "runtime")] pub mod model; -#[cfg(feature = "runtime")] pub mod primitives; -#[cfg(feature = "runtime")] pub mod services; -#[cfg(feature = "runtime")] +pub mod state; pub mod view; + +// WASM browser build modules +#[cfg(feature = "wasm")] +pub mod wasm; diff --git a/src/model/buffer.rs b/src/model/buffer.rs index 24b864343..cdef6cb1f 100644 --- a/src/model/buffer.rs +++ b/src/model/buffer.rs @@ -1,14 +1,16 @@ /// Text buffer that uses PieceTree with integrated line tracking /// Architecture where the tree is the single source of truth for text and line information +use crate::model::filesystem::{FileSystem, NoopFileSystem, StdFileSystem}; use crate::model::piece_tree::{ BufferData, BufferLocation, Cursor, PieceInfo, PieceRangeIter, PieceTree, Position, StringBuffer, TreeStats, }; use crate::model::piece_tree_diff::PieceTreeDiff; use crate::primitives::grapheme; +use crate::view::text_content::{LineEnding as TextLineEnding, TextContentProvider, TextLine}; use anyhow::{Context, Result}; use regex::bytes::Regex; -use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::io::{self, Write}; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -104,6 +106,10 @@ impl LineNumber { /// A text buffer that manages document content using a piece table /// with integrated line tracking pub struct TextBuffer { + /// Filesystem abstraction for file I/O operations + /// Stored internally so methods can access it without threading through call chains + fs: Arc, + /// The piece tree for efficient text manipulation with integrated line tracking piece_tree: PieceTree, @@ -157,6 +163,7 @@ impl TextBuffer { let piece_tree = PieceTree::empty(); let line_ending = LineEnding::default(); TextBuffer { + fs: Arc::new(NoopFileSystem), saved_root: piece_tree.root(), piece_tree, buffers: vec![StringBuffer::new(0, Vec::new())], @@ -192,6 +199,7 @@ impl TextBuffer { let saved_root = piece_tree.root(); TextBuffer { + fs: Arc::new(NoopFileSystem), line_ending, original_line_ending: line_ending, piece_tree, @@ -218,6 +226,7 @@ impl TextBuffer { let saved_root = piece_tree.root(); let line_ending = LineEnding::default(); TextBuffer { + fs: Arc::new(NoopFileSystem), piece_tree, saved_root, buffers: vec![StringBuffer::new(0, Vec::new())], @@ -234,15 +243,19 @@ impl TextBuffer { } /// Load a text buffer from a file + /// + /// Uses `StdFileSystem` for native filesystem access. For WASM builds where + /// content comes from JavaScript, use `from_bytes()` instead. pub fn load_from_file>( path: P, large_file_threshold: usize, ) -> io::Result { + let fs: Arc = Arc::new(StdFileSystem); let path = path.as_ref(); // Get file size to determine loading strategy - let metadata = std::fs::metadata(path)?; - let file_size = metadata.len() as usize; + let metadata = fs.metadata(path)?; + let file_size = metadata.size as usize; // Use threshold parameter or default let threshold = if large_file_threshold > 0 { @@ -253,18 +266,18 @@ impl TextBuffer { // Choose loading strategy based on file size if file_size >= threshold { - Self::load_large_file(path, file_size) + Self::load_large_file(&fs, path, file_size) } else { - Self::load_small_file(path) + Self::load_small_file(&fs, path) } } /// Load a small file with full eager loading and line indexing - fn load_small_file>(path: P) -> io::Result { - let path = path.as_ref(); - let mut file = std::fs::File::open(path)?; - let mut contents = Vec::new(); - file.read_to_end(&mut contents)?; + fn load_small_file( + fs: &Arc, + path: &Path, + ) -> io::Result { + let contents = fs.read_file(path)?; // Detect if this is a binary file let is_binary = Self::detect_binary(&contents); @@ -274,6 +287,7 @@ impl TextBuffer { // Keep original line endings - the view layer handles CRLF display let mut buffer = Self::from_bytes(contents); + buffer.fs = Arc::clone(fs); buffer.file_path = Some(path.to_path_buf()); buffer.modified = false; buffer.large_file = false; @@ -284,22 +298,19 @@ impl TextBuffer { } /// Load a large file with unloaded buffer (no line indexing, lazy loading) - fn load_large_file>(path: P, file_size: usize) -> io::Result { + fn load_large_file( + fs: &Arc, + path: &Path, + file_size: usize, + ) -> io::Result { use crate::model::piece_tree::{BufferData, BufferLocation}; - let path = path.as_ref(); - // Read a sample of the file to detect if it's binary and line ending format // We read the first 8KB for both binary and line ending detection - let (is_binary, line_ending) = { - let mut file = std::fs::File::open(path)?; - let sample_size = file_size.min(8 * 1024); - let mut sample = vec![0u8; sample_size]; - file.read_exact(&mut sample)?; - let is_binary = Self::detect_binary(&sample); - let line_ending = Self::detect_line_ending(&sample); - (is_binary, line_ending) - }; + let sample_size = file_size.min(8 * 1024); + let sample = fs.read_range(path, 0, sample_size)?; + let is_binary = Self::detect_binary(&sample); + let line_ending = Self::detect_line_ending(&sample); // Create an unloaded buffer that references the entire file let buffer = StringBuffer { @@ -327,6 +338,7 @@ impl TextBuffer { ); Ok(TextBuffer { + fs: Arc::clone(fs), piece_tree, saved_root, buffers: vec![buffer], @@ -344,8 +356,8 @@ impl TextBuffer { /// Save the buffer to its associated file pub fn save(&mut self) -> io::Result<()> { - if let Some(path) = &self.file_path { - self.save_to_file(path.clone()) + if let Some(path) = &self.file_path.clone() { + self.save_to_file(path) } else { Err(io::Error::new( io::ErrorKind::NotFound, @@ -363,12 +375,16 @@ impl TextBuffer { /// If the line ending format has been changed (via set_line_ending), all content /// will be converted to the new format during save. pub fn save_to_file>(&mut self, path: P) -> io::Result<()> { + use crate::model::filesystem::FileReader; + use std::io::SeekFrom; + + let fs = &self.fs; let dest_path = path.as_ref(); let total = self.total_bytes(); // Get original file metadata (permissions, owner, etc.) before writing // so we can preserve it after creating/renaming the temp file - let original_metadata = std::fs::metadata(dest_path).ok(); + let original_metadata = fs.metadata_if_exists(dest_path); // Check if we need to convert line endings let needs_conversion = self.line_ending != self.original_line_ending; @@ -376,9 +392,11 @@ impl TextBuffer { if total == 0 { // Empty file - just create it - std::fs::File::create(dest_path)?; + let _ = fs.create_file(dest_path)?; if let Some(ref meta) = original_metadata { - Self::restore_file_metadata(dest_path, meta)?; + if let Some(ref perms) = meta.permissions { + let _ = fs.set_permissions(dest_path, perms); + } } self.file_path = Some(dest_path.to_path_buf()); self.mark_saved_snapshot(); @@ -390,10 +408,10 @@ impl TextBuffer { // Use a temp file to avoid corrupting the original if something goes wrong let temp_path = dest_path.with_extension("tmp"); - let mut out_file = std::fs::File::create(&temp_path)?; + let mut out_file = fs.create_file(&temp_path)?; // Cache for open source files (for streaming unloaded regions) - let mut source_file_cache: Option<(PathBuf, std::fs::File)> = None; + let mut source_file_cache: Option<(PathBuf, Box)> = None; // Iterate through all pieces and write them for piece_view in self.piece_tree.iter_pieces_in_range(0, total) { @@ -426,12 +444,12 @@ impl TextBuffer { .. } => { // Stream from source file - let source_file = match &mut source_file_cache { - Some((cached_path, file)) if cached_path == file_path => file, + let source_file: &mut dyn FileReader = match &mut source_file_cache { + Some((cached_path, file)) if cached_path == file_path => file.as_mut(), _ => { - let file = std::fs::File::open(file_path)?; + let file = fs.open_file(file_path)?; source_file_cache = Some((file_path.clone(), file)); - &mut source_file_cache.as_mut().unwrap().1 + source_file_cache.as_mut().unwrap().1.as_mut() } }; @@ -467,16 +485,18 @@ impl TextBuffer { out_file.sync_all()?; drop(out_file); - // Restore original file permissions/owner before renaming + // Restore original file permissions before renaming if let Some(ref meta) = original_metadata { - Self::restore_file_metadata(&temp_path, meta)?; + if let Some(ref perms) = meta.permissions { + let _ = fs.set_permissions(&temp_path, perms); + } } // Atomically replace the original file - std::fs::rename(&temp_path, dest_path)?; + fs.rename(&temp_path, dest_path)?; // Update saved file size to match the file on disk - let new_size = std::fs::metadata(dest_path)?.len() as usize; + let new_size = fs.metadata(dest_path)?.size as usize; tracing::debug!( "Buffer::save: updating saved_file_size from {:?} to {}", self.saved_file_size, @@ -494,30 +514,6 @@ impl TextBuffer { Ok(()) } - /// Restore file metadata (permissions, owner/group) from original file - fn restore_file_metadata(path: &Path, original_meta: &std::fs::Metadata) -> io::Result<()> { - // Restore permissions (works cross-platform) - std::fs::set_permissions(path, original_meta.permissions())?; - - // On Unix, also restore owner and group - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let uid = original_meta.uid(); - let gid = original_meta.gid(); - // Use libc to set owner/group - ignore errors since we may not have permission - // (e.g., only root can chown to a different user) - unsafe { - use std::os::unix::ffi::OsStrExt; - let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; - libc::chown(c_path.as_ptr(), uid, gid); - } - } - - Ok(()) - } - /// Get the total number of bytes in the document pub fn total_bytes(&self) -> usize { self.piece_tree.total_bytes() @@ -1155,7 +1151,7 @@ impl TextBuffer { self.buffers .get_mut(new_buffer_id) .context("Chunk buffer not found")? - .load() + .load(self.fs.as_ref()) .context("Failed to load chunk")?; // Restart iteration with the modified tree @@ -1166,7 +1162,7 @@ impl TextBuffer { self.buffers .get_mut(buffer_id) .context("Buffer not found")? - .load() + .load(self.fs.as_ref()) .context("Failed to load buffer")?; } } @@ -2309,6 +2305,9 @@ impl TextBuffer { /// /// This iterator lazily loads chunks as needed, never scanning the entire file. /// For large files with unloaded buffers, chunks are loaded on-demand (1MB at a time). + /// + /// Note: In WASM with NoopFileSystem, lazy loading will fail. Use `from_bytes()` + /// to create fully-loaded buffers instead. pub fn line_iterator( &mut self, byte_pos: usize, @@ -2438,6 +2437,112 @@ impl TextBuffer { } } +// ============================================================================ +// TextContentProvider Implementation +// ============================================================================ + +/// Implementation of TextContentProvider for shared rendering with WASM +impl TextContentProvider for TextBuffer { + fn len(&self) -> usize { + self.total_bytes() + } + + fn is_binary(&self) -> bool { + self.is_binary + } + + fn line_ending(&self) -> TextLineEnding { + match self.line_ending { + LineEnding::LF => TextLineEnding::Lf, + LineEnding::CRLF => TextLineEnding::CrLf, + LineEnding::CR => TextLineEnding::Cr, + } + } + + fn line_count(&self) -> usize { + // Use piece_tree's line count if available, otherwise estimate from content + self.piece_tree.line_count().unwrap_or_else(|| { + // For large files without line indexing, count lines on demand + // This is expensive but correct + self.get_all_text() + .map(|text| text.iter().filter(|&&b| b == b'\n').count() + 1) + .unwrap_or(1) + }) + } + + fn get_line(&self, line_num: usize) -> Option { + let start = self.line_start_offset(line_num)?; + let next_start = self.line_start_offset(line_num + 1); + + // Calculate line content (without line ending) + let line_bytes = if let Some(next) = next_start { + // Get bytes between this line start and next line start + let raw = self.get_text_range(start, next - start)?; + + // Trim trailing line ending + let mut end = raw.len(); + if end > 0 && raw[end - 1] == b'\n' { + end -= 1; + if end > 0 && raw[end - 1] == b'\r' { + end -= 1; + } + } else if end > 0 && raw[end - 1] == b'\r' { + end -= 1; + } + raw[..end].to_vec() + } else { + // Last line - get remaining content + let total = self.total_bytes(); + if start >= total { + Vec::new() + } else { + self.get_text_range(start, total - start).unwrap_or_default() + } + }; + + let content = String::from_utf8_lossy(&line_bytes).into_owned(); + let line_ending_len = if let Some(next) = next_start { + next - start - line_bytes.len() + } else { + 0 + }; + + Some(TextLine { + start_byte: start, + content, + line_ending_len, + }) + } + + fn slice_bytes(&self, range: Range) -> Vec { + self.get_text_range(range.start, range.end.saturating_sub(range.start)) + .unwrap_or_default() + } + + fn byte_to_line(&self, byte_offset: usize) -> usize { + self.offset_to_position(byte_offset) + .map(|pos| pos.line) + .unwrap_or(0) + } + + fn line_to_byte(&self, line: usize) -> Option { + self.line_start_offset(line) + } + + fn get_text_range(&self, start: usize, end: usize) -> String { + let bytes = self + .get_text_range(start, end.saturating_sub(start)) + .unwrap_or_default(); + String::from_utf8_lossy(&bytes).into_owned() + } + + fn content(&self) -> String { + self.get_all_text() + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .unwrap_or_default() + } +} + /// Type alias for backwards compatibility pub type Buffer = TextBuffer; @@ -2993,7 +3098,8 @@ mod tests { assert!(!buffer.is_loaded()); // Load the buffer - buffer.load().unwrap(); + let fs = crate::model::filesystem::StdFileSystem; + buffer.load(&fs).unwrap(); // Now it should be loaded assert!(buffer.is_loaded()); diff --git a/src/model/cursor.rs b/src/model/cursor.rs index 0e97ea6a2..e665e1a1e 100644 --- a/src/model/cursor.rs +++ b/src/model/cursor.rs @@ -1,7 +1,17 @@ -use crate::model::event::CursorId; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::ops::Range; +/// Unique identifier for a cursor +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CursorId(pub usize); + +impl CursorId { + /// Sentinel value used for inverse events during undo/redo + /// This indicates that the event shouldn't move any cursor + pub const UNDO_SENTINEL: CursorId = CursorId(usize::MAX); +} + /// Selection mode for cursors #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SelectionMode { diff --git a/src/model/event.rs b/src/model/event.rs index ad39ce58f..58e95a4e2 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -1,19 +1,11 @@ +// Re-export CursorId from cursor module for backwards compatibility +pub use crate::model::cursor::CursorId; use crate::model::piece_tree::PieceTree; use crate::view::overlay::{OverlayHandle, OverlayNamespace}; use serde::{Deserialize, Serialize}; use std::ops::Range; use std::sync::Arc; -/// Unique identifier for a cursor -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CursorId(pub usize); - -impl CursorId { - /// Sentinel value used for inverse events during undo/redo - /// This indicates that the event shouldn't move any cursor - pub const UNDO_SENTINEL: CursorId = CursorId(usize::MAX); -} - /// Unique identifier for a split pane (re-exported from split.rs) /// Note: This is defined in split.rs and re-exported here for events #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -570,7 +562,8 @@ pub struct EventLog { /// How often to create snapshots (every N events) snapshot_interval: usize, - /// Optional file for streaming events to disk + /// Optional file for streaming events to disk (runtime-only, for debugging) + #[cfg(feature = "runtime")] stream_file: Option, /// Index at which the buffer was last saved (for tracking modified status) @@ -586,6 +579,7 @@ impl EventLog { current_index: 0, snapshots: Vec::new(), snapshot_interval: 100, + #[cfg(feature = "runtime")] stream_file: None, saved_at_index: Some(0), // New buffer starts at "saved" state (index 0) } @@ -621,7 +615,8 @@ impl EventLog { } } - /// Enable streaming events to a file + /// Enable streaming events to a file (runtime-only, for debugging) + #[cfg(feature = "runtime")] pub fn enable_streaming>(&mut self, path: P) -> std::io::Result<()> { use std::io::Write; @@ -641,12 +636,14 @@ impl EventLog { Ok(()) } - /// Disable streaming + /// Disable streaming (runtime-only) + #[cfg(feature = "runtime")] pub fn disable_streaming(&mut self) { self.stream_file = None; } - /// Log rendering state (for debugging) + /// Log rendering state (for debugging, runtime-only) + #[cfg(feature = "runtime")] pub fn log_render_state( &mut self, cursor_pos: usize, @@ -674,7 +671,8 @@ impl EventLog { } } - /// Log keystroke (for debugging) + /// Log keystroke (for debugging, runtime-only) + #[cfg(feature = "runtime")] pub fn log_keystroke(&mut self, key_code: &str, modifiers: &str) { if let Some(ref mut file) = self.stream_file { use std::io::Write; @@ -702,14 +700,15 @@ impl EventLog { self.entries.truncate(self.current_index); } - // Stream event to file if enabled + // Stream event to file if enabled (runtime-only) + #[cfg(feature = "runtime")] if let Some(ref mut file) = self.stream_file { use std::io::Write; let stream_entry = serde_json::json!({ "index": self.entries.len(), "timestamp": chrono::Local::now().to_rfc3339(), - "event": event, + "event": &event, }); // Write JSON line and flush immediately for real-time logging @@ -837,7 +836,8 @@ impl EventLog { self.snapshots.clear(); } - /// Save event log to JSON Lines format + /// Save event log to JSON Lines format (runtime-only) + #[cfg(feature = "runtime")] pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> { use std::io::Write; let file = std::fs::File::create(path)?; @@ -851,7 +851,8 @@ impl EventLog { Ok(()) } - /// Load event log from JSON Lines format + /// Load event log from JSON Lines format (runtime-only) + #[cfg(feature = "runtime")] pub fn load_from_file(path: &std::path::Path) -> std::io::Result { use std::io::BufRead; let file = std::fs::File::open(path)?; diff --git a/src/model/filesystem.rs b/src/model/filesystem.rs new file mode 100644 index 000000000..b43f6dd89 --- /dev/null +++ b/src/model/filesystem.rs @@ -0,0 +1,391 @@ +//! Filesystem abstraction for platform-independent file operations +//! +//! This module provides a trait for filesystem operations, allowing the buffer +//! layer to work with different backends (native filesystem, browser storage, etc.) +//! +//! # Relationship to `services::fs::FsBackend` +//! +//! This crate has two filesystem abstractions: +//! +//! - **`model::filesystem::FileSystem`** (this module): Sync trait for file content I/O. +//! Used by `Buffer` for loading/saving file contents. Lives in `model` to avoid +//! circular dependencies and to support WASM builds where `services` is unavailable. +//! +//! - **`services::fs::FsBackend`**: Async trait for directory traversal and metadata. +//! Used by the file tree UI for listing directories. Lives in `services` (runtime-only). +//! +//! These are kept separate because: +//! 1. Different concerns: content I/O vs directory navigation +//! 2. Different styles: sync (buffer ops) vs async (UI with slow/network FS) +//! 3. Different availability: `model` works in WASM, `services` is runtime-only + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// Metadata about a file +#[derive(Debug, Clone)] +pub struct FileMetadata { + /// File size in bytes + pub size: u64, + /// File permissions (opaque, platform-specific) + pub permissions: Option, +} + +/// Opaque file permissions wrapper +#[derive(Debug, Clone)] +pub struct FilePermissions { + #[cfg(unix)] + mode: u32, + #[cfg(not(unix))] + readonly: bool, +} + +impl FilePermissions { + /// Create from std::fs::Permissions + #[cfg(unix)] + pub fn from_std(perms: std::fs::Permissions) -> Self { + use std::os::unix::fs::PermissionsExt; + Self { mode: perms.mode() } + } + + #[cfg(not(unix))] + pub fn from_std(perms: std::fs::Permissions) -> Self { + Self { + readonly: perms.readonly(), + } + } + + /// Convert to std::fs::Permissions + #[cfg(unix)] + pub fn to_std(&self) -> std::fs::Permissions { + use std::os::unix::fs::PermissionsExt; + std::fs::Permissions::from_mode(self.mode) + } + + #[cfg(not(unix))] + pub fn to_std(&self) -> std::fs::Permissions { + let mut perms = std::fs::Permissions::from(std::fs::metadata(".").unwrap().permissions()); + perms.set_readonly(self.readonly); + perms + } +} + +/// A writable file handle +pub trait FileWriter: Write { + /// Sync all data to disk + fn sync_all(&self) -> io::Result<()>; +} + +/// Wrapper around std::fs::File that implements FileWriter +struct StdFileWriter(std::fs::File); + +impl Write for StdFileWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} + +impl FileWriter for StdFileWriter { + fn sync_all(&self) -> io::Result<()> { + self.0.sync_all() + } +} + +/// A readable and seekable file handle +pub trait FileReader: io::Read + io::Seek {} + +/// Wrapper around std::fs::File that implements FileReader +struct StdFileReader(std::fs::File); + +impl io::Read for StdFileReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.read(buf) + } +} + +impl io::Seek for StdFileReader { + fn seek(&mut self, pos: io::SeekFrom) -> io::Result { + self.0.seek(pos) + } +} + +impl FileReader for StdFileReader {} + +/// Trait for filesystem operations +/// +/// This abstraction allows the buffer layer to work with different filesystem +/// implementations: +/// - `StdFileSystem`: Uses `std::fs` for native builds +/// - `NoopFileSystem`: Returns errors, for WASM where files come from JavaScript +/// +/// # Example +/// +/// ```ignore +/// // Native build +/// let fs = StdFileSystem; +/// let buffer = TextBuffer::load_from_file(&fs, "file.txt", 0)?; +/// +/// // WASM build - content comes from JavaScript, no filesystem needed +/// let buffer = TextBuffer::from_bytes(content_from_js); +/// ``` +pub trait FileSystem { + /// Read entire file into memory + fn read_file(&self, path: &Path) -> io::Result>; + + /// Read a range of bytes from a file + /// + /// Used for lazy loading of large files. Reads `len` bytes starting at `offset`. + fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result>; + + /// Write data to file atomically + /// + /// Implementations should use a temp file + rename pattern to avoid + /// corrupting the original file if something goes wrong. + fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>; + + /// Get file metadata (size and permissions) + fn metadata(&self, path: &Path) -> io::Result; + + /// Check if metadata exists (file exists), returns None if not + fn metadata_if_exists(&self, path: &Path) -> Option { + self.metadata(path).ok() + } + + /// Create a file for writing, returns a writer handle + fn create_file(&self, path: &Path) -> io::Result>; + + /// Open a file for reading, returns a reader handle + fn open_file(&self, path: &Path) -> io::Result>; + + /// Rename/move a file atomically + fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; + + /// Set file permissions + fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>; +} + +/// Standard filesystem implementation using `std::fs` +/// +/// This is the default implementation for native builds. +#[derive(Debug, Clone, Copy, Default)] +pub struct StdFileSystem; + +impl FileSystem for StdFileSystem { + fn read_file(&self, path: &Path) -> io::Result> { + std::fs::read(path) + } + + fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result> { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = std::fs::File::open(path)?; + file.seek(SeekFrom::Start(offset))?; + + let mut buffer = vec![0u8; len]; + file.read_exact(&mut buffer)?; + + Ok(buffer) + } + + fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> { + // Get original metadata to preserve permissions + let original_metadata = self.metadata_if_exists(path); + + // Use temp file for atomic write + let temp_path = path.with_extension("tmp"); + { + let mut file = self.create_file(&temp_path)?; + file.write_all(data)?; + file.sync_all()?; + } + + // Restore permissions if original file existed + if let Some(ref meta) = original_metadata { + if let Some(ref perms) = meta.permissions { + let _ = self.set_permissions(&temp_path, perms); + } + } + + // Atomic rename + self.rename(&temp_path, path)?; + + Ok(()) + } + + fn metadata(&self, path: &Path) -> io::Result { + let meta = std::fs::metadata(path)?; + Ok(FileMetadata { + size: meta.len(), + permissions: Some(FilePermissions::from_std(meta.permissions())), + }) + } + + fn create_file(&self, path: &Path) -> io::Result> { + let file = std::fs::File::create(path)?; + Ok(Box::new(StdFileWriter(file))) + } + + fn open_file(&self, path: &Path) -> io::Result> { + let file = std::fs::File::open(path)?; + Ok(Box::new(StdFileReader(file))) + } + + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + std::fs::rename(from, to) + } + + fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> { + std::fs::set_permissions(path, permissions.to_std()) + } +} + +/// No-op filesystem that returns errors for all operations +/// +/// Used in WASM builds where there is no filesystem access. +/// Content should be loaded via `TextBuffer::from_bytes()` instead. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopFileSystem; + +impl FileSystem for NoopFileSystem { + fn read_file(&self, _path: &Path) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn metadata(&self, _path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn create_file(&self, _path: &Path) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn open_file(&self, _path: &Path) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } + + fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Filesystem not available (WASM build)", + )) + } +} + +/// Helper to get a temp path for atomic writes +pub fn temp_path_for(path: &Path) -> PathBuf { + path.with_extension("tmp") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn test_std_filesystem_read_write() { + let fs = StdFileSystem; + let mut temp = NamedTempFile::new().unwrap(); + let path = temp.path().to_path_buf(); + + // Write some content + std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap(); + std::io::Write::flush(&mut temp).unwrap(); + + // Read it back + let content = fs.read_file(&path).unwrap(); + assert_eq!(content, b"Hello, World!"); + + // Read a range + let range = fs.read_range(&path, 7, 5).unwrap(); + assert_eq!(range, b"World"); + + // Check metadata + let meta = fs.metadata(&path).unwrap(); + assert_eq!(meta.size, 13); + } + + #[test] + fn test_noop_filesystem() { + let fs = NoopFileSystem; + let path = Path::new("/some/path"); + + assert!(fs.read_file(path).is_err()); + assert!(fs.read_range(path, 0, 10).is_err()); + assert!(fs.write_file(path, b"data").is_err()); + assert!(fs.metadata(path).is_err()); + assert!(fs.create_file(path).is_err()); + assert!(fs.open_file(path).is_err()); + assert!(fs.rename(path, path).is_err()); + } + + #[test] + fn test_create_and_write_file() { + let fs = StdFileSystem; + let temp_dir = tempfile::tempdir().unwrap(); + let path = temp_dir.path().join("test.txt"); + + // Create and write + { + let mut writer = fs.create_file(&path).unwrap(); + writer.write_all(b"test content").unwrap(); + writer.sync_all().unwrap(); + } + + // Read back + let content = fs.read_file(&path).unwrap(); + assert_eq!(content, b"test content"); + } + + #[test] + fn test_open_and_read_file() { + let fs = StdFileSystem; + let mut temp = NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut temp, b"seekable content").unwrap(); + std::io::Write::flush(&mut temp).unwrap(); + + let mut reader = fs.open_file(temp.path()).unwrap(); + + // Seek and read + reader.seek(io::SeekFrom::Start(9)).unwrap(); + let mut buf = [0u8; 7]; + reader.read_exact(&mut buf).unwrap(); + assert_eq!(&buf, b"content"); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 84d25f0bc..860b1f701 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,6 +1,7 @@ //! Core data model for documents //! //! This module contains pure data structures with minimal external dependencies. +//! All submodules are pure Rust and WASM-compatible. pub mod buffer; pub mod composite_buffer; @@ -9,6 +10,7 @@ pub mod cursor; pub mod document_model; pub mod edit; pub mod event; +pub mod filesystem; pub mod line_diff; pub mod marker; pub mod marker_tree; diff --git a/src/model/piece_tree.rs b/src/model/piece_tree.rs index 7a08f3807..20c63ef66 100644 --- a/src/model/piece_tree.rs +++ b/src/model/piece_tree.rs @@ -1,7 +1,9 @@ -use std::io::{self, Read, Seek, SeekFrom}; +use std::io; use std::path::PathBuf; use std::sync::Arc; +use super::filesystem::FileSystem; + /// A position in the document (line and column) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Position { @@ -100,7 +102,7 @@ impl StringBuffer { /// Load buffer data from file (for unloaded buffers) /// Returns error if buffer is not unloaded or if I/O fails - pub fn load(&mut self) -> io::Result<()> { + pub fn load(&mut self, fs: &dyn FileSystem) -> io::Result<()> { match &self.data { BufferData::Loaded { .. } => Ok(()), // Already loaded BufferData::Unloaded { @@ -108,12 +110,8 @@ impl StringBuffer { file_offset, bytes, } => { - // Load from file - let mut file = std::fs::File::open(file_path)?; - file.seek(SeekFrom::Start(*file_offset as u64))?; - - let mut buffer = vec![0u8; *bytes]; - file.read_exact(&mut buffer)?; + // Load from file using the filesystem abstraction + let buffer = fs.read_range(file_path, *file_offset as u64, *bytes)?; // Replace with loaded data (no line indexing for lazy-loaded chunks) self.data = BufferData::Loaded { diff --git a/src/primitives/grammar_registry.rs b/src/primitives/grammar_registry.rs index 84779d61f..8a60e67e3 100644 --- a/src/primitives/grammar_registry.rs +++ b/src/primitives/grammar_registry.rs @@ -3,16 +3,30 @@ //! This module handles discovery and loading of TextMate grammars from: //! 1. Built-in syntect grammars (100+ languages) //! 2. Embedded grammars for languages not in syntect (TOML, etc.) -//! 3. User-installed grammars in ~/.config/fresh/grammars/ +//! 3. User-provided grammars (passed via constructor or loaded from disk) //! -//! User grammars use VSCode extension format for compatibility. +//! The module is WASM-compatible - file system access is opt-in via +//! `load_from_config_dir()` which is only available in runtime builds. -use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use syntect::parsing::{SyntaxDefinition, SyntaxReference, SyntaxSet, SyntaxSetBuilder}; +/// A user-provided grammar definition +/// +/// This allows grammars to be passed to the registry constructor, +/// enabling WASM builds to receive grammars from JavaScript. +#[derive(Debug, Clone)] +pub struct UserGrammar { + /// The grammar content (sublime-syntax or tmLanguage format) + pub content: String, + /// File extensions this grammar handles (without leading dot) + pub extensions: Vec, + /// The scope name for this grammar + pub scope_name: String, +} + /// Embedded TOML grammar (syntect doesn't include one) const TOML_GRAMMAR: &str = include_str!("../grammars/toml.sublime-syntax"); @@ -27,14 +41,91 @@ pub struct GrammarRegistry { } impl GrammarRegistry { - /// Create a fully-loaded grammar registry for the editor - /// Loads built-in, embedded, and user grammars + /// Create a grammar registry with built-in, embedded, and user-provided grammars. + /// + /// This is the core constructor that works in both native and WASM builds. + /// User grammars are passed directly rather than loaded from the filesystem. + /// + /// # Arguments + /// * `user_grammars` - User-provided grammars (e.g., from JavaScript in WASM) + pub fn new(user_grammars: Vec) -> Self { + let mut user_extensions = HashMap::new(); + + // Start with syntect defaults, convert to builder to add more + let defaults = SyntaxSet::load_defaults_newlines(); + let mut builder = defaults.into_builder(); + + // Add embedded grammars (TOML, etc.) + Self::add_embedded_grammars(&mut builder); + + // Add user-provided grammars + for grammar in user_grammars { + match SyntaxDefinition::load_from_str(&grammar.content, true, Some(&grammar.scope_name)) + { + Ok(syntax) => { + builder.add(syntax); + // Map extensions to scope name + for ext in &grammar.extensions { + user_extensions.insert(ext.clone(), grammar.scope_name.clone()); + } + tracing::debug!("Loaded user grammar: {}", grammar.scope_name); + } + Err(e) => { + tracing::warn!( + "Failed to load user grammar {}: {}", + grammar.scope_name, + e + ); + } + } + } + + let syntax_set = builder.build(); + + // Build filename -> scope mappings for dotfiles and special files + let filename_scopes = Self::build_filename_scopes(); + + tracing::info!( + "Loaded {} syntaxes, {} user extension mappings, {} filename mappings", + syntax_set.syntaxes().len(), + user_extensions.len(), + filename_scopes.len() + ); + + Self { + syntax_set: Arc::new(syntax_set), + user_extensions, + filename_scopes, + } + } + + /// Create a grammar registry with only built-in grammars (no user grammars). + /// + /// This is useful for WASM builds or when user grammars aren't needed. + pub fn builtin_only() -> Self { + Self::new(vec![]) + } + + /// Create a fully-loaded grammar registry for the editor. + /// + /// On native builds, this loads user grammars from the config directory. + /// On WASM builds, this returns builtin grammars only. pub fn for_editor() -> Arc { - Arc::new(Self::load()) + #[cfg(feature = "runtime")] + { + Arc::new(Self::load_from_config_dir()) + } + #[cfg(not(feature = "runtime"))] + { + Arc::new(Self::builtin_only()) + } } - /// Load grammar registry, scanning user grammars directory - pub fn load() -> Self { + /// Load grammar registry, scanning user grammars from the config directory. + /// + /// This method reads from the filesystem and is only available in native builds. + #[cfg(feature = "runtime")] + pub fn load_from_config_dir() -> Self { let mut user_extensions = HashMap::new(); // Start with syntect defaults, convert to builder to add more @@ -47,14 +138,13 @@ impl GrammarRegistry { // Add user grammars from config directory if let Some(grammars_dir) = Self::grammars_directory() { if grammars_dir.exists() { - Self::load_user_grammars_into(&grammars_dir, &mut builder, &mut user_extensions); + Self::load_user_grammars_from_dir(&grammars_dir, &mut builder, &mut user_extensions); } } let syntax_set = builder.build(); // Build filename -> scope mappings for dotfiles and special files - // These are files that syntect may not recognize by default let filename_scopes = Self::build_filename_scopes(); tracing::info!( @@ -82,7 +172,8 @@ impl GrammarRegistry { }) } - /// Get the grammars directory path + /// Get the grammars directory path (runtime-only) + #[cfg(feature = "runtime")] pub fn grammars_directory() -> Option { dirs::config_dir().map(|p| p.join("fresh/grammars")) } @@ -126,8 +217,9 @@ impl GrammarRegistry { map } - /// Load user grammars into builder - fn load_user_grammars_into( + /// Load user grammars from directory into builder (runtime-only) + #[cfg(feature = "runtime")] + fn load_user_grammars_from_dir( dir: &Path, builder: &mut SyntaxSetBuilder, user_extensions: &mut HashMap, @@ -163,7 +255,8 @@ impl GrammarRegistry { } } - /// Load a grammar directly from a .tmLanguage.json file + /// Load a grammar directly from a .tmLanguage.json file (runtime-only) + #[cfg(feature = "runtime")] fn load_direct_grammar( dir: &Path, builder: &mut SyntaxSetBuilder, @@ -195,7 +288,8 @@ impl GrammarRegistry { } } - /// Parse a VSCode package.json manifest + /// Parse a VSCode package.json manifest (runtime-only) + #[cfg(feature = "runtime")] fn parse_package_json(path: &Path) -> Result { let content = std::fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?; @@ -203,7 +297,8 @@ impl GrammarRegistry { serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e)) } - /// Process a package manifest and load its grammars + /// Process a package manifest and load its grammars (runtime-only) + #[cfg(feature = "runtime")] fn process_manifest( package_dir: &Path, manifest: PackageManifest, @@ -409,55 +504,62 @@ impl GrammarRegistry { impl Default for GrammarRegistry { fn default() -> Self { - Self::load() + Self::builtin_only() } } -// VSCode package.json structures +// VSCode package.json structures (runtime-only, used for loading from config dir) +#[cfg(feature = "runtime")] +mod manifest { + use serde::Deserialize; -#[derive(Debug, Deserialize)] -struct PackageManifest { - #[serde(default)] - contributes: Option, -} + #[derive(Debug, Deserialize)] + pub struct PackageManifest { + #[serde(default)] + pub contributes: Option, + } -#[derive(Debug, Deserialize, Default)] -struct Contributes { - #[serde(default)] - languages: Vec, - #[serde(default)] - grammars: Vec, -} + #[derive(Debug, Deserialize, Default)] + pub struct Contributes { + #[serde(default)] + pub languages: Vec, + #[serde(default)] + pub grammars: Vec, + } -#[derive(Debug, Deserialize)] -struct LanguageContribution { - id: String, - #[serde(default)] - extensions: Vec, -} + #[derive(Debug, Deserialize)] + pub struct LanguageContribution { + pub id: String, + #[serde(default)] + pub extensions: Vec, + } -#[derive(Debug, Deserialize)] -struct GrammarContribution { - language: String, - #[serde(rename = "scopeName")] - scope_name: String, - path: String, + #[derive(Debug, Deserialize)] + pub struct GrammarContribution { + pub language: String, + #[serde(rename = "scopeName")] + pub scope_name: String, + pub path: String, + } } +#[cfg(feature = "runtime")] +use manifest::*; + #[cfg(test)] mod tests { use super::*; #[test] fn test_registry_creation() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); // Should have built-in syntaxes assert!(!registry.available_syntaxes().is_empty()); } #[test] fn test_find_syntax_for_common_extensions() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); // Test common extensions that syntect should support let test_cases = [ @@ -486,7 +588,7 @@ mod tests { #[test] fn test_syntax_set_arc() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); let arc1 = registry.syntax_set_arc(); let arc2 = registry.syntax_set_arc(); // Both should point to the same data @@ -495,7 +597,7 @@ mod tests { #[test] fn test_shell_dotfiles_detection() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); // All these should be detected as shell scripts let shell_files = [".zshrc", ".zprofile", ".zshenv", ".bash_aliases"]; @@ -522,7 +624,7 @@ mod tests { #[test] fn test_pkgbuild_detection() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); // PKGBUILD and APKBUILD should be detected as shell scripts for filename in ["PKGBUILD", "APKBUILD"] { @@ -545,9 +647,11 @@ mod tests { } } + // This test uses crate::config which is runtime-only + #[cfg(feature = "runtime")] #[test] fn test_find_syntax_with_custom_languages_config() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); // Create a custom languages config that maps "custom.myext" files to bash let mut languages = std::collections::HashMap::new(); @@ -603,7 +707,7 @@ mod tests { #[test] fn test_list_all_syntaxes() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::builtin_only(); let syntax_set = registry.syntax_set(); let mut syntaxes: Vec<_> = syntax_set diff --git a/src/primitives/highlight_engine.rs b/src/primitives/highlight_engine.rs index 8f9d5fc2d..3e14f5b0a 100644 --- a/src/primitives/highlight_engine.rs +++ b/src/primitives/highlight_engine.rs @@ -1,30 +1,171 @@ //! Unified highlighting engine //! -//! This module provides a unified abstraction over different highlighting backends: -//! - TextMate grammars via syntect (default for highlighting) -//! - Tree-sitter (available via explicit preference, also used for non-highlighting features) +//! This module provides syntax highlighting using TextMate grammars via syntect. +//! This is the default and WASM-compatible highlighting backend. //! -//! # Backend Selection -//! By default, syntect/TextMate is used for syntax highlighting because it provides -//! broader language coverage. Tree-sitter language detection is still performed -//! to support non-highlighting features like auto-indentation and semantic highlighting. -//! -//! # Non-Highlighting Features -//! Even when using TextMate for highlighting, tree-sitter `Language` is detected -//! and available via `.language()` for: -//! - Auto-indentation (via IndentCalculator) -//! - Semantic highlighting (variable scope tracking) -//! - Other syntax-aware features +//! For tree-sitter based features (semantic highlighting, indentation), see the +//! `highlighter` module which is runtime-only. use crate::model::buffer::Buffer; use crate::primitives::grammar_registry::GrammarRegistry; -use crate::primitives::highlighter::{HighlightCategory, HighlightSpan, Highlighter, Language}; use crate::view::theme::Theme; +use ratatui::style::Color; use std::ops::Range; use std::path::Path; use std::sync::Arc; use syntect::parsing::SyntaxSet; + +// ============================================================================= +// Shared Types (WASM-compatible) +// ============================================================================= + +/// Categories of syntax elements for highlighting +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightCategory { + Attribute, + Comment, + Constant, + Function, + Keyword, + Number, + Operator, + Property, + String, + Type, + Variable, +} + +impl HighlightCategory { + /// Get the color for this category from the theme + pub fn color(&self, theme: &Theme) -> Color { + match self { + Self::Attribute => theme.syntax_constant, // No specific attribute color, use constant + Self::Comment => theme.syntax_comment, + Self::Constant => theme.syntax_constant, + Self::Function => theme.syntax_function, + Self::Keyword => theme.syntax_keyword, + Self::Number => theme.syntax_constant, + Self::Operator => theme.syntax_operator, + Self::Property => theme.syntax_variable, // Properties are like variables + Self::String => theme.syntax_string, + Self::Type => theme.syntax_type, + Self::Variable => theme.syntax_variable, + } + } + + /// Map a default language highlight index to a category (for tree-sitter) + pub(crate) fn from_default_index(index: usize) -> Option { + match index { + 0 => Some(Self::Attribute), + 1 => Some(Self::Comment), + 2 => Some(Self::Constant), + 3 => Some(Self::Function), + 4 => Some(Self::Keyword), + 5 => Some(Self::Number), + 6 => Some(Self::Operator), + 7 => Some(Self::Property), + 8 => Some(Self::String), + 9 => Some(Self::Type), + 10 => Some(Self::Variable), + _ => None, + } + } + + /// Map a TypeScript highlight index to a category (for tree-sitter) + pub(crate) fn from_typescript_index(index: usize) -> Option { + match index { + 0 => Some(Self::Attribute), // attribute + 1 => Some(Self::Comment), // comment + 2 => Some(Self::Constant), // constant + 3 => Some(Self::Constant), // constant.builtin + 4 => Some(Self::Type), // constructor + 5 => Some(Self::String), // embedded (template substitutions) + 6 => Some(Self::Function), // function + 7 => Some(Self::Function), // function.builtin + 8 => Some(Self::Function), // function.method + 9 => Some(Self::Keyword), // keyword + 10 => Some(Self::Number), // number + 11 => Some(Self::Operator), // operator + 12 => Some(Self::Property), // property + 13 => Some(Self::Operator), // punctuation.bracket + 14 => Some(Self::Operator), // punctuation.delimiter + 15 => Some(Self::Constant), // punctuation.special (template ${}) + 16 => Some(Self::String), // string + 17 => Some(Self::String), // string.special (regex) + 18 => Some(Self::Type), // type + 19 => Some(Self::Type), // type.builtin + 20 => Some(Self::Variable), // variable + 21 => Some(Self::Constant), // variable.builtin (this, super, arguments) + 22 => Some(Self::Variable), // variable.parameter + _ => None, + } + } +} + +/// A highlighted span in the buffer +#[derive(Debug, Clone)] +pub struct HighlightSpan { + /// Byte range in the buffer + pub range: Range, + /// Color for this span + pub color: Color, +} + +/// Language enum for syntax detection +/// +/// Used for both TextMate grammar lookup and tree-sitter language selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Language { + Rust, + Python, + JavaScript, + TypeScript, + HTML, + CSS, + C, + Cpp, + Go, + Json, + Java, + CSharp, + Php, + Ruby, + Bash, + Lua, + Pascal, +} + +impl Language { + /// Detect language from file extension + pub fn from_path(path: &std::path::Path) -> Option { + match path.extension()?.to_str()? { + "rs" => Some(Language::Rust), + "py" => Some(Language::Python), + "js" | "jsx" => Some(Language::JavaScript), + "ts" | "tsx" => Some(Language::TypeScript), + "html" => Some(Language::HTML), + "css" => Some(Language::CSS), + "c" | "h" => Some(Language::C), + "cpp" | "hpp" | "cc" | "hh" | "cxx" | "hxx" => Some(Language::Cpp), + "go" => Some(Language::Go), + "json" => Some(Language::Json), + "java" => Some(Language::Java), + "cs" => Some(Language::CSharp), + "php" => Some(Language::Php), + "rb" => Some(Language::Ruby), + "sh" | "bash" => Some(Language::Bash), + "lua" => Some(Language::Lua), + "pas" | "p" => Some(Language::Pascal), + _ => None, + } + } +} + +// ============================================================================= +// Highlighting Engine +// ============================================================================= + /// Map TextMate scope to highlight category fn scope_to_category(scope: &str) -> Option { let scope_lower = scope.to_lowercase(); @@ -171,20 +312,15 @@ fn scope_to_category(scope: &str) -> Option { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum HighlighterPreference { /// Use TextMate/syntect for highlighting (default) - /// Tree-sitter language is still detected for other features (indentation, semantic highlighting) #[default] Auto, - /// Force tree-sitter for highlighting (useful for testing/comparison) - TreeSitter, /// Explicitly use TextMate grammar (same as Auto) TextMate, } -/// Unified highlighting engine supporting multiple backends +/// Highlighting engine using TextMate grammars via syntect pub enum HighlightEngine { - /// Tree-sitter based highlighting (built-in languages) - TreeSitter(Highlighter), - /// TextMate grammar based highlighting + /// TextMate grammar based highlighting (WASM-compatible) TextMate(TextMateEngine), /// No highlighting available None, @@ -514,54 +650,28 @@ impl HighlightEngine { path: &Path, registry: &GrammarRegistry, languages: &std::collections::HashMap, - preference: HighlighterPreference, + _preference: HighlighterPreference, ) -> Self { - match preference { - // Auto now defaults to TextMate for highlighting (syntect has broader coverage) - // but still detects tree-sitter language for indentation/semantic features - HighlighterPreference::Auto | HighlighterPreference::TextMate => { - Self::textmate_for_file_with_languages(path, registry, languages) - } - HighlighterPreference::TreeSitter => { - if let Some(lang) = Language::from_path(path) { - if let Ok(highlighter) = Highlighter::new(lang) { - return Self::TreeSitter(highlighter); - } - } - Self::None - } - } + // All preferences now use TextMate/syntect (WASM-compatible) + Self::textmate_for_file_with_languages(path, registry, languages) } /// Create a highlighting engine with explicit preference pub fn for_file_with_preference( path: &Path, registry: &GrammarRegistry, - preference: HighlighterPreference, + _preference: HighlighterPreference, ) -> Self { - match preference { - // Auto now defaults to TextMate for highlighting (syntect has broader coverage) - // but still detects tree-sitter language for indentation/semantic features - HighlighterPreference::Auto | HighlighterPreference::TextMate => { - Self::textmate_for_file(path, registry) - } - HighlighterPreference::TreeSitter => { - if let Some(lang) = Language::from_path(path) { - if let Ok(highlighter) = Highlighter::new(lang) { - return Self::TreeSitter(highlighter); - } - } - Self::None - } - } + // All preferences now use TextMate/syntect (WASM-compatible) + Self::textmate_for_file(path, registry) } - /// Create a TextMate engine for a file, falling back to tree-sitter if no TextMate grammar + /// Create a TextMate engine for a file fn textmate_for_file(path: &Path, registry: &GrammarRegistry) -> Self { let syntax_set = registry.syntax_set_arc(); - // Detect tree-sitter language for non-highlighting features - let ts_language = Language::from_path(path); + // Detect language for non-highlighting features + let language = Language::from_path(path); // Find syntax by file extension if let Some(syntax) = registry.find_syntax_for_file(path) { @@ -574,23 +684,11 @@ impl HighlightEngine { return Self::TextMate(TextMateEngine::with_language( syntax_set, index, - ts_language, + language, )); } } - // No TextMate grammar found - fall back to tree-sitter if available - // This handles languages like TypeScript that syntect doesn't include by default - if let Some(lang) = ts_language { - if let Ok(highlighter) = Highlighter::new(lang) { - tracing::debug!( - "No TextMate grammar for {:?}, falling back to tree-sitter", - path.extension() - ); - return Self::TreeSitter(highlighter); - } - } - Self::None } @@ -602,8 +700,8 @@ impl HighlightEngine { ) -> Self { let syntax_set = registry.syntax_set_arc(); - // Detect tree-sitter language for non-highlighting features - let ts_language = Language::from_path(path); + // Detect language for non-highlighting features + let language = Language::from_path(path); // Find syntax by file extension, checking languages config first if let Some(syntax) = registry.find_syntax_for_file_with_languages(path, languages) { @@ -616,23 +714,11 @@ impl HighlightEngine { return Self::TextMate(TextMateEngine::with_language( syntax_set, index, - ts_language, + language, )); } } - // No TextMate grammar found - fall back to tree-sitter if available - // This handles languages like TypeScript that syntect doesn't include by default - if let Some(lang) = ts_language { - if let Ok(highlighter) = Highlighter::new(lang) { - tracing::debug!( - "No TextMate grammar for {:?}, falling back to tree-sitter", - path.extension() - ); - return Self::TreeSitter(highlighter); - } - } - Self::None } @@ -649,9 +735,6 @@ impl HighlightEngine { context_bytes: usize, ) -> Vec { match self { - Self::TreeSitter(h) => { - h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes) - } Self::TextMate(h) => { h.highlight_viewport(buffer, viewport_start, viewport_end, theme, context_bytes) } @@ -662,7 +745,6 @@ impl HighlightEngine { /// Invalidate cache for an edited range pub fn invalidate_range(&mut self, edit_range: Range) { match self { - Self::TreeSitter(h) => h.invalidate_range(edit_range), Self::TextMate(h) => h.invalidate_range(edit_range), Self::None => {} } @@ -671,7 +753,6 @@ impl HighlightEngine { /// Invalidate entire cache pub fn invalidate_all(&mut self) { match self { - Self::TreeSitter(h) => h.invalidate_all(), Self::TextMate(h) => h.invalidate_all(), Self::None => {} } @@ -685,7 +766,6 @@ impl HighlightEngine { /// Get a description of the active backend pub fn backend_name(&self) -> &str { match self { - Self::TreeSitter(_) => "tree-sitter", Self::TextMate(_) => "textmate", Self::None => "none", } @@ -694,17 +774,14 @@ impl HighlightEngine { /// Get the language/syntax name if available pub fn syntax_name(&self) -> Option<&str> { match self { - Self::TreeSitter(_) => None, // Tree-sitter doesn't expose name easily Self::TextMate(h) => Some(h.syntax_name()), Self::None => None, } } - /// Get the tree-sitter Language for non-highlighting features - /// Returns the language even when using TextMate for highlighting + /// Get the Language for non-highlighting features (indentation, etc.) pub fn language(&self) -> Option<&Language> { match self { - Self::TreeSitter(h) => Some(h.language()), Self::TextMate(h) => h.language(), Self::None => None, } @@ -859,7 +936,7 @@ mod tests { #[test] fn test_textmate_backend_selection() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::load_from_config_dir(); // Languages with TextMate grammars use TextMate for highlighting let engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry); @@ -875,32 +952,16 @@ mod tests { assert_eq!(engine.backend_name(), "textmate"); assert!(engine.language().is_some()); - // TypeScript falls back to tree-sitter (syntect doesn't include TS by default) + // TypeScript - no built-in TextMate grammar, returns None let engine = HighlightEngine::for_file(Path::new("test.ts"), ®istry); - assert_eq!(engine.backend_name(), "tree-sitter"); - assert!(engine.language().is_some()); - - let engine = HighlightEngine::for_file(Path::new("test.tsx"), ®istry); - assert_eq!(engine.backend_name(), "tree-sitter"); - assert!(engine.language().is_some()); - } - - #[test] - fn test_tree_sitter_explicit_preference() { - let registry = GrammarRegistry::load(); - - // Force tree-sitter for highlighting - let engine = HighlightEngine::for_file_with_preference( - Path::new("test.rs"), - ®istry, - HighlighterPreference::TreeSitter, - ); - assert_eq!(engine.backend_name(), "tree-sitter"); + assert_eq!(engine.backend_name(), "none"); + // But language detection still works + assert!(Language::from_path(Path::new("test.ts")).is_some()); } #[test] fn test_unknown_extension() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::load_from_config_dir(); // Unknown extension let engine = HighlightEngine::for_file(Path::new("test.unknown_xyz_123"), ®istry); @@ -919,7 +980,7 @@ mod tests { // - viewport_start > context_bytes (so parse_start > 0 after saturating_sub) // - parse_end = min(viewport_end + context_bytes, buffer.len()) = 0 // - parse_end - parse_start would underflow (0 - positive = overflow) - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::load_from_config_dir(); let mut engine = HighlightEngine::for_file(Path::new("test.rs"), ®istry); @@ -942,7 +1003,7 @@ mod tests { /// offset drift per line because it strips line terminators. #[test] fn test_textmate_engine_crlf_byte_offsets() { - let registry = GrammarRegistry::load(); + let registry = GrammarRegistry::load_from_config_dir(); let mut engine = HighlightEngine::for_file(Path::new("test.java"), ®istry); diff --git a/src/primitives/highlighter.rs b/src/primitives/highlighter.rs index 19a0c5c1a..b24f22842 100644 --- a/src/primitives/highlighter.rs +++ b/src/primitives/highlighter.rs @@ -1,5 +1,8 @@ //! Syntax highlighting with tree-sitter //! +//! This module provides tree-sitter based syntax highlighting (runtime-only). +//! For WASM-compatible highlighting, see `highlight_engine` which uses syntect. +//! //! # Design //! - **Viewport-only parsing**: Only highlights visible lines for instant performance with large files //! - **Incremental updates**: Re-parses only edited regions @@ -12,109 +15,15 @@ use crate::config::LARGE_FILE_THRESHOLD_BYTES; use crate::model::buffer::Buffer; use crate::view::theme::Theme; -use ratatui::style::Color; use std::ops::Range; use tree_sitter_highlight::{HighlightConfiguration, HighlightEvent, Highlighter as TSHighlighter}; +// Re-export shared types from highlight_engine (WASM-compatible) +pub use crate::primitives::highlight_engine::{HighlightCategory, HighlightSpan, Language}; + /// Maximum bytes to parse in a single operation (for viewport highlighting) const MAX_PARSE_BYTES: usize = LARGE_FILE_THRESHOLD_BYTES as usize; // 1MB -/// Highlight category names used for default languages. -/// The order matches the `configure()` call in `highlight_config()`. -/// Index 0 = attribute, 1 = comment, 2 = constant, 3 = function, 4 = keyword, -/// 5 = number, 6 = operator, 7 = property, 8 = string, 9 = type, 10 = variable -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HighlightCategory { - Attribute, - Comment, - Constant, - Function, - Keyword, - Number, - Operator, - Property, - String, - Type, - Variable, -} - -impl HighlightCategory { - /// Map a default language highlight index to a category - fn from_default_index(index: usize) -> Option { - match index { - 0 => Some(Self::Attribute), - 1 => Some(Self::Comment), - 2 => Some(Self::Constant), - 3 => Some(Self::Function), - 4 => Some(Self::Keyword), - 5 => Some(Self::Number), - 6 => Some(Self::Operator), - 7 => Some(Self::Property), - 8 => Some(Self::String), - 9 => Some(Self::Type), - 10 => Some(Self::Variable), - _ => None, - } - } - - /// Map a TypeScript highlight index to a category. - /// TypeScript has more categories; we map them to the closest theme color. - fn from_typescript_index(index: usize) -> Option { - match index { - 0 => Some(Self::Attribute), // attribute - 1 => Some(Self::Comment), // comment - 2 => Some(Self::Constant), // constant - 3 => Some(Self::Constant), // constant.builtin - 4 => Some(Self::Type), // constructor - 5 => Some(Self::String), // embedded (template substitutions) - 6 => Some(Self::Function), // function - 7 => Some(Self::Function), // function.builtin - 8 => Some(Self::Function), // function.method - 9 => Some(Self::Keyword), // keyword - 10 => Some(Self::Number), // number - 11 => Some(Self::Operator), // operator - 12 => Some(Self::Property), // property - 13 => Some(Self::Operator), // punctuation.bracket - 14 => Some(Self::Operator), // punctuation.delimiter - 15 => Some(Self::Constant), // punctuation.special (template ${}) - 16 => Some(Self::String), // string - 17 => Some(Self::String), // string.special (regex) - 18 => Some(Self::Type), // type - 19 => Some(Self::Type), // type.builtin - 20 => Some(Self::Variable), // variable - 21 => Some(Self::Constant), // variable.builtin (this, super, arguments) - 22 => Some(Self::Variable), // variable.parameter - _ => None, - } - } - - /// Get the color for this category from the theme - pub fn color(&self, theme: &Theme) -> Color { - match self { - Self::Attribute => theme.syntax_constant, // No specific attribute color, use constant - Self::Comment => theme.syntax_comment, - Self::Constant => theme.syntax_constant, - Self::Function => theme.syntax_function, - Self::Keyword => theme.syntax_keyword, - Self::Number => theme.syntax_constant, - Self::Operator => theme.syntax_operator, - Self::Property => theme.syntax_variable, // Properties are like variables - Self::String => theme.syntax_string, - Self::Type => theme.syntax_type, - Self::Variable => theme.syntax_variable, - } - } -} - -/// A highlighted span of text -#[derive(Debug, Clone)] -pub struct HighlightSpan { - /// Byte range in the buffer - pub range: Range, - /// Color for this span - pub color: Color, -} - /// Internal span used for caching (stores category instead of color) #[derive(Debug, Clone)] struct CachedSpan { @@ -124,55 +33,7 @@ struct CachedSpan { category: HighlightCategory, } -/// Language configuration for syntax highlighting -#[derive(Debug, Clone, Copy)] -pub enum Language { - Rust, - Python, - JavaScript, - TypeScript, - HTML, - CSS, - C, - Cpp, - Go, - Json, - Java, - CSharp, - Php, - Ruby, - Bash, - Lua, - Pascal, - // Markdown, // Disabled due to tree-sitter version conflict -} - impl Language { - /// Detect language from file extension - pub fn from_path(path: &std::path::Path) -> Option { - match path.extension()?.to_str()? { - "rs" => Some(Language::Rust), - "py" => Some(Language::Python), - "js" | "jsx" => Some(Language::JavaScript), - "ts" | "tsx" => Some(Language::TypeScript), - "html" => Some(Language::HTML), - "css" => Some(Language::CSS), - "c" | "h" => Some(Language::C), - "cpp" | "hpp" | "cc" | "hh" | "cxx" | "hxx" => Some(Language::Cpp), - "go" => Some(Language::Go), - "json" => Some(Language::Json), - "java" => Some(Language::Java), - "cs" => Some(Language::CSharp), - "php" => Some(Language::Php), - "rb" => Some(Language::Ruby), - "sh" | "bash" => Some(Language::Bash), - "lua" => Some(Language::Lua), - "pas" | "p" => Some(Language::Pascal), - // "md" => Some(Language::Markdown), // Disabled - _ => None, - } - } - /// Get tree-sitter highlight configuration for this language fn highlight_config(&self) -> Result { match self { diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index d48972a04..55fe1cb19 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -2,19 +2,29 @@ //! //! This module contains syntax highlighting, ANSI handling, //! and text manipulation utilities. +//! +//! Most submodules are pure Rust and WASM-compatible. +//! Runtime-only modules (tree-sitter based) are marked with #[cfg(feature = "runtime")]. +// Pure Rust primitives (WASM-compatible) pub mod ansi; pub mod ansi_background; pub mod display_width; pub mod grammar_registry; pub mod grapheme; pub mod highlight_engine; -pub mod highlighter; -pub mod indent; pub mod line_iterator; pub mod line_wrapping; -pub mod semantic_highlight; pub mod snippet; +pub mod syntect_highlighter; pub mod text_property; pub mod visual_layout; pub mod word_navigation; + +// Runtime-only primitives (depend on tree-sitter which requires native C code) +#[cfg(feature = "runtime")] +pub mod highlighter; +#[cfg(feature = "runtime")] +pub mod indent; +#[cfg(feature = "runtime")] +pub mod semantic_highlight; diff --git a/src/primitives/syntect_highlighter.rs b/src/primitives/syntect_highlighter.rs new file mode 100644 index 000000000..01562bb4b --- /dev/null +++ b/src/primitives/syntect_highlighter.rs @@ -0,0 +1,445 @@ +//! WASM-safe syntax highlighting using syntect +//! +//! This module provides syntect-based syntax highlighting that works in both +//! native and WASM builds. It doesn't depend on model::buffer::Buffer, making +//! it suitable for use in the WASM editor. +//! +//! For the native editor, the more feature-rich `highlight_engine.rs` is used +//! which also includes tree-sitter fallback. + +use ratatui::style::Color; +use std::ops::Range; +use std::sync::Arc; +use syntect::parsing::{ParseState, ScopeStack, SyntaxReference, SyntaxSet}; + +use crate::view::theme::Theme; + +/// Highlight category for syntax elements +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightCategory { + Attribute, + Comment, + Constant, + Function, + Keyword, + Number, + Operator, + Property, + String, + Type, + Variable, +} + +impl HighlightCategory { + /// Get the color for this category from the theme + pub fn color(&self, theme: &Theme) -> Color { + match self { + Self::Attribute => theme.syntax_constant, + Self::Comment => theme.syntax_comment, + Self::Constant => theme.syntax_constant, + Self::Function => theme.syntax_function, + Self::Keyword => theme.syntax_keyword, + Self::Number => theme.syntax_constant, + Self::Operator => theme.syntax_operator, + Self::Property => theme.syntax_variable, + Self::String => theme.syntax_string, + Self::Type => theme.syntax_type, + Self::Variable => theme.syntax_variable, + } + } +} + +/// A highlighted span of text +#[derive(Debug, Clone)] +pub struct HighlightSpan { + /// Byte range in the source text + pub range: Range, + /// Color for this span + pub color: Color, +} + +/// Cached highlight span (stores category for theme-independent caching) +#[derive(Debug, Clone)] +struct CachedSpan { + range: Range, + category: HighlightCategory, +} + +/// Map TextMate scope to highlight category +fn scope_to_category(scope: &str) -> Option { + let scope_lower = scope.to_lowercase(); + + // Comments - highest priority + if scope_lower.starts_with("comment") { + return Some(HighlightCategory::Comment); + } + + // Strings + if scope_lower.starts_with("string") { + return Some(HighlightCategory::String); + } + + // Keywords (but not keyword.operator) + if scope_lower.starts_with("keyword") && !scope_lower.starts_with("keyword.operator") { + return Some(HighlightCategory::Keyword); + } + + // Operators + if scope_lower.starts_with("keyword.operator") || scope_lower.starts_with("punctuation") { + return Some(HighlightCategory::Operator); + } + + // Functions + if scope_lower.starts_with("entity.name.function") + || scope_lower.starts_with("support.function") + || scope_lower.starts_with("meta.function-call") + || scope_lower.starts_with("variable.function") + { + return Some(HighlightCategory::Function); + } + + // Types + if scope_lower.starts_with("entity.name.type") + || scope_lower.starts_with("entity.name.class") + || scope_lower.starts_with("support.type") + || scope_lower.starts_with("support.class") + || scope_lower.starts_with("storage.type") + { + return Some(HighlightCategory::Type); + } + + // Storage modifiers + if scope_lower.starts_with("storage.modifier") { + return Some(HighlightCategory::Keyword); + } + + // Constants and numbers + if scope_lower.starts_with("constant.numeric") + || scope_lower.starts_with("constant.language.boolean") + { + return Some(HighlightCategory::Number); + } + if scope_lower.starts_with("constant") { + return Some(HighlightCategory::Constant); + } + + // Variables + if scope_lower.starts_with("variable") { + return Some(HighlightCategory::Variable); + } + + // Properties + if scope_lower.starts_with("entity.name.tag") + || scope_lower.starts_with("support.other.property") + || scope_lower.starts_with("meta.object-literal.key") + { + return Some(HighlightCategory::Property); + } + + // Attributes + if scope_lower.starts_with("entity.other.attribute") + || scope_lower.starts_with("meta.attribute") + { + return Some(HighlightCategory::Attribute); + } + + None +} + +/// WASM-safe syntax highlighter using syntect +pub struct SyntectHighlighter { + syntax_set: Arc, + syntax_index: usize, + cache: Option, + last_content_len: usize, +} + +#[derive(Debug, Clone)] +struct HighlightCache { + range: Range, + spans: Vec, +} + +impl SyntectHighlighter { + /// Create a new highlighter for the given file extension + pub fn for_extension(ext: &str) -> Option { + let syntax_set = SyntaxSet::load_defaults_newlines(); + let syntax = syntax_set.find_syntax_by_extension(ext)?; + + let syntax_index = syntax_set + .syntaxes() + .iter() + .position(|s| s.name == syntax.name)?; + + Some(Self { + syntax_set: Arc::new(syntax_set), + syntax_index, + cache: None, + last_content_len: 0, + }) + } + + /// Create a new highlighter for the given syntax name (e.g., "Rust", "Python") + pub fn for_syntax_name(name: &str) -> Option { + let syntax_set = SyntaxSet::load_defaults_newlines(); + let syntax = syntax_set.find_syntax_by_name(name)?; + + let syntax_index = syntax_set + .syntaxes() + .iter() + .position(|s| s.name == syntax.name)?; + + Some(Self { + syntax_set: Arc::new(syntax_set), + syntax_index, + cache: None, + last_content_len: 0, + }) + } + + /// Get the syntax reference + fn syntax(&self) -> &SyntaxReference { + &self.syntax_set.syntaxes()[self.syntax_index] + } + + /// Get the syntax name + pub fn syntax_name(&self) -> &str { + &self.syntax().name + } + + /// Highlight a string and return colored spans + /// + /// `viewport_start` and `viewport_end` are byte offsets into the content. + /// Only spans within or overlapping this range are returned. + /// `context_bytes` controls how much context before the viewport to parse + /// for accurate multi-line constructs. + pub fn highlight_viewport( + &mut self, + content: &str, + viewport_start: usize, + viewport_end: usize, + theme: &Theme, + context_bytes: usize, + ) -> Vec { + // Check cache validity + if let Some(cache) = &self.cache { + if cache.range.start <= viewport_start + && cache.range.end >= viewport_end + && self.last_content_len == content.len() + { + return cache + .spans + .iter() + .filter(|span| { + span.range.start < viewport_end && span.range.end > viewport_start + }) + .map(|span| HighlightSpan { + range: span.range.clone(), + color: span.category.color(theme), + }) + .collect(); + } + } + + // Cache miss - parse + let parse_start = viewport_start.saturating_sub(context_bytes); + let parse_end = viewport_end.saturating_add(context_bytes).min(content.len()); + + if parse_end <= parse_start { + return Vec::new(); + } + + let mut state = ParseState::new(self.syntax()); + let mut spans = Vec::new(); + let mut current_scopes = ScopeStack::new(); + let mut current_offset = 0; + + // Parse line by line + for line in content.lines() { + let line_with_newline = format!("{}\n", line); + let line_len = line.len(); + + // Skip lines before our parse region + if current_offset + line_len < parse_start { + // Still need to parse for state, but don't collect spans + let _ = state.parse_line(&line_with_newline, &self.syntax_set); + current_offset += line_len + 1; // +1 for newline + continue; + } + + // Stop if we're past the parse region + if current_offset >= parse_end { + break; + } + + // Parse this line + let ops = match state.parse_line(&line_with_newline, &self.syntax_set) { + Ok(ops) => ops, + Err(_) => { + current_offset += line_len + 1; + continue; + } + }; + + // Convert operations to spans + let mut syntect_offset = 0; + + for (op_offset, op) in ops { + let clamped_offset = op_offset.min(line_len); + if clamped_offset > syntect_offset { + if let Some(category) = Self::scope_stack_to_category(¤t_scopes) { + let byte_start = current_offset + syntect_offset; + let byte_end = current_offset + clamped_offset; + if byte_start < byte_end { + spans.push(CachedSpan { + range: byte_start..byte_end, + category, + }); + } + } + } + syntect_offset = clamped_offset; + let _ = current_scopes.apply(&op); + } + + // Handle remaining text on line + if syntect_offset < line_len { + if let Some(category) = Self::scope_stack_to_category(¤t_scopes) { + let byte_start = current_offset + syntect_offset; + let byte_end = current_offset + line_len; + if byte_start < byte_end { + spans.push(CachedSpan { + range: byte_start..byte_end, + category, + }); + } + } + } + + current_offset += line_len + 1; // +1 for newline + } + + // Merge adjacent spans with same category + Self::merge_adjacent_spans(&mut spans); + + // Update cache + self.cache = Some(HighlightCache { + range: parse_start..parse_end, + spans: spans.clone(), + }); + self.last_content_len = content.len(); + + // Filter to requested viewport and resolve colors + spans + .into_iter() + .filter(|span| span.range.start < viewport_end && span.range.end > viewport_start) + .map(|span| HighlightSpan { + range: span.range, + color: span.category.color(theme), + }) + .collect() + } + + /// Map scope stack to highlight category + fn scope_stack_to_category(scopes: &ScopeStack) -> Option { + for scope in scopes.as_slice().iter().rev() { + let scope_str = scope.build_string(); + if let Some(cat) = scope_to_category(&scope_str) { + return Some(cat); + } + } + None + } + + /// Merge adjacent spans with same category + fn merge_adjacent_spans(spans: &mut Vec) { + if spans.len() < 2 { + return; + } + + let mut write_idx = 0; + for read_idx in 1..spans.len() { + if spans[write_idx].category == spans[read_idx].category + && spans[write_idx].range.end == spans[read_idx].range.start + { + spans[write_idx].range.end = spans[read_idx].range.end; + } else { + write_idx += 1; + if write_idx != read_idx { + spans[write_idx] = spans[read_idx].clone(); + } + } + } + spans.truncate(write_idx + 1); + } + + /// Invalidate the cache (call when content changes) + pub fn invalidate(&mut self) { + self.cache = None; + } +} + +/// Get a list of supported file extensions +pub fn supported_extensions() -> Vec<&'static str> { + vec![ + "rs", "py", "js", "jsx", "ts", "tsx", "html", "css", "c", "h", "cpp", "hpp", "cc", "go", + "json", "java", "cs", "php", "rb", "sh", "bash", "lua", "md", "yaml", "yml", "toml", "xml", + "sql", "swift", "kt", "scala", "r", "pl", "pm", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_highlighter_creation() { + let h = SyntectHighlighter::for_extension("rs"); + assert!(h.is_some()); + assert_eq!(h.unwrap().syntax_name(), "Rust"); + + let h = SyntectHighlighter::for_extension("py"); + assert!(h.is_some()); + assert_eq!(h.unwrap().syntax_name(), "Python"); + + let h = SyntectHighlighter::for_extension("unknown_xyz"); + assert!(h.is_none()); + } + + #[test] + fn test_basic_highlighting() { + let mut h = SyntectHighlighter::for_extension("rs").unwrap(); + let theme = Theme::dark(); + let content = "fn main() {\n println!(\"hello\");\n}"; + + let spans = h.highlight_viewport(content, 0, content.len(), &theme, 1000); + + // Should have some spans + assert!(!spans.is_empty()); + + // Keywords like "fn" should be highlighted + let has_keyword = spans.iter().any(|s| s.color == theme.syntax_keyword); + assert!(has_keyword, "Should highlight keywords"); + } + + #[test] + fn test_viewport_highlighting() { + let mut h = SyntectHighlighter::for_extension("rs").unwrap(); + let theme = Theme::dark(); + + // Create content with multiple lines + let content = "fn foo() {}\nfn bar() {}\nfn baz() {}"; + + // Highlight only middle portion + let spans = h.highlight_viewport(content, 12, 23, &theme, 0); + + // Spans should be within or near the viewport + for span in &spans { + assert!( + span.range.start < 30, + "Span start {} too far from viewport", + span.range.start + ); + } + } +} diff --git a/src/services/fs/backend.rs b/src/services/fs/backend.rs index c6b4745c1..61478cd34 100644 --- a/src/services/fs/backend.rs +++ b/src/services/fs/backend.rs @@ -1,3 +1,21 @@ +//! Async filesystem backend trait for directory operations +//! +//! # Relationship to `model::filesystem::FileSystem` +//! +//! This crate has two filesystem abstractions: +//! +//! - **`services::fs::FsBackend`** (this module): Async trait for directory traversal +//! and metadata. Used by the file tree UI for listing directories. +//! +//! - **`model::filesystem::FileSystem`**: Sync trait for file content I/O. +//! Used by `Buffer` for loading/saving file contents. Lives in `model` to support +//! WASM builds where `services` is unavailable. +//! +//! These are kept separate because: +//! 1. Different concerns: directory navigation vs content I/O +//! 2. Different styles: async (UI with slow/network FS) vs sync (buffer ops) +//! 3. Different availability: `services` is runtime-only, `model` works in WASM + use async_trait::async_trait; use std::io; use std::path::{Path, PathBuf}; diff --git a/src/state.rs b/src/state.rs index 9a3ea074a..46c3372b0 100644 --- a/src/state.rs +++ b/src/state.rs @@ -9,8 +9,7 @@ use crate::model::event::{ }; use crate::model::marker::MarkerList; use crate::primitives::grammar_registry::GrammarRegistry; -use crate::primitives::highlight_engine::HighlightEngine; -use crate::primitives::highlighter::Language; +use crate::primitives::highlight_engine::{HighlightEngine, Language}; use crate::primitives::indent::IndentCalculator; use crate::primitives::semantic_highlight::SemanticHighlighter; use crate::primitives::text_property::TextPropertyManager; diff --git a/src/view/markdown.rs b/src/view/markdown.rs index 14168ef93..3ab35d823 100644 --- a/src/view/markdown.rs +++ b/src/view/markdown.rs @@ -5,8 +5,7 @@ //! wrapping utilities for styled text. use crate::primitives::grammar_registry::GrammarRegistry; -use crate::primitives::highlight_engine::highlight_string; -use crate::primitives::highlighter::HighlightSpan; +use crate::primitives::highlight_engine::{highlight_string, HighlightSpan}; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use ratatui::style::{Color, Modifier, Style}; diff --git a/src/view/mod.rs b/src/view/mod.rs index a0277b7bb..e72d25e7e 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,28 +1,52 @@ //! View and UI layer //! //! This module contains all presentation and rendering components. +//! +//! Most submodules are pure Rust/ratatui and WASM-compatible. +//! Runtime-only modules (crossterm, tree-sitter, input handlers) are marked with #[cfg(feature = "runtime")]. -pub mod calibration_wizard; +// Pure Rust view components (WASM-compatible) pub mod color_support; pub mod composite_view; -pub mod controls; pub mod dimming; -pub mod file_browser_input; -pub mod file_tree; pub mod margin; pub mod markdown; pub mod overlay; +pub mod scroll_sync; +pub mod text_content; +pub mod theme; +pub mod virtual_text; + +// Mixed view components (have internal gating for runtime-only parts) +#[cfg(any(feature = "runtime", feature = "wasm"))] pub mod popup; +#[cfg(any(feature = "runtime", feature = "wasm"))] +pub mod ui; + +// Runtime-only view components (depend on input, services, tree-sitter, crossterm) +#[cfg(feature = "runtime")] +pub mod calibration_wizard; +#[cfg(feature = "runtime")] +pub mod controls; +#[cfg(feature = "runtime")] +pub mod file_browser_input; +#[cfg(feature = "runtime")] +pub mod file_tree; +#[cfg(feature = "runtime")] pub mod popup_input; +#[cfg(feature = "runtime")] pub mod prompt; +#[cfg(feature = "runtime")] pub mod prompt_input; +#[cfg(feature = "runtime")] pub mod query_replace_input; -pub mod scroll_sync; +#[cfg(feature = "runtime")] pub mod semantic_highlight_cache; +#[cfg(feature = "runtime")] pub mod settings; +#[cfg(feature = "runtime")] pub mod split; +#[cfg(feature = "runtime")] pub mod stream; -pub mod theme; -pub mod ui; +#[cfg(feature = "runtime")] pub mod viewport; -pub mod virtual_text; diff --git a/src/view/text_content.rs b/src/view/text_content.rs new file mode 100644 index 000000000..f61031382 --- /dev/null +++ b/src/view/text_content.rs @@ -0,0 +1,242 @@ +//! Text content provider trait for shared rendering +//! +//! This module defines a trait that abstracts buffer access for rendering. +//! Both native `Buffer` and WASM `TextBuffer` implement this trait, enabling +//! shared rendering code between native and WASM builds. + +use std::ops::Range; + +/// Line ending format +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LineEnding { + /// Unix-style line ending (LF) + #[default] + Lf, + /// Windows-style line ending (CRLF) + CrLf, + /// Classic Mac-style line ending (CR) + Cr, +} + +impl LineEnding { + /// Get the line ending as a string + pub fn as_str(&self) -> &'static str { + match self { + LineEnding::Lf => "\n", + LineEnding::CrLf => "\r\n", + LineEnding::Cr => "\r", + } + } +} + +/// A line of text with its byte offset in the buffer +#[derive(Debug, Clone)] +pub struct TextLine { + /// Byte offset where this line starts in the buffer + pub start_byte: usize, + /// The line content (without line ending) + pub content: String, + /// Length of line ending (0, 1, or 2) + pub line_ending_len: usize, +} + +impl TextLine { + /// Total byte length including line ending + pub fn total_len(&self) -> usize { + self.content.len() + self.line_ending_len + } +} + +/// Minimal interface for text content access during rendering +/// +/// This trait is designed to be implementable by both native `Buffer` and +/// WASM `TextBuffer`, enabling shared rendering code between platforms. +/// +/// All methods are read-only queries - no modification operations are included +/// as they're not needed for rendering. +pub trait TextContentProvider { + /// Total bytes in buffer + fn len(&self) -> usize; + + /// Check if buffer is empty + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether content is binary (affects rendering) + fn is_binary(&self) -> bool; + + /// Get line ending format (LF, CRLF, CR) + fn line_ending(&self) -> LineEnding; + + /// Total number of lines in the buffer + fn line_count(&self) -> usize; + + /// Get a specific line by line number (0-indexed) + /// Returns None if line number is out of bounds + fn get_line(&self, line_num: usize) -> Option; + + /// Get raw bytes in range (for binary mode rendering) + fn slice_bytes(&self, range: Range) -> Vec; + + /// Map byte offset to line number (0-indexed) + fn byte_to_line(&self, byte_offset: usize) -> usize; + + /// Map line number to first byte offset of that line + fn line_to_byte(&self, line: usize) -> Option; + + /// Get text in byte range + fn get_text_range(&self, start: usize, end: usize) -> String; + + /// Get all content as a single string (for highlighting) + fn content(&self) -> String; +} + +/// Iterator over visible lines in a viewport +pub struct ViewportLineIterator<'a, T: TextContentProvider + ?Sized> { + provider: &'a T, + current_line: usize, + end_line: usize, +} + +impl<'a, T: TextContentProvider + ?Sized> ViewportLineIterator<'a, T> { + /// Create a new iterator for the given viewport + pub fn new(provider: &'a T, start_line: usize, end_line: usize) -> Self { + Self { + provider, + current_line: start_line, + end_line: end_line.min(provider.line_count()), + } + } +} + +impl<'a, T: TextContentProvider + ?Sized> Iterator for ViewportLineIterator<'a, T> { + type Item = (usize, TextLine); + + fn next(&mut self) -> Option { + if self.current_line >= self.end_line { + return None; + } + + let line_num = self.current_line; + self.current_line += 1; + + self.provider.get_line(line_num).map(|line| (line_num, line)) + } +} + +/// Extension trait for viewport iteration +pub trait TextContentProviderExt: TextContentProvider { + /// Iterate over lines in a viewport range + fn viewport_lines(&self, start_line: usize, end_line: usize) -> ViewportLineIterator<'_, Self> { + ViewportLineIterator::new(self, start_line, end_line) + } +} + +impl TextContentProviderExt for T {} + +#[cfg(test)] +mod tests { + use super::*; + + // Simple test implementation + struct SimpleBuffer { + lines: Vec, + } + + impl TextContentProvider for SimpleBuffer { + fn len(&self) -> usize { + self.lines.iter().map(|l| l.len() + 1).sum::().saturating_sub(1) + } + + fn is_binary(&self) -> bool { + false + } + + fn line_ending(&self) -> LineEnding { + LineEnding::Lf + } + + fn line_count(&self) -> usize { + self.lines.len() + } + + fn get_line(&self, line_num: usize) -> Option { + self.lines.get(line_num).map(|content| { + let start_byte = self.lines[..line_num] + .iter() + .map(|l| l.len() + 1) + .sum(); + TextLine { + start_byte, + content: content.clone(), + line_ending_len: if line_num < self.lines.len() - 1 { 1 } else { 0 }, + } + }) + } + + fn slice_bytes(&self, range: Range) -> Vec { + self.content().as_bytes()[range].to_vec() + } + + fn byte_to_line(&self, byte_offset: usize) -> usize { + let mut offset = 0; + for (i, line) in self.lines.iter().enumerate() { + if byte_offset < offset + line.len() + 1 { + return i; + } + offset += line.len() + 1; + } + self.lines.len().saturating_sub(1) + } + + fn line_to_byte(&self, line: usize) -> Option { + if line >= self.lines.len() { + return None; + } + Some(self.lines[..line].iter().map(|l| l.len() + 1).sum()) + } + + fn get_text_range(&self, start: usize, end: usize) -> String { + let content = self.content(); + content[start.min(content.len())..end.min(content.len())].to_string() + } + + fn content(&self) -> String { + self.lines.join("\n") + } + } + + #[test] + fn test_viewport_iterator() { + let buffer = SimpleBuffer { + lines: vec!["line 0".into(), "line 1".into(), "line 2".into(), "line 3".into()], + }; + + let lines: Vec<_> = buffer.viewport_lines(1, 3).collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].0, 1); + assert_eq!(lines[0].1.content, "line 1"); + assert_eq!(lines[1].0, 2); + assert_eq!(lines[1].1.content, "line 2"); + } + + #[test] + fn test_byte_line_mapping() { + let buffer = SimpleBuffer { + lines: vec!["abc".into(), "defgh".into(), "i".into()], + }; + + // "abc\ndefgh\ni" + assert_eq!(buffer.byte_to_line(0), 0); // 'a' + assert_eq!(buffer.byte_to_line(3), 0); // '\n' + assert_eq!(buffer.byte_to_line(4), 1); // 'd' + assert_eq!(buffer.byte_to_line(9), 1); // '\n' + assert_eq!(buffer.byte_to_line(10), 2); // 'i' + + assert_eq!(buffer.line_to_byte(0), Some(0)); + assert_eq!(buffer.line_to_byte(1), Some(4)); + assert_eq!(buffer.line_to_byte(2), Some(10)); + assert_eq!(buffer.line_to_byte(3), None); + } +} diff --git a/src/view/theme.rs b/src/view/theme.rs index b99c440ff..80c354d7a 100644 --- a/src/view/theme.rs +++ b/src/view/theme.rs @@ -1,5 +1,7 @@ use ratatui::style::Color; use serde::{Deserialize, Serialize}; + +#[cfg(feature = "runtime")] use std::path::Path; /// Convert a ratatui Color to RGB values. @@ -604,7 +606,8 @@ impl From for Theme { } impl Theme { - /// Load theme from a JSON file + /// Load theme from a JSON file (runtime only) + #[cfg(feature = "runtime")] fn from_file>(path: P) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read theme file: {}", e))?; @@ -613,7 +616,8 @@ impl Theme { Ok(theme_file.into()) } - /// Load builtin theme from the themes directory + /// Load builtin theme from the themes directory (runtime only) + #[cfg(feature = "runtime")] fn load_builtin_theme(name: &str) -> Option { // Build list of paths to search let mut theme_paths = vec![ @@ -1035,7 +1039,8 @@ impl Theme { } /// Get a theme by name, defaults to dark if not found - /// Tries to load from JSON file first, falls back to hardcoded themes + /// Runtime version: tries to load from JSON file first, falls back to hardcoded themes + #[cfg(feature = "runtime")] pub fn from_name(name: &str) -> Self { let normalized_name = name.to_lowercase().replace('_', "-"); @@ -1045,7 +1050,20 @@ impl Theme { } // Fall back to hardcoded themes - match normalized_name.as_str() { + Self::from_name_embedded(&normalized_name) + } + + /// Get a theme by name, defaults to dark if not found + /// Non-runtime version: only uses hardcoded themes + #[cfg(not(feature = "runtime"))] + pub fn from_name(name: &str) -> Self { + let normalized_name = name.to_lowercase().replace('_', "-"); + Self::from_name_embedded(&normalized_name) + } + + /// Get a theme from embedded/hardcoded themes only + fn from_name_embedded(normalized_name: &str) -> Self { + match normalized_name { "light" => Self::light(), "high-contrast" => Self::high_contrast(), "nostalgia" => Self::nostalgia(), @@ -1054,13 +1072,10 @@ impl Theme { } /// Get all available theme names (builtin + user themes) + /// Runtime version: scans user themes directory + #[cfg(feature = "runtime")] pub fn available_themes() -> Vec { - let mut themes: Vec = vec![ - "dark".to_string(), - "light".to_string(), - "high-contrast".to_string(), - "nostalgia".to_string(), - ]; + let mut themes = Self::embedded_themes(); // Scan user themes directory if let Some(config_dir) = dirs::config_dir() { @@ -1084,6 +1099,23 @@ impl Theme { themes } + /// Get all available theme names (embedded only) + /// Non-runtime version: only returns hardcoded themes + #[cfg(not(feature = "runtime"))] + pub fn available_themes() -> Vec { + Self::embedded_themes() + } + + /// Get embedded/hardcoded theme names + pub fn embedded_themes() -> Vec { + vec![ + "dark".to_string(), + "light".to_string(), + "high-contrast".to_string(), + "nostalgia".to_string(), + ] + } + /// Nostalgia theme (Turbo Pascal 5 / WordPerfect 5 inspired) pub fn nostalgia() -> Self { Self { diff --git a/src/view/ui/mod.rs b/src/view/ui/mod.rs index 7cb858ebd..084a8bfa0 100644 --- a/src/view/ui/mod.rs +++ b/src/view/ui/mod.rs @@ -2,40 +2,63 @@ //! //! This module contains all rendering logic for the editor UI, //! separated into focused submodules: -//! - `menu` - Menu bar rendering -//! - `tabs` - Tab bar rendering for multiple buffers -//! - `status_bar` - Status bar and prompt/minibuffer display -//! - `suggestions` - Autocomplete and command palette UI -//! - `split_rendering` - Split pane layout and rendering -//! - `file_explorer` - File tree explorer rendering -//! - `scrollbar` - Reusable scrollbar widget -//! - `scroll_panel` - Reusable scrollable panel for variable-height items -//! - `file_browser` - File open dialog popup +//! - `scrollbar` - Reusable scrollbar widget (WASM-compatible) +//! - `scroll_panel` - Reusable scrollable panel (WASM-compatible) +//! - `text_edit` - Text input widget (WASM-compatible) +//! - `menu` - Menu bar rendering (WASM-compatible) +//! - `tabs` - Tab bar rendering (runtime-only) +//! - `status_bar` - Status bar display (runtime-only) +//! - `suggestions` - Autocomplete UI (runtime-only) +//! - `split_rendering` - Split pane layout (runtime-only) +//! - `file_explorer` - File tree explorer (runtime-only) +//! - `file_browser` - File open dialog (runtime-only) +// Pure Rust UI widgets (WASM-compatible) +pub mod scroll_panel; +pub mod scrollbar; +pub mod text_edit; + +// Runtime-only UI modules +#[cfg(feature = "runtime")] pub mod file_browser; +#[cfg(feature = "runtime")] pub mod file_explorer; +#[cfg(feature = "runtime")] pub mod menu; +#[cfg(feature = "runtime")] pub mod menu_input; -pub mod scroll_panel; -pub mod scrollbar; +#[cfg(feature = "runtime")] pub mod split_rendering; +#[cfg(feature = "runtime")] pub mod status_bar; +#[cfg(feature = "runtime")] pub mod suggestions; +#[cfg(feature = "runtime")] pub mod tabs; -pub mod text_edit; +#[cfg(feature = "runtime")] pub mod view_pipeline; -// Re-export main types for convenience -pub use file_browser::{FileBrowserLayout, FileBrowserRenderer}; -pub use file_explorer::FileExplorerRenderer; -pub use menu::{context_keys, MenuContext, MenuRenderer, MenuState}; -pub use menu_input::MenuInputHandler; +// Re-export pure types (always available) pub use scroll_panel::{ FocusRegion, RenderInfo, ScrollItem, ScrollState, ScrollablePanel, ScrollablePanelLayout, }; pub use scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState}; +pub use text_edit::TextEdit; + +// Re-export runtime-only types +#[cfg(feature = "runtime")] +pub use file_browser::{FileBrowserLayout, FileBrowserRenderer}; +#[cfg(feature = "runtime")] +pub use file_explorer::FileExplorerRenderer; +#[cfg(feature = "runtime")] +pub use menu::{context_keys, MenuContext, MenuRenderer, MenuState}; +#[cfg(feature = "runtime")] +pub use menu_input::MenuInputHandler; +#[cfg(feature = "runtime")] pub use split_rendering::SplitRenderer; +#[cfg(feature = "runtime")] pub use status_bar::{truncate_path, StatusBarLayout, StatusBarRenderer, TruncatedPath}; +#[cfg(feature = "runtime")] pub use suggestions::SuggestionsRenderer; +#[cfg(feature = "runtime")] pub use tabs::TabsRenderer; -pub use text_edit::TextEdit; diff --git a/src/wasm/event_adapter.rs b/src/wasm/event_adapter.rs new file mode 100644 index 000000000..5adafff93 --- /dev/null +++ b/src/wasm/event_adapter.rs @@ -0,0 +1,200 @@ +//! Event adapter for converting Ratzilla events to crossterm-compatible events +//! +//! The Fresh editor uses crossterm's event types internally. This module provides +//! conversion functions to translate Ratzilla's browser-based events to crossterm format. + +use ratzilla::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; + +/// Crossterm-compatible key event for WASM builds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WasmKeyEvent { + pub code: WasmKeyCode, + pub modifiers: WasmKeyModifiers, +} + +/// Crossterm-compatible key code for WASM builds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasmKeyCode { + Char(char), + Enter, + Tab, + Backspace, + Delete, + Esc, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Insert, + F(u8), +} + +bitflags::bitflags! { + /// Crossterm-compatible key modifiers for WASM builds + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct WasmKeyModifiers: u8 { + const NONE = 0b0000_0000; + const SHIFT = 0b0000_0001; + const CONTROL = 0b0000_0010; + const ALT = 0b0000_0100; + } +} + +/// Crossterm-compatible mouse event for WASM builds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WasmMouseEvent { + pub kind: WasmMouseEventKind, + pub column: u16, + pub row: u16, + pub modifiers: WasmKeyModifiers, +} + +/// Crossterm-compatible mouse button for WASM builds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasmMouseButton { + Left, + Right, + Middle, +} + +/// Crossterm-compatible mouse event kind for WASM builds +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasmMouseEventKind { + Down(WasmMouseButton), + Up(WasmMouseButton), + Drag(WasmMouseButton), + Moved, + ScrollUp, + ScrollDown, +} + +impl WasmKeyEvent { + pub fn new(code: WasmKeyCode, modifiers: WasmKeyModifiers) -> Self { + Self { code, modifiers } + } +} + +/// Convert Ratzilla KeyEvent to WASM-compatible KeyEvent +pub fn convert_key_event(event: &KeyEvent) -> Option { + let code = convert_key_code(&event.code)?; + + let mut modifiers = WasmKeyModifiers::NONE; + if event.ctrl { + modifiers |= WasmKeyModifiers::CONTROL; + } + if event.alt { + modifiers |= WasmKeyModifiers::ALT; + } + if event.shift { + modifiers |= WasmKeyModifiers::SHIFT; + } + + Some(WasmKeyEvent::new(code, modifiers)) +} + +fn convert_key_code(code: &KeyCode) -> Option { + Some(match code { + KeyCode::Char(c) => WasmKeyCode::Char(*c), + KeyCode::Enter => WasmKeyCode::Enter, + KeyCode::Tab => WasmKeyCode::Tab, + KeyCode::Backspace => WasmKeyCode::Backspace, + KeyCode::Delete => WasmKeyCode::Delete, + KeyCode::Esc => WasmKeyCode::Esc, + KeyCode::Up => WasmKeyCode::Up, + KeyCode::Down => WasmKeyCode::Down, + KeyCode::Left => WasmKeyCode::Left, + KeyCode::Right => WasmKeyCode::Right, + KeyCode::Home => WasmKeyCode::Home, + KeyCode::End => WasmKeyCode::End, + KeyCode::PageUp => WasmKeyCode::PageUp, + KeyCode::PageDown => WasmKeyCode::PageDown, + KeyCode::F(n) => WasmKeyCode::F(*n), + KeyCode::Unidentified => return None, + }) +} + +/// Convert Ratzilla MouseEvent to WASM-compatible MouseEvent +pub fn convert_mouse_event(event: &MouseEvent) -> Option { + let button = convert_mouse_button(&event.button); + let kind = convert_mouse_event_kind(&event.event, button)?; + + let mut modifiers = WasmKeyModifiers::NONE; + if event.ctrl { + modifiers |= WasmKeyModifiers::CONTROL; + } + if event.alt { + modifiers |= WasmKeyModifiers::ALT; + } + if event.shift { + modifiers |= WasmKeyModifiers::SHIFT; + } + + Some(WasmMouseEvent { + kind, + column: event.x as u16, + row: event.y as u16, + modifiers, + }) +} + +fn convert_mouse_button(button: &MouseButton) -> WasmMouseButton { + match button { + MouseButton::Left => WasmMouseButton::Left, + MouseButton::Right => WasmMouseButton::Right, + MouseButton::Middle => WasmMouseButton::Middle, + MouseButton::Back | MouseButton::Forward | MouseButton::Unidentified => { + WasmMouseButton::Left // Default for unhandled buttons + } + } +} + +fn convert_mouse_event_kind( + kind: &MouseEventKind, + button: WasmMouseButton, +) -> Option { + Some(match kind { + MouseEventKind::Pressed => WasmMouseEventKind::Down(button), + MouseEventKind::Released => WasmMouseEventKind::Up(button), + MouseEventKind::Moved => WasmMouseEventKind::Moved, + MouseEventKind::Unidentified => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_key_event() { + let event = KeyEvent { + code: KeyCode::Char('a'), + ctrl: true, + alt: false, + shift: false, + }; + let result = convert_key_event(&event).unwrap(); + assert_eq!(result.code, WasmKeyCode::Char('a')); + assert!(result.modifiers.contains(WasmKeyModifiers::CONTROL)); + } + + #[test] + fn test_convert_mouse_event() { + let event = MouseEvent { + button: MouseButton::Left, + event: MouseEventKind::Down, + x: 10, + y: 20, + ctrl: false, + alt: false, + shift: true, + }; + let result = convert_mouse_event(&event).unwrap(); + assert_eq!(result.column, 10); + assert_eq!(result.row, 20); + assert!(result.modifiers.contains(WasmKeyModifiers::SHIFT)); + } +} diff --git a/src/wasm/fs_backend.rs b/src/wasm/fs_backend.rs new file mode 100644 index 000000000..d89a50be8 --- /dev/null +++ b/src/wasm/fs_backend.rs @@ -0,0 +1,286 @@ +//! In-memory filesystem backend for WASM builds +//! +//! This module provides a virtual filesystem that runs entirely in memory, +//! suitable for browser environments where direct filesystem access is not available. + +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; +use std::time::SystemTime; + +/// Represents a file or directory entry in the virtual filesystem +#[derive(Debug, Clone)] +pub struct WasmFsEntry { + pub path: PathBuf, + pub name: String, + pub entry_type: WasmFsEntryType, + pub metadata: Option, +} + +/// Type of filesystem entry +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WasmFsEntryType { + File, + Directory, +} + +/// Metadata about a filesystem entry +#[derive(Debug, Clone)] +pub struct WasmFsMetadata { + pub size: Option, + pub modified: Option, +} + +impl WasmFsEntry { + pub fn new(path: PathBuf, name: String, entry_type: WasmFsEntryType) -> Self { + Self { + path, + name, + entry_type, + metadata: None, + } + } + + pub fn with_metadata(mut self, metadata: WasmFsMetadata) -> Self { + self.metadata = Some(metadata); + self + } + + pub fn is_dir(&self) -> bool { + self.entry_type == WasmFsEntryType::Directory + } + + pub fn is_file(&self) -> bool { + self.entry_type == WasmFsEntryType::File + } +} + +impl WasmFsMetadata { + pub fn new() -> Self { + Self { + size: None, + modified: None, + } + } + + pub fn with_size(mut self, size: u64) -> Self { + self.size = Some(size); + self + } +} + +impl Default for WasmFsMetadata { + fn default() -> Self { + Self::new() + } +} + +/// In-memory filesystem for WASM +/// +/// Files are stored in memory. Can be extended to support: +/// - IndexedDB for persistence +/// - File System Access API for local files (with user permission) +/// - Server API for remote file access +pub struct WasmFsBackend { + /// Virtual filesystem: path -> content + files: RwLock>>, + /// Directory structure: path -> child names + directories: RwLock>>, +} + +impl WasmFsBackend { + pub fn new() -> Self { + let mut dirs = HashMap::new(); + dirs.insert(PathBuf::from("/"), Vec::new()); + + Self { + files: RwLock::new(HashMap::new()), + directories: RwLock::new(dirs), + } + } + + /// Add a file to the virtual filesystem + pub fn add_file(&self, path: &Path, content: Vec) { + let mut files = self.files.write().unwrap(); + files.insert(path.to_path_buf(), content); + + // Update parent directory listing + if let Some(parent) = path.parent() { + let mut dirs = self.directories.write().unwrap(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + dirs.entry(parent.to_path_buf()) + .or_insert_with(Vec::new) + .push(name); + } + } + + /// Read file content + pub fn read_file(&self, path: &Path) -> Option> { + self.files.read().unwrap().get(path).cloned() + } + + /// Write file content + pub fn write_file(&self, path: &Path, content: Vec) { + self.add_file(path, content); + } + + /// Create a directory + pub fn create_dir(&self, path: &Path) { + let mut dirs = self.directories.write().unwrap(); + dirs.entry(path.to_path_buf()).or_insert_with(Vec::new); + + // Update parent directory + if let Some(parent) = path.parent() { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + dirs.entry(parent.to_path_buf()) + .or_insert_with(Vec::new) + .push(name); + } + } + + /// List entries in a directory + pub fn read_dir(&self, path: &Path) -> io::Result> { + let dirs = self.directories.read().unwrap(); + let files = self.files.read().unwrap(); + + let entries = dirs.get(path).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Directory not found") + })?; + + Ok(entries + .iter() + .map(|name| { + let full_path = path.join(name); + let entry_type = if files.contains_key(&full_path) { + WasmFsEntryType::File + } else { + WasmFsEntryType::Directory + }; + WasmFsEntry::new(full_path, name.clone(), entry_type) + }) + .collect()) + } + + /// Check if path exists + pub fn exists(&self, path: &Path) -> bool { + let files = self.files.read().unwrap(); + let dirs = self.directories.read().unwrap(); + files.contains_key(path) || dirs.contains_key(path) + } + + /// Check if path is a directory + pub fn is_dir(&self, path: &Path) -> bool { + let dirs = self.directories.read().unwrap(); + dirs.contains_key(path) + } + + /// Get file/directory entry + pub fn get_entry(&self, path: &Path) -> io::Result { + let files = self.files.read().unwrap(); + let dirs = self.directories.read().unwrap(); + + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + if files.contains_key(path) { + let size = files.get(path).map(|c| c.len() as u64); + let metadata = WasmFsMetadata::new().with_size(size.unwrap_or(0)); + Ok(WasmFsEntry::new(path.to_path_buf(), name, WasmFsEntryType::File) + .with_metadata(metadata)) + } else if dirs.contains_key(path) { + Ok(WasmFsEntry::new(path.to_path_buf(), name, WasmFsEntryType::Directory)) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "Not found")) + } + } + + /// Remove a file + pub fn remove_file(&self, path: &Path) -> io::Result<()> { + let mut files = self.files.write().unwrap(); + if files.remove(path).is_some() { + // Update parent directory + if let Some(parent) = path.parent() { + let mut dirs = self.directories.write().unwrap(); + if let Some(entries) = dirs.get_mut(parent) { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + entries.retain(|n| n != name); + } + } + } + Ok(()) + } else { + Err(io::Error::new(io::ErrorKind::NotFound, "File not found")) + } + } + + /// Get all file paths in the filesystem + pub fn list_all_files(&self) -> Vec { + self.files.read().unwrap().keys().cloned().collect() + } +} + +impl Default for WasmFsBackend { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_and_read_file() { + let fs = WasmFsBackend::new(); + let path = Path::new("/test.txt"); + let content = b"Hello, world!".to_vec(); + + fs.add_file(path, content.clone()); + + let read = fs.read_file(path).unwrap(); + assert_eq!(read, content); + } + + #[test] + fn test_directory_listing() { + let fs = WasmFsBackend::new(); + fs.add_file(Path::new("/file1.txt"), vec![]); + fs.add_file(Path::new("/file2.txt"), vec![]); + + let entries = fs.read_dir(Path::new("/")).unwrap(); + assert_eq!(entries.len(), 2); + } + + #[test] + fn test_exists() { + let fs = WasmFsBackend::new(); + let path = Path::new("/test.txt"); + + assert!(!fs.exists(path)); + fs.add_file(path, vec![]); + assert!(fs.exists(path)); + } + + #[test] + fn test_create_dir() { + let fs = WasmFsBackend::new(); + let path = Path::new("/subdir"); + + fs.create_dir(path); + assert!(fs.is_dir(path)); + } +} diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs new file mode 100644 index 000000000..996f21b60 --- /dev/null +++ b/src/wasm/mod.rs @@ -0,0 +1,564 @@ +//! WASM browser build module for Fresh editor +//! +//! This module provides the entry point for running Fresh in a web browser +//! using WebAssembly. It uses Ratzilla for browser-based terminal rendering. +//! +//! Uses the native PieceTree-based Buffer from model::buffer for maximum +//! code sharing with the native editor. + +pub mod event_adapter; +pub mod fs_backend; + +use ratzilla::ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::Style, + text::{Line, Span}, + widgets::Paragraph, + Frame, +}; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::prelude::*; + +#[allow(unused_imports)] +use crate::config::Config; +use crate::model::buffer::TextBuffer as NativeBuffer; +use crate::primitives::syntect_highlighter::SyntectHighlighter; +use crate::view::text_content::TextContentProvider; +use crate::view::theme::Theme; +use event_adapter::{WasmKeyCode, WasmKeyEvent, WasmKeyModifiers, WasmMouseButton, WasmMouseEvent, WasmMouseEventKind}; +#[allow(unused_imports)] +use fs_backend::WasmFsBackend; + +/// Editor buffer that wraps the native PieceTree-based Buffer with cursor tracking +/// +/// This provides a high-level editing API (insert_char, delete_char, cursor movement) +/// on top of the native Buffer's byte-offset operations. +struct EditorBuffer { + /// The native PieceTree-based buffer (shared with native editor) + buffer: NativeBuffer, + /// Cursor position as (line, column) - column is character-based, not byte-based + cursor: (usize, usize), + /// Viewport top line for scrolling + viewport_top: usize, + /// Whether the buffer has been modified + modified: bool, + /// File name (if any) + filename: Option, +} + +impl EditorBuffer { + fn new() -> Self { + Self { + buffer: NativeBuffer::empty(), + cursor: (0, 0), + viewport_top: 0, + modified: false, + filename: None, + } + } + + fn from_content(content: &str, filename: Option) -> Self { + let buffer = NativeBuffer::from_bytes(content.as_bytes().to_vec()); + Self { + buffer, + cursor: (0, 0), + viewport_top: 0, + modified: false, + filename, + } + } + + /// Convert cursor (line, column) to byte offset in the buffer + fn cursor_to_offset(&self) -> usize { + let (line, col) = self.cursor; + + // Get the byte offset of the start of the current line + let line_start = self.buffer.line_start_offset(line).unwrap_or(0); + + // Get the line content to convert column to byte offset + if let Some(text_line) = TextContentProvider::get_line(&self.buffer, line) { + // Convert character column to byte offset within line + let byte_col = text_line + .content + .char_indices() + .nth(col) + .map(|(i, _)| i) + .unwrap_or(text_line.content.len()); + line_start + byte_col + } else { + line_start + } + } + + fn insert_char(&mut self, ch: char) { + let offset = self.cursor_to_offset(); + let mut buf = [0u8; 4]; + let s = ch.encode_utf8(&mut buf); + self.buffer.insert(offset, s); + self.cursor.1 += 1; + self.modified = true; + } + + fn insert_newline(&mut self) { + let offset = self.cursor_to_offset(); + self.buffer.insert(offset, "\n"); + self.cursor = (self.cursor.0 + 1, 0); + self.modified = true; + } + + fn delete_char(&mut self) { + let (line, col) = self.cursor; + if col > 0 { + // Delete character before cursor on current line + let offset = self.cursor_to_offset(); + // Need to find the start of the previous character + if let Some(text_line) = TextContentProvider::get_line(&self.buffer, line) { + let byte_col_before = text_line + .content + .char_indices() + .nth(col - 1) + .map(|(i, _)| i) + .unwrap_or(0); + let line_start = self.buffer.line_start_offset(line).unwrap_or(0); + let delete_start = line_start + byte_col_before; + self.buffer.delete(delete_start..offset); + self.cursor.1 -= 1; + self.modified = true; + } + } else if line > 0 { + // Join with previous line (delete the newline at end of previous line) + if let Some(prev_line) = TextContentProvider::get_line(&self.buffer, line - 1) { + let prev_line_len = prev_line.content.chars().count(); + let line_start = self.buffer.line_start_offset(line).unwrap_or(0); + // Delete the newline character before this line + if line_start > 0 { + self.buffer.delete(line_start - 1..line_start); + self.cursor = (line - 1, prev_line_len); + self.modified = true; + } + } + } + } + + fn move_cursor(&mut self, dx: i32, dy: i32) { + let (line, col) = self.cursor; + let line_count = TextContentProvider::line_count(&self.buffer); + + let new_line = (line as i32 + dy).max(0) as usize; + let new_line = new_line.min(line_count.saturating_sub(1)); + + let line_len = TextContentProvider::get_line(&self.buffer, new_line) + .map(|l| l.content.chars().count()) + .unwrap_or(0); + + let new_col = if dy != 0 { + col.min(line_len) + } else { + (col as i32 + dx).max(0) as usize + }; + let new_col = new_col.min(line_len); + + self.cursor = (new_line, new_col); + } + + fn move_to_line_start(&mut self) { + self.cursor.1 = 0; + } + + fn move_to_line_end(&mut self) { + let (line, _) = self.cursor; + if let Some(text_line) = TextContentProvider::get_line(&self.buffer, line) { + self.cursor.1 = text_line.content.chars().count(); + } + } + + fn ensure_cursor_visible(&mut self, height: usize) { + let (line, _) = self.cursor; + if line < self.viewport_top { + self.viewport_top = line; + } else if line >= self.viewport_top + height { + self.viewport_top = line - height + 1; + } + } + + /// Get line count (delegate to buffer via TextContentProvider) + fn line_count(&self) -> usize { + TextContentProvider::line_count(&self.buffer) + } + + /// Get content as string (delegate to buffer via TextContentProvider) + fn content(&self) -> String { + TextContentProvider::content(&self.buffer) + } +} + +/// WASM Editor state +struct WasmEditorState { + buffer: EditorBuffer, + width: u16, + height: u16, + status_message: Option, + /// Shared theme from view module + theme: Theme, + /// Syntax highlighter (optional, based on file extension) + highlighter: Option, +} + +impl WasmEditorState { + fn new(width: u16, height: u16) -> Self { + // Use shared theme from view module - load from config theme name or default to dark + let theme = Theme::default(); + Self { + buffer: EditorBuffer::new(), + width, + height, + status_message: Some("Fresh Editor (WASM) - Press Ctrl+Q to quit".to_string()), + theme, + highlighter: None, + } + } + + fn handle_key(&mut self, event: event_adapter::WasmKeyEvent) -> bool { + use event_adapter::WasmKeyModifiers; + + match event.code { + WasmKeyCode::Char('q') if event.modifiers.contains(WasmKeyModifiers::CONTROL) => { + // Quit - signal to stop + return false; + } + WasmKeyCode::Char('s') if event.modifiers.contains(WasmKeyModifiers::CONTROL) => { + self.status_message = Some("Save not implemented in WASM demo".to_string()); + } + WasmKeyCode::Char(c) => { + self.buffer.insert_char(c); + self.status_message = None; + } + WasmKeyCode::Enter => { + self.buffer.insert_newline(); + self.status_message = None; + } + WasmKeyCode::Backspace => { + self.buffer.delete_char(); + self.status_message = None; + } + WasmKeyCode::Left => { + self.buffer.move_cursor(-1, 0); + } + WasmKeyCode::Right => { + self.buffer.move_cursor(1, 0); + } + WasmKeyCode::Up => { + self.buffer.move_cursor(0, -1); + } + WasmKeyCode::Down => { + self.buffer.move_cursor(0, 1); + } + WasmKeyCode::Home => { + self.buffer.move_to_line_start(); + } + WasmKeyCode::End => { + self.buffer.move_to_line_end(); + } + WasmKeyCode::PageUp => { + let height = self.height.saturating_sub(2) as i32; + self.buffer.move_cursor(0, -height); + } + WasmKeyCode::PageDown => { + let height = self.height.saturating_sub(2) as i32; + self.buffer.move_cursor(0, height); + } + _ => {} + } + true + } + + #[allow(dead_code)] + fn handle_mouse(&mut self, event: event_adapter::WasmMouseEvent) { + match event.kind { + WasmMouseEventKind::Down(_) => { + // Click to position cursor + let line = self.buffer.viewport_top + event.row as usize; + let col = event.column as usize; + + // Clamp to valid range + let line_count = self.buffer.line_count(); + let line = line.min(line_count.saturating_sub(1)); + + let line_len = TextContentProvider::get_line(&self.buffer.buffer, line) + .map(|l| l.content.chars().count()) + .unwrap_or(0); + let col = col.min(line_len); + + self.buffer.cursor = (line, col); + } + WasmMouseEventKind::ScrollDown => { + self.buffer.move_cursor(0, 3); + } + WasmMouseEventKind::ScrollUp => { + self.buffer.move_cursor(0, -3); + } + _ => {} + } + } + + fn render(&mut self, frame: &mut Frame<'_>) { + let size = frame.area(); + let height = size.height.saturating_sub(2) as usize; // Leave room for status bar + + // Ensure cursor is visible + self.buffer.ensure_cursor_visible(height); + + // Create layout: main area + status bar + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(size); + + // Render the text area with syntax highlighting + self.render_text_area(frame, chunks[0]); + + // Render status bar + self.render_status_bar(frame, chunks[1]); + } + + fn render_text_area(&mut self, frame: &mut Frame<'_>, area: Rect) { + let height = area.height as usize; + let line_count = self.buffer.line_count(); + + // Render visible lines + let mut lines: Vec> = Vec::new(); + for i in 0..height { + let line_idx = self.buffer.viewport_top + i; + if line_idx >= line_count { + // Empty line beyond content + lines.push(Line::from(Span::styled("~", Style::default().fg(self.theme.syntax_comment)))); + } else if let Some(text_line) = TextContentProvider::get_line(&self.buffer.buffer, line_idx) { + // Simple rendering without syntax highlighting for now + // TODO: Add syntax highlighting using SyntectHighlighter::highlight_viewport + lines.push(Line::from(text_line.content.clone())); + } else { + lines.push(Line::from("")); + } + } + + let paragraph = Paragraph::new(lines) + .style(Style::default().fg(self.theme.editor_fg).bg(self.theme.editor_bg)); + frame.render_widget(paragraph, area); + + // Render cursor (simple block cursor) + let (cursor_line, cursor_col) = self.buffer.cursor; + if cursor_line >= self.buffer.viewport_top + && cursor_line < self.buffer.viewport_top + height + { + let cursor_y = area.y + (cursor_line - self.buffer.viewport_top) as u16; + let cursor_x = area.x + cursor_col as u16; + if cursor_x < area.x + area.width && cursor_y < area.y + area.height { + frame.set_cursor_position((cursor_x, cursor_y)); + } + } + } + + fn render_status_bar(&self, frame: &mut Frame<'_>, area: Rect) { + let (line, col) = self.buffer.cursor; + let modified = if self.buffer.modified { "[+]" } else { "" }; + let filename = self + .buffer + .filename + .as_deref() + .unwrap_or("[No Name]"); + + let status_left = format!(" {} {} ", filename, modified); + let status_right = format!(" {}:{} ", line + 1, col + 1); + + // Message or position + let message = self + .status_message + .as_deref() + .unwrap_or(""); + + let status = format!( + "{}{}{}{}", + status_left, + message, + " ".repeat( + area.width + .saturating_sub(status_left.len() as u16) + .saturating_sub(status_right.len() as u16) + .saturating_sub(message.len() as u16) as usize + ), + status_right + ); + + // Use inverted colors for status bar + let status_style = Style::default() + .fg(self.theme.editor_bg) + .bg(self.theme.editor_fg); + + let paragraph = Paragraph::new(status).style(status_style); + frame.render_widget(paragraph, area); + } +} + +/// WASM-exported editor handle +#[wasm_bindgen] +pub struct WasmEditor { + state: Rc>>, +} + +#[wasm_bindgen] +impl WasmEditor { + /// Create a new WASM editor instance + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + // Set up panic hook for better error messages + console_error_panic_hook::set_once(); + + Self { + state: Rc::new(RefCell::new(None)), + } + } + + /// Initialize the editor with the given terminal size + pub fn init(&self, width: u16, height: u16) { + let mut state = self.state.borrow_mut(); + *state = Some(WasmEditorState::new(width, height)); + } + + /// Load content into the editor + pub fn load_content(&self, content: &str, filename: Option) { + let mut state = self.state.borrow_mut(); + if let Some(ref mut s) = *state { + s.buffer = EditorBuffer::from_content(content, filename.clone()); + // Try to set up syntax highlighter based on filename extension + if let Some(ref name) = filename { + let path = std::path::Path::new(name); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + s.highlighter = SyntectHighlighter::for_extension(ext); + } + } + s.status_message = Some(format!( + "Loaded: {}", + filename.as_deref().unwrap_or("[No Name]") + )); + } + } + + /// Get the current content + pub fn get_content(&self) -> String { + let state = self.state.borrow(); + if let Some(ref s) = *state { + s.buffer.content() + } else { + String::new() + } + } + + /// Handle a key event (returns false if editor should quit) + pub fn handle_key( + &self, + key: &str, + ctrl: bool, + alt: bool, + shift: bool, + ) -> bool { + let mut state = self.state.borrow_mut(); + if let Some(ref mut s) = *state { + // Convert string key to WasmKeyCode + let code = match key { + "Enter" => WasmKeyCode::Enter, + "Tab" => WasmKeyCode::Tab, + "Backspace" => WasmKeyCode::Backspace, + "Delete" => WasmKeyCode::Delete, + "Escape" => WasmKeyCode::Esc, + "ArrowUp" => WasmKeyCode::Up, + "ArrowDown" => WasmKeyCode::Down, + "ArrowLeft" => WasmKeyCode::Left, + "ArrowRight" => WasmKeyCode::Right, + "Home" => WasmKeyCode::Home, + "End" => WasmKeyCode::End, + "PageUp" => WasmKeyCode::PageUp, + "PageDown" => WasmKeyCode::PageDown, + s if s.len() == 1 => WasmKeyCode::Char(s.chars().next().unwrap()), + _ => return true, // Unknown key, ignore + }; + + let mut modifiers = WasmKeyModifiers::NONE; + if ctrl { + modifiers |= WasmKeyModifiers::CONTROL; + } + if alt { + modifiers |= WasmKeyModifiers::ALT; + } + if shift { + modifiers |= WasmKeyModifiers::SHIFT; + } + + let event = WasmKeyEvent::new(code, modifiers); + s.handle_key(event) + } else { + true + } + } + + /// Handle a mouse event + pub fn handle_mouse(&self, kind: &str, row: u16, column: u16) { + let mut state = self.state.borrow_mut(); + if let Some(ref mut s) = *state { + let mouse_kind = match kind { + "mousedown" => WasmMouseEventKind::Down(WasmMouseButton::Left), + "mouseup" => WasmMouseEventKind::Up(WasmMouseButton::Left), + "mousemove" => WasmMouseEventKind::Moved, + "wheel_up" | "scrollup" => WasmMouseEventKind::ScrollUp, + "wheel_down" | "scrolldown" => WasmMouseEventKind::ScrollDown, + _ => return, // Unknown event type + }; + + let event = WasmMouseEvent { + kind: mouse_kind, + column, + row, + modifiers: WasmKeyModifiers::NONE, + }; + s.handle_mouse(event); + } + } + + /// Resize the editor + pub fn resize(&self, width: u16, height: u16) { + let mut state = self.state.borrow_mut(); + if let Some(ref mut s) = *state { + s.width = width; + s.height = height; + } + } + + /// Check if the buffer has been modified + pub fn is_modified(&self) -> bool { + let state = self.state.borrow(); + if let Some(ref s) = *state { + s.buffer.modified + } else { + false + } + } +} + +impl Default for WasmEditor { + fn default() -> Self { + Self::new() + } +} + +/// Main entry point for WASM - starts the editor +#[wasm_bindgen(start)] +pub fn wasm_main() { + // Set up panic hook for better error messages + console_error_panic_hook::set_once(); + + // Log startup + web_sys::console::log_1(&"Fresh Editor WASM module loaded".into()); +} + +// Note: run_editor using Ratzilla's WebRenderer is not currently working +// Use the WasmEditor struct directly from JavaScript instead diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 000000000..b4465c531 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,193 @@ +/* Fresh Editor WASM Styles */ + +:root { + --bg-color: #1e1e1e; + --fg-color: #d4d4d4; + --accent-color: #569cd6; + --error-color: #f14c4c; + --success-color: #23d18b; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + background-color: var(--bg-color); + color: var(--fg-color); + font-family: 'Fira Code', 'Fira Mono', 'Consolas', 'Monaco', 'Liberation Mono', 'Courier New', monospace; +} + +/* Loading screen */ +.loading { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--bg-color); + z-index: 1000; +} + +.loading-content { + text-align: center; +} + +.loading-content p { + margin-top: 16px; + font-size: 14px; + color: var(--fg-color); +} + +.spinner { + width: 40px; + height: 40px; + margin: 0 auto; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Error screen */ +.error-content { + text-align: center; + padding: 20px; +} + +.error-content h2 { + color: var(--error-color); + margin-bottom: 16px; +} + +.error-content p { + margin-bottom: 16px; + color: var(--fg-color); +} + +.error-content button { + padding: 8px 16px; + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 14px; +} + +.error-content button:hover { + opacity: 0.9; +} + +/* NoScript message */ +.noscript-message { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + text-align: center; + padding: 20px; +} + +.noscript-message h1 { + color: var(--error-color); + margin-bottom: 16px; +} + +.noscript-message p { + margin-bottom: 8px; +} + +/* Ratzilla terminal container */ +#ratzilla-terminal, +.ratzilla-terminal, +canvas { + width: 100% !important; + height: 100% !important; + display: block; +} + +/* Terminal text styling */ +.ratzilla-terminal { + font-family: 'Fira Code', 'Fira Mono', 'Consolas', 'Monaco', 'Liberation Mono', 'Courier New', monospace; + font-size: 14px; + line-height: 1.4; + background-color: var(--bg-color); + color: var(--fg-color); +} + +/* Ensure proper focus handling */ +.ratzilla-terminal:focus, +canvas:focus { + outline: none; +} + +/* Cursor blinking animation (if using DOM backend) */ +.cursor-blink { + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--bg-color); +} + +::-webkit-scrollbar-thumb { + background: #444; + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* Selection styling */ +::selection { + background-color: rgba(86, 156, 214, 0.4); +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .ratzilla-terminal { + font-size: 12px; + } +} + +@media (max-width: 480px) { + .ratzilla-terminal { + font-size: 10px; + } +} + +/* Print styles */ +@media print { + .loading, + .spinner { + display: none; + } +}