├── docs ├── nostalgia.jpeg ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png ├── visual-regression │ └── tests │ │ ├── Comprehensive_UI_B.md │ │ └── Comprehensive_UI_A.md ├── internal │ ├── README.md │ └── MARKDOWN.md └── fresh.txt ├── tests ├── e2e_tests.rs ├── fixtures │ ├── syntax_highlighting │ │ ├── hello.rb │ │ ├── hello.sh │ │ ├── hello.cs │ │ ├── hello.js │ │ ├── hello.css │ │ ├── hello.java │ │ ├── hello.json │ │ ├── hello.php │ │ ├── hello.rs │ │ ├── hello.c │ │ ├── hello.py │ │ ├── hello.go │ │ ├── hello.cpp │ │ ├── hello.html │ │ ├── hello.lua │ │ └── hello.ts │ ├── scroll_test_file.txt │ └── markdown_sample.md ├── common │ ├── mod.rs │ ├── tracing.rs │ ├── snapshots │ │ ├── e2e_tests__common__visual_testing__Comprehensive UI A__state_a.snap │ │ └── e2e_tests__common__visual_testing__Comprehensive UI B__state_b.snap │ └── fixtures.rs ├── property_tests.proptest-regressions ├── e2e │ ├── lifecycle.rs │ ├── mod.rs │ ├── position_history_debug.rs │ ├── ansi_cursor.rs │ ├── position_history_truncate_debug.rs │ ├── terminal_resize.rs │ └── unicode_cursor.rs ├── harness_test.rs ├── test_overlay_colors.rs ├── focused_bug_test.rs ├── merge_parser_test.js ├── property_tests.rs └── test_line_iterator_comprehensive.rs ├── scripts ├── landscape.png ├── landscape-wide.png ├── png_to_ansi.py ├── record-demo.sh └── release.sh ├── .config └── nextest.toml ├── src ├── services │ ├── lsp │ │ └── mod.rs │ ├── plugins │ │ └── mod.rs │ ├── mod.rs │ ├── fs │ │ └── mod.rs │ └── signal_handler.rs ├── input │ └── mod.rs ├── lib.rs ├── view │ ├── mod.rs │ ├── file_tree │ │ ├── mod.rs │ │ └── node.rs │ ├── ui │ │ └── mod.rs │ └── stream.rs ├── model │ ├── mod.rs │ └── edit.rs ├── primitives │ ├── mod.rs │ └── ansi_background.rs ├── app │ └── help.rs ├── ts_bootstrap.js └── v8_init.rs ├── queries ├── css │ └── indents.scm ├── json │ └── indents.scm ├── html │ └── indents.scm ├── ruby │ └── indents.scm ├── lua │ └── indents.scm ├── bash │ └── indents.scm ├── c │ └── indents.scm ├── java │ └── indents.scm ├── cpp │ └── indents.scm ├── php │ └── indents.scm ├── go │ └── indents.scm ├── python │ └── indents.scm ├── rust │ └── indents.scm ├── javascript │ └── indents.scm └── typescript │ └── indents.scm ├── proptest-regressions ├── text_buffer.txt ├── word_navigation.txt ├── piece_tree.txt ├── marker.txt └── buffer.txt ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── keymaps └── vscode.json ├── plugins ├── lib │ ├── index.ts │ ├── types.ts │ ├── virtual-buffer-factory.ts │ ├── navigation-controller.ts │ └── panel-manager.ts ├── clangd_support.md ├── examples │ ├── README.md │ ├── hello_world.ts │ ├── buffer_query_demo.ts │ ├── virtual_buffer_demo.ts │ └── async_demo.ts ├── welcome.ts ├── README.md ├── path_complete.ts └── git_grep.ts ├── dist-workspace.toml ├── examples ├── test_todo_comments.txt └── script_mode_demo.sh ├── .gitignore ├── themes ├── dark.json ├── light.json ├── high-contrast.json ├── solarized-dark.json ├── nord.json └── dracula.json ├── config.example.json ├── README.md ├── Cargo.toml └── types └── fresh.d.ts.template /docs/nostalgia.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/docs/nostalgia.jpeg -------------------------------------------------------------------------------- /tests/e2e_tests.rs: -------------------------------------------------------------------------------- 1 | // End-to-end tests for the editor 2 | mod common; 3 | mod e2e; 4 | -------------------------------------------------------------------------------- /docs/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/docs/screenshot1.png -------------------------------------------------------------------------------- /docs/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/docs/screenshot2.png -------------------------------------------------------------------------------- /docs/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/docs/screenshot3.png -------------------------------------------------------------------------------- /scripts/landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/scripts/landscape.png -------------------------------------------------------------------------------- /scripts/landscape-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fcoury/fresh/master/scripts/landscape-wide.png -------------------------------------------------------------------------------- /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | # .config/nextest.toml 2 | [profile.default] 3 | 4 | slow-timeout = { period = "2s", terminate-after = 20 } 5 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | def greet(name) 4 | puts "Hello, #{name}!" 5 | end 6 | 7 | greet("World") 8 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function greet() { 4 | echo "Hello, $1!" 5 | } 6 | 7 | MESSAGE="World" 8 | greet "$MESSAGE" 9 | -------------------------------------------------------------------------------- /src/services/lsp/mod.rs: -------------------------------------------------------------------------------- 1 | //! LSP (Language Server Protocol) integration 2 | 3 | pub mod async_handler; 4 | pub mod client; 5 | pub mod diagnostics; 6 | pub mod manager; 7 | -------------------------------------------------------------------------------- /src/services/plugins/mod.rs: -------------------------------------------------------------------------------- 1 | //! Plugin system 2 | 3 | pub mod api; 4 | pub mod event_hooks; 5 | pub mod hooks; 6 | pub mod process; 7 | pub mod runtime; 8 | pub mod thread; 9 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | class Hello { 4 | static void Main() { 5 | Console.WriteLine("Hello, World!"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.js: -------------------------------------------------------------------------------- 1 | function greet(name) { 2 | return `Hello, ${name}!`; 3 | } 4 | 5 | const message = greet("World"); 6 | console.log(message); 7 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.css: -------------------------------------------------------------------------------- 1 | .message { 2 | color: blue; 3 | font-size: 16px; 4 | margin: 10px; 5 | } 6 | 7 | h1 { 8 | color: red; 9 | } 10 | -------------------------------------------------------------------------------- /queries/css/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent inside blocks 2 | [ 3 | (block) 4 | (keyframe_block_list) 5 | ] @indent 6 | 7 | ; Dedent closing braces 8 | [ 9 | "}" 10 | ] @dedent 11 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.java: -------------------------------------------------------------------------------- 1 | public class Hello { 2 | public static void main(String[] args) { 3 | System.out.println("Hello, World!"); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.json: -------------------------------------------------------------------------------- 1 | { 2 | "greeting": "Hello, World!", 3 | "languages": ["Rust", "Python", "JavaScript"], 4 | "count": 42, 5 | "enabled": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.php: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /queries/json/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent inside objects and arrays 2 | [ 3 | (object) 4 | (array) 5 | ] @indent 6 | 7 | ; Dedent closing delimiters 8 | [ 9 | "}" 10 | "]" 11 | ] @dedent 12 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.rs: -------------------------------------------------------------------------------- 1 | fn greet(name: &str) -> String { 2 | format!("Hello, {}!", name) 3 | } 4 | 5 | fn main() { 6 | let message = greet("World"); 7 | println!("{}", message); 8 | } 9 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | void greet(const char* name) { 4 | printf("Hello, %s!\n", name); 5 | } 6 | 7 | int main() { 8 | greet("World"); 9 | return 0; 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | def greet(name: str) -> str: 4 | return f"Hello, {name}!" 5 | 6 | if __name__ == "__main__": 7 | message = greet("World") 8 | print(message) 9 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func greet(name string) string { 6 | return fmt.Sprintf("Hello, %s!", name) 7 | } 8 | 9 | func main() { 10 | message := greet("World") 11 | fmt.Println(message) 12 | } 13 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | void greet(const std::string& name) { 5 | std::cout << "Hello, " << name << "!" << std::endl; 6 | } 7 | 8 | int main() { 9 | greet("World"); 10 | return 0; 11 | } 12 | -------------------------------------------------------------------------------- /queries/html/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent inside elements 2 | (element) @indent 3 | 4 | ; Indent inside script and style tags 5 | [ 6 | (script_element) 7 | (style_element) 8 | ] @indent 9 | 10 | ; Dedent closing tags (handled by element end) 11 | [ 12 | (end_tag) 13 | ] @dedent 14 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World 6 | 7 | 8 |

Hello, World!

9 |

Welcome

10 | 11 | 12 | -------------------------------------------------------------------------------- /queries/ruby/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after blocks and control flow 2 | [ 3 | (method) 4 | (class) 5 | (module) 6 | (if) 7 | (unless) 8 | (case) 9 | (when) 10 | (while) 11 | (until) 12 | (for) 13 | (do_block) 14 | (begin) 15 | ] @indent 16 | 17 | ; Dedent on end keyword 18 | [ 19 | "end" 20 | ] @dedent 21 | -------------------------------------------------------------------------------- /src/input/mod.rs: -------------------------------------------------------------------------------- 1 | //! Input pipeline 2 | //! 3 | //! This module handles the input-to-action-to-event translation. 4 | 5 | pub mod actions; 6 | pub mod buffer_mode; 7 | pub mod command_registry; 8 | pub mod commands; 9 | pub mod fuzzy; 10 | pub mod input_history; 11 | pub mod keybindings; 12 | pub mod multi_cursor; 13 | pub mod position_history; 14 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Editor library - exposes all core modules for testing 2 | 3 | pub mod v8_init; 4 | 5 | // Core modules at root level 6 | pub mod config; 7 | pub mod session; 8 | pub mod state; 9 | 10 | // Organized modules 11 | pub mod app; 12 | pub mod input; 13 | pub mod model; 14 | pub mod primitives; 15 | pub mod services; 16 | pub mod view; 17 | -------------------------------------------------------------------------------- /src/view/mod.rs: -------------------------------------------------------------------------------- 1 | //! View and UI layer 2 | //! 3 | //! This module contains all presentation and rendering components. 4 | 5 | pub mod file_tree; 6 | pub mod margin; 7 | pub mod overlay; 8 | pub mod popup; 9 | pub mod prompt; 10 | pub mod split; 11 | pub mod stream; 12 | pub mod theme; 13 | pub mod ui; 14 | pub mod viewport; 15 | pub mod virtual_text; 16 | -------------------------------------------------------------------------------- /tests/common/mod.rs: -------------------------------------------------------------------------------- 1 | // Common test utilities 2 | 3 | pub mod fake_lsp; 4 | pub mod fixtures; 5 | pub mod git_test_helper; 6 | pub mod harness; 7 | pub mod tracing; 8 | pub mod visual_testing; 9 | 10 | // Note: Visual regression tests write their own documentation files independently. 11 | // No destructor needed - each test is self-contained and parallel-safe. 12 | -------------------------------------------------------------------------------- /src/services/mod.rs: -------------------------------------------------------------------------------- 1 | //! Asynchronous services and external integrations 2 | //! 3 | //! This module contains all code that deals with external processes, 4 | //! I/O, and async operations. 5 | 6 | pub mod async_bridge; 7 | pub mod clipboard; 8 | pub mod fs; 9 | pub mod lsp; 10 | pub mod plugins; 11 | pub mod process_limits; 12 | pub mod recovery; 13 | pub mod signal_handler; 14 | -------------------------------------------------------------------------------- /docs/visual-regression/tests/Comprehensive_UI_B.md: -------------------------------------------------------------------------------- 1 | # Comprehensive UI B 2 | 3 | **Category**: Visual Regression 4 | 5 | *Command palette open, split view, line wrap off, horizontal scroll* 6 | 7 | --- 8 | 9 | ## Step 1: state_b 10 | 11 | ![state_b](../screenshots/Comprehensive_UI_B_01_state_b.svg) 12 | 13 | *Split view + command palette open + line wrap off + horizontal scroll* 14 | 15 | -------------------------------------------------------------------------------- /docs/visual-regression/tests/Comprehensive_UI_A.md: -------------------------------------------------------------------------------- 1 | # Comprehensive UI A 2 | 3 | **Category**: Visual Regression 4 | 5 | *File explorer open, line wrap on, multicursors, diagnostics, scrolled* 6 | 7 | --- 8 | 9 | ## Step 1: state_a 10 | 11 | ![state_a](../screenshots/Comprehensive_UI_A_01_state_a.svg) 12 | 13 | *File explorer open + syntax highlighting + diagnostics + multicursors + scrolled* 14 | 15 | -------------------------------------------------------------------------------- /queries/lua/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening blocks 2 | [ 3 | (block) 4 | (function_definition) 5 | (function_declaration) 6 | (if_statement) 7 | (for_statement) 8 | (while_statement) 9 | (repeat_statement) 10 | (do_statement) 11 | (table_constructor) 12 | ] @indent 13 | 14 | ; Dedent closing delimiters 15 | [ 16 | "end" 17 | "}" 18 | "]" 19 | ")" 20 | "until" 21 | ] @dedent 22 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! Core data model for documents 2 | //! 3 | //! This module contains pure data structures with minimal external dependencies. 4 | 5 | pub mod buffer; 6 | pub mod control_event; 7 | pub mod cursor; 8 | pub mod document_model; 9 | pub mod edit; 10 | pub mod event; 11 | pub mod line_diff; 12 | pub mod marker; 13 | pub mod marker_tree; 14 | pub mod piece_tree; 15 | pub mod piece_tree_diff; 16 | -------------------------------------------------------------------------------- /queries/bash/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after control structures 2 | [ 3 | (if_statement) 4 | (while_statement) 5 | (for_statement) 6 | (case_statement) 7 | (case_item) 8 | (function_definition) 9 | (compound_statement) 10 | ] @indent 11 | 12 | ; Indent inside do...done, then...fi 13 | [ 14 | (do_group) 15 | ] @indent 16 | 17 | ; Dedent on closing keywords 18 | [ 19 | "done" 20 | "fi" 21 | "esac" 22 | ] @dedent 23 | -------------------------------------------------------------------------------- /tests/property_tests.proptest-regressions: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 827a95a1045c47f9e01438c0c267154e232a233440e0e603010a9cdb54ef9524 # shrinks to ops = [TypeChar('{'), Enter] 8 | -------------------------------------------------------------------------------- /tests/e2e/lifecycle.rs: -------------------------------------------------------------------------------- 1 | use crate::common::harness::EditorTestHarness; 2 | 3 | /// Test that editor doesn't quit prematurely 4 | #[test] 5 | fn test_editor_lifecycle() { 6 | let harness = EditorTestHarness::new(80, 24).unwrap(); 7 | 8 | // New editor should not want to quit 9 | assert!(!harness.should_quit()); 10 | 11 | // TODO: When action_to_events() is implemented: 12 | // - Send quit command 13 | // - Verify should_quit() returns true 14 | } 15 | -------------------------------------------------------------------------------- /tests/common/tracing.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Once; 2 | 3 | /// Initialize the global tracing subscriber once (used by tests that run with `RUST_LOG`). 4 | pub fn init_tracing_from_env() { 5 | static INIT: Once = Once::new(); 6 | INIT.call_once(|| { 7 | let subscriber = tracing_subscriber::fmt() 8 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) 9 | .with_writer(std::io::stdout); 10 | let _ = subscriber.try_init(); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/primitives/mod.rs: -------------------------------------------------------------------------------- 1 | //! Low-level primitives and utilities 2 | //! 3 | //! This module contains syntax highlighting, ANSI handling, 4 | //! and text manipulation utilities. 5 | 6 | pub mod ansi; 7 | pub mod ansi_background; 8 | pub mod grammar_registry; 9 | pub mod highlight_engine; 10 | pub mod highlighter; 11 | pub mod indent; 12 | pub mod line_iterator; 13 | pub mod line_wrapping; 14 | pub mod semantic_highlight; 15 | pub mod text_property; 16 | pub mod textmate_highlighter; 17 | pub mod word_navigation; 18 | -------------------------------------------------------------------------------- /queries/c/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces 2 | [ 3 | (compound_statement) 4 | (struct_specifier) 5 | (enum_specifier) 6 | (field_declaration_list) 7 | (enumerator_list) 8 | (initializer_list) 9 | ] @indent 10 | 11 | ; Indent after control flow 12 | [ 13 | (if_statement) 14 | (for_statement) 15 | (while_statement) 16 | (do_statement) 17 | (switch_statement) 18 | (case_statement) 19 | ] @indent 20 | 21 | ; Dedent closing delimiters 22 | [ 23 | "}" 24 | "]" 25 | ")" 26 | ] @dedent 27 | -------------------------------------------------------------------------------- /src/view/file_tree/mod.rs: -------------------------------------------------------------------------------- 1 | // File tree module for lazy-loaded directory hierarchy 2 | // 3 | // This module provides a tree structure for representing filesystem hierarchies 4 | // with lazy loading (directories are only read when expanded) and efficient 5 | // navigation. 6 | 7 | pub mod ignore; 8 | pub mod node; 9 | pub mod tree; 10 | pub mod view; 11 | 12 | pub use ignore::{IgnorePatterns, IgnoreStatus}; 13 | pub use node::{NodeId, NodeState, TreeNode}; 14 | pub use tree::FileTree; 15 | pub use view::{FileTreeView, SortMode}; 16 | -------------------------------------------------------------------------------- /proptest-regressions/text_buffer.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 41fcbb9f2acc639c975b7bac026ee3f64c56a987180101b1a6767c7cf537a3a2 # shrinks to operations = [Delete { offset: 0, bytes: 12 }, Insert { offset: 0, text: [97] }, Insert { offset: 0, text: [98] }] 8 | -------------------------------------------------------------------------------- /src/services/fs/mod.rs: -------------------------------------------------------------------------------- 1 | // Filesystem abstraction layer for async, pluggable file system access 2 | // 3 | // This module provides a clean abstraction over filesystem operations, 4 | // designed to work efficiently with both local and network filesystems. 5 | 6 | pub mod backend; 7 | pub mod local; 8 | pub mod manager; 9 | pub mod slow; 10 | 11 | pub use backend::{FsBackend, FsEntry, FsEntryType, FsMetadata}; 12 | pub use local::LocalFsBackend; 13 | pub use manager::FsManager; 14 | pub use slow::{BackendMetrics, SlowFsBackend, SlowFsConfig}; 15 | -------------------------------------------------------------------------------- /proptest-regressions/word_navigation.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc b73f9df7c74e71b29a4faec20e0a4a2c3cb34b2bc6f3ca5f96318f3db2eaacdd # shrinks to s = ",a", pos = 2 8 | cc 4eb786123fd944e76736d6d15089cc7c1f85aeec014cf0ea40421de8ef6139db # shrinks to s = " ,", pos = 2 9 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | -- Greeting function with string formatting 4 | function greet(name) 5 | return string.format("Hello, %s!", name) 6 | end 7 | 8 | -- Table with mixed content 9 | local config = { 10 | version = "1.0", 11 | enabled = true, 12 | count = 42 13 | } 14 | 15 | -- Main execution with conditional logic 16 | if arg then 17 | local message = greet("World") 18 | print(message) 19 | 20 | for i = 1, 3 do 21 | print("Iteration: " .. i) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /queries/java/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces 2 | [ 3 | (block) 4 | (class_body) 5 | (interface_body) 6 | (enum_body) 7 | (array_initializer) 8 | (switch_block) 9 | ] @indent 10 | 11 | ; Indent after control flow 12 | [ 13 | (if_statement) 14 | (for_statement) 15 | (enhanced_for_statement) 16 | (while_statement) 17 | (do_statement) 18 | (try_statement) 19 | (catch_clause) 20 | (finally_clause) 21 | (switch_block_statement_group) 22 | ] @indent 23 | 24 | ; Dedent closing delimiters 25 | [ 26 | "}" 27 | "]" 28 | ")" 29 | ] @dedent 30 | -------------------------------------------------------------------------------- /src/app/help.rs: -------------------------------------------------------------------------------- 1 | //! Built-in help manual support 2 | //! 3 | //! This module provides the embedded help manual that is bundled into the binary 4 | //! at compile time using `include_str!()`. 5 | 6 | /// The embedded help manual content (bundled at compile time) 7 | pub const HELP_MANUAL_CONTENT: &str = include_str!("../../docs/fresh.txt"); 8 | 9 | /// The name of the help manual buffer 10 | pub const HELP_MANUAL_BUFFER_NAME: &str = "*Fresh Manual*"; 11 | 12 | /// The name of the keyboard shortcuts buffer 13 | pub const KEYBOARD_SHORTCUTS_BUFFER_NAME: &str = "*Keyboard Shortcuts*"; 14 | -------------------------------------------------------------------------------- /proptest-regressions/piece_tree.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 0e8c25cc2c3b6427fbfd79f7aaf992b54ceb8b8b7c12a27667043ccb0b5d1af9 # shrinks to ops = [(0, [97, 97, 97, 97, 97, 97, 97, 97], true), (0, [97], true), (6, [97, 97, 97, 97, 97, 97, 97, 10, 97, 97, 97], true), (0, [97], true), (0, [97], true)], test_offsets = [19, 0, 0] 8 | -------------------------------------------------------------------------------- /queries/cpp/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces 2 | [ 3 | (compound_statement) 4 | (struct_specifier) 5 | (class_specifier) 6 | (enum_specifier) 7 | (field_declaration_list) 8 | (enumerator_list) 9 | (initializer_list) 10 | ] @indent 11 | 12 | ; Indent after control flow 13 | [ 14 | (if_statement) 15 | (for_statement) 16 | (for_range_loop) 17 | (while_statement) 18 | (do_statement) 19 | (switch_statement) 20 | (case_statement) 21 | (try_statement) 22 | (catch_clause) 23 | ] @indent 24 | 25 | ; Indent namespaces 26 | (namespace_definition) @indent 27 | 28 | ; Dedent closing delimiters 29 | [ 30 | "}" 31 | "]" 32 | ")" 33 | ] @dedent 34 | -------------------------------------------------------------------------------- /queries/php/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces 2 | [ 3 | (compound_statement) 4 | (switch_block) 5 | (declaration_list) 6 | (class_declaration) 7 | (interface_declaration) 8 | (trait_declaration) 9 | ] @indent 10 | 11 | ; Indent after control flow 12 | [ 13 | (if_statement) 14 | (for_statement) 15 | (foreach_statement) 16 | (while_statement) 17 | (do_statement) 18 | (switch_statement) 19 | (case_statement) 20 | (try_statement) 21 | (catch_clause) 22 | (finally_clause) 23 | ] @indent 24 | 25 | ; Indent arrays 26 | [ 27 | (array_creation_expression) 28 | ] @indent 29 | 30 | ; Dedent closing delimiters 31 | [ 32 | "}" 33 | "]" 34 | ")" 35 | ] @dedent 36 | -------------------------------------------------------------------------------- /docs/internal/README.md: -------------------------------------------------------------------------------- 1 | # Internal Documentation 2 | 3 | This directory contains pending work tracking. Completed plans have been removed. 4 | 5 | ## Documents 6 | 7 | | Document | Description | 8 | |----------|-------------| 9 | | [MARKDOWN.md](MARKDOWN.md) | Markdown compose mode remaining work | 10 | | [CR.md](CR.md) | Code quality improvements to address | 11 | 12 | ## User-Facing Documentation 13 | 14 | See the parent [docs/](../) directory: 15 | - [USER_GUIDE.md](../USER_GUIDE.md) - User guide 16 | - [PLUGIN_DEVELOPMENT.md](../PLUGIN_DEVELOPMENT.md) - Plugin development guide 17 | - [plugin-api.md](../plugin-api.md) - Full plugin API reference 18 | - [ARCHITECTURE.md](../ARCHITECTURE.md) - System architecture 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for Cargo 9 | - package-ecosystem: "cargo" 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "weekly" 13 | # Maintain dependencies for GitHub Actions 14 | - package-ecosystem: github-actions 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | -------------------------------------------------------------------------------- /queries/go/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces 2 | [ 3 | (block) 4 | (literal_value) 5 | (struct_type) 6 | (interface_type) 7 | (field_declaration_list) 8 | (interface_type) 9 | ] @indent 10 | 11 | ; Indent after control flow 12 | [ 13 | (if_statement) 14 | (for_statement) 15 | (expression_switch_statement) 16 | (type_switch_statement) 17 | (select_statement) 18 | (communication_case) 19 | (expression_case) 20 | (type_case) 21 | (default_case) 22 | ] @indent 23 | 24 | ; Indent function declarations 25 | [ 26 | (function_declaration) 27 | (method_declaration) 28 | (func_literal) 29 | ] @indent 30 | 31 | ; Dedent closing delimiters 32 | [ 33 | "}" 34 | "]" 35 | ")" 36 | ] @dedent 37 | -------------------------------------------------------------------------------- /proptest-regressions/marker.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 800720d432810ce520ffc73b285a7899f2b7926c8cd929be3a1707024f8f86a3 # shrinks to buffer_size = 10, ops = [Insert { position: 0, length: 1 }] 8 | cc 7185e67428aa7c251619c63ca88385724b93b79668991412cecef13cba21e95f # shrinks to buffer_size = 10, ops = [Insert { position: 0, length: 1 }] 9 | cc 0aee6336bd312adcac2e3e8d0dbbdb2a2fbaeb88fdd516c42ba23effa349ae4a # shrinks to initial_spacing = 10, ops = [Delete { position: 5, length: 16 }] 10 | -------------------------------------------------------------------------------- /proptest-regressions/buffer.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc ae6d01db8e69ca7398be8a06ecdb9ac144d0def97802e4b950606fa1b25fe68d # shrinks to before = "aaaaa", pattern = "aaaaaaaaaaaaaa", after = "", start_offset = 0 8 | cc 285373ae11601ac2b41bb6acf12a09bd73c0b414abe547fb076b47e7dd985704 # shrinks to before = "", pattern = "aaaaa", after = "", start_offset = 5 9 | cc 307ffe0bc6f1eb9d50f8c92438a9d15844aa9709f94ee0f8c747784e508d2e23 # shrinks to pattern = "aaaaaaa", lines = 373, replacement = "AAA" 10 | -------------------------------------------------------------------------------- /queries/python/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after compound statements 2 | [ 3 | (function_definition) 4 | (class_definition) 5 | (if_statement) 6 | (elif_clause) 7 | (else_clause) 8 | (for_statement) 9 | (while_statement) 10 | (with_statement) 11 | (try_statement) 12 | (except_clause) 13 | (finally_clause) 14 | (match_statement) 15 | (case_clause) 16 | ] @indent 17 | 18 | ; Indent inside blocks 19 | (block) @indent 20 | 21 | ; Indent lists, dicts, sets 22 | [ 23 | (list) 24 | (dictionary) 25 | (set) 26 | (tuple) 27 | ] @indent 28 | 29 | ; Indent function/lambda arguments and parameters 30 | [ 31 | (argument_list) 32 | (parameters) 33 | (lambda_parameters) 34 | ] @indent 35 | 36 | ; Dedent closing delimiters 37 | [ 38 | ")" 39 | "]" 40 | "}" 41 | ] @dedent 42 | -------------------------------------------------------------------------------- /keymaps/vscode.json: -------------------------------------------------------------------------------- 1 | { 2 | "inherits": "default", 3 | "bindings": [ 4 | { 5 | "comment": "VSCode-specific overrides", 6 | "key": "d", 7 | "modifiers": ["ctrl"], 8 | "action": "add_cursor_next_match", 9 | "args": {}, 10 | "when": "normal" 11 | }, 12 | { 13 | "key": "/", 14 | "modifiers": ["ctrl"], 15 | "action": "toggle_comment", 16 | "args": {}, 17 | "when": "normal" 18 | }, 19 | { 20 | "key": "k", 21 | "modifiers": ["ctrl", "shift"], 22 | "action": "delete_line", 23 | "args": {}, 24 | "when": "normal" 25 | }, 26 | { 27 | "key": "g", 28 | "modifiers": ["ctrl"], 29 | "action": "goto_line", 30 | "args": {}, 31 | "when": "normal" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /queries/rust/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces and blocks 2 | [ 3 | (block) 4 | (struct_expression) 5 | (enum_variant_list) 6 | (field_declaration_list) 7 | (declaration_list) 8 | (match_block) 9 | (token_tree) 10 | (use_list) 11 | ] @indent 12 | 13 | ; Indent inside function definitions 14 | (function_item 15 | body: (block)) @indent 16 | 17 | ; Indent inside impl blocks 18 | (impl_item 19 | body: (declaration_list)) @indent 20 | 21 | ; Indent match arms 22 | (match_arm) @indent 23 | 24 | ; Indent array expressions 25 | (array_expression) @indent 26 | 27 | ; Indent tuple expressions 28 | (tuple_expression) @indent 29 | 30 | ; Dedent closing delimiters 31 | [ 32 | "}" 33 | "]" 34 | ")" 35 | ] @dedent 36 | 37 | ; Keep same indent for these 38 | [ 39 | "where" 40 | "else" 41 | ] @dedent 42 | -------------------------------------------------------------------------------- /queries/javascript/indents.scm: -------------------------------------------------------------------------------- 1 | ; Indent after opening braces and blocks 2 | [ 3 | (statement_block) 4 | (object) 5 | (object_pattern) 6 | (class_body) 7 | (switch_body) 8 | ] @indent 9 | 10 | ; Indent function bodies 11 | [ 12 | (function_declaration) 13 | (function_expression) 14 | (arrow_function) 15 | (method_definition) 16 | ] @indent 17 | 18 | ; Indent control flow statements 19 | [ 20 | (if_statement) 21 | (for_statement) 22 | (for_in_statement) 23 | (while_statement) 24 | (do_statement) 25 | (try_statement) 26 | (catch_clause) 27 | (finally_clause) 28 | ] @indent 29 | 30 | ; Indent arrays 31 | [ 32 | (array) 33 | (array_pattern) 34 | ] @indent 35 | 36 | ; Indent switch cases 37 | (switch_case) @indent 38 | 39 | ; Dedent closing delimiters 40 | [ 41 | "}" 42 | "]" 43 | ")" 44 | ] @dedent 45 | -------------------------------------------------------------------------------- /plugins/lib/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Fresh Editor Plugin Library 3 | * 4 | * Shared utilities for building LSP-related plugins with common patterns. 5 | * 6 | * @example 7 | * ```typescript 8 | * import { PanelManager, NavigationController, VirtualBufferFactory } from "./lib/index.ts"; 9 | * import type { Location, RGB, PanelOptions } from "./lib/index.ts"; 10 | * ``` 11 | */ 12 | 13 | // Types 14 | export type { RGB, Location, PanelOptions, PanelState, NavigationOptions, HighlightPattern } from "./types.ts"; 15 | 16 | // Panel Management 17 | export { PanelManager } from "./panel-manager.ts"; 18 | 19 | // Navigation 20 | export { NavigationController } from "./navigation-controller.ts"; 21 | 22 | // Buffer Creation 23 | export { VirtualBufferFactory } from "./virtual-buffer-factory.ts"; 24 | export type { VirtualBufferOptions, SplitBufferOptions } from "./virtual-buffer-factory.ts"; 25 | -------------------------------------------------------------------------------- /dist-workspace.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["cargo:."] 3 | 4 | # Config for 'dist' 5 | [dist] 6 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 7 | cargo-dist-version = "0.30.2" 8 | # CI backends to support 9 | ci = "github" 10 | # The installers to generate for each app 11 | installers = ["npm"] 12 | # Target platforms to build apps for (Rust target-triple syntax) 13 | targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 14 | # Path that installers should place binaries in 15 | install-path = "CARGO_HOME" 16 | # Whether to install an updater program 17 | install-updater = false 18 | # Extra static files to include in each App (path relative to this Cargo.toml's dir) 19 | include = ["plugins/"] 20 | # A namespace to use when publishing this package to the npm registry 21 | npm-scope = "@fresh-editor" 22 | -------------------------------------------------------------------------------- /src/model/edit.rs: -------------------------------------------------------------------------------- 1 | /// Represents a single edit operation in the buffer's history 2 | #[derive(Clone, Debug)] 3 | pub struct Edit { 4 | pub version: u64, 5 | pub kind: EditKind, 6 | } 7 | 8 | /// The type of edit operation 9 | #[derive(Clone, Debug)] 10 | pub enum EditKind { 11 | /// Insert operation: bytes were inserted at offset 12 | Insert { offset: usize, len: usize }, 13 | /// Delete operation: bytes were deleted at offset 14 | Delete { offset: usize, len: usize }, 15 | } 16 | 17 | impl Edit { 18 | /// Create a new insert edit 19 | pub fn insert(version: u64, offset: usize, len: usize) -> Self { 20 | Self { 21 | version, 22 | kind: EditKind::Insert { offset, len }, 23 | } 24 | } 25 | 26 | /// Create a new delete edit 27 | pub fn delete(version: u64, offset: usize, len: usize) -> Self { 28 | Self { 29 | version, 30 | kind: EditKind::Delete { offset, len }, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /queries/typescript/indents.scm: -------------------------------------------------------------------------------- 1 | ; TypeScript extends JavaScript, so include JS rules 2 | ; Indent after opening braces and blocks 3 | [ 4 | (statement_block) 5 | (object_type) 6 | (object) 7 | (object_pattern) 8 | (class_body) 9 | (interface_body) 10 | (enum_body) 11 | (switch_body) 12 | ] @indent 13 | 14 | ; Indent function bodies 15 | [ 16 | (function_declaration) 17 | (function_expression) 18 | (arrow_function) 19 | (method_definition) 20 | (method_signature) 21 | ] @indent 22 | 23 | ; Indent control flow statements 24 | [ 25 | (if_statement) 26 | (for_statement) 27 | (for_in_statement) 28 | (while_statement) 29 | (do_statement) 30 | (try_statement) 31 | (catch_clause) 32 | (finally_clause) 33 | ] @indent 34 | 35 | ; Indent arrays and tuples 36 | [ 37 | (array) 38 | (array_pattern) 39 | (tuple_type) 40 | ] @indent 41 | 42 | ; Indent switch cases 43 | (switch_case) @indent 44 | 45 | ; Dedent closing delimiters 46 | [ 47 | "}" 48 | "]" 49 | ")" 50 | ] @dedent 51 | -------------------------------------------------------------------------------- /examples/test_todo_comments.txt: -------------------------------------------------------------------------------- 1 | // This is a test file for the TODO Highlighter plugin 2 | 3 | // TODO: Implement user authentication 4 | // FIXME: Memory leak in connection pool 5 | // HACK: Temporary workaround for parser bug 6 | // NOTE: This function is performance-critical 7 | // XXX: Needs review before production 8 | // BUG: Off-by-one error in loop counter 9 | 10 | # Python-style comments 11 | # TODO: Add type hints to all functions 12 | # FIXME: Handle edge case when list is empty 13 | # NOTE: Uses deprecated API, migrate to v2 14 | 15 | -- Lua-style comments 16 | -- TODO: Refactor this module 17 | -- HACK: Quick fix for deadline 18 | -- BUG: Crashes on null input 19 | 20 | /* C-style block comments */ 21 | /* TODO: Add error handling */ 22 | /* FIXME: Race condition in multi-threaded code */ 23 | 24 | Regular text without keywords should not be highlighted: 25 | TODO FIXME HACK NOTE XXX BUG (not in comments) 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/harness_test.rs: -------------------------------------------------------------------------------- 1 | // Test the EditorTestHarness itself 2 | 3 | mod common; 4 | 5 | use common::harness::EditorTestHarness; 6 | 7 | #[test] 8 | fn test_harness_creation() { 9 | let harness = EditorTestHarness::new(80, 24).unwrap(); 10 | assert!(!harness.should_quit()); 11 | } 12 | 13 | #[test] 14 | fn test_harness_render() { 15 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 16 | harness.render().unwrap(); 17 | 18 | let screen = harness.screen_to_string(); 19 | assert!(!screen.is_empty()); 20 | } 21 | 22 | #[test] 23 | fn test_buffer_content() { 24 | let harness = EditorTestHarness::new(80, 24).unwrap(); 25 | let content = harness.get_buffer_content().unwrap(); 26 | assert_eq!(content, ""); // New buffer is empty 27 | } 28 | 29 | #[test] 30 | fn test_screen_contains() { 31 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 32 | harness.render().unwrap(); 33 | 34 | // Should show status bar with "[No Name]" 35 | harness.assert_screen_contains("[No Name]"); 36 | } 37 | -------------------------------------------------------------------------------- /src/ts_bootstrap.js: -------------------------------------------------------------------------------- 1 | // Fresh Editor TypeScript Plugin Bootstrap 2 | // This file sets up the global 'editor' API object that plugins can use 3 | 4 | const core = Deno.core; 5 | 6 | // Create the editor API object 7 | const editor = { 8 | // Status bar 9 | setStatus(message) { 10 | core.ops.op_fresh_set_status(message); 11 | }, 12 | 13 | // Logging 14 | debug(message) { 15 | core.ops.op_fresh_debug(message); 16 | }, 17 | 18 | // Buffer operations (placeholders for now) 19 | getActiveBufferId() { 20 | return core.ops.op_fresh_get_active_buffer_id(); 21 | }, 22 | 23 | // TODO: Add more ops as they are implemented in Rust 24 | // - getBufferInfo 25 | // - insertText 26 | // - deleteRange 27 | // - addOverlay 28 | // - removeOverlay 29 | // - registerCommand 30 | // - defineMode 31 | // - createVirtualBufferInSplit (async) 32 | // - spawn (async) 33 | // - openFile (async) 34 | }; 35 | 36 | // Make editor globally available 37 | globalThis.editor = editor; 38 | 39 | // Log that the runtime is ready 40 | console.log("Fresh TypeScript plugin runtime initialized"); 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Cargo.lock is tracked for this binary application (ensures reproducible builds) 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | # MSVC Windows builds of rustc generate these, which store debugging information 13 | *.pdb 14 | 15 | # RustRover 16 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 17 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 18 | # and can be added to the global gitignore or merged into this file. For a more nuclear 19 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 20 | #.idea/ 21 | 22 | # Large test files (generated on demand) 23 | tests/BIG.txt 24 | flamegraph.svg 25 | perf.data 26 | perf.data.old 27 | *.snap.new 28 | \#*# 29 | # Generated TypeScript types - OLD location (now in plugins/lib, committed for distribution) 30 | types/fresh.d.ts 31 | -------------------------------------------------------------------------------- /src/v8_init.rs: -------------------------------------------------------------------------------- 1 | //! V8 platform initialization 2 | //! 3 | //! This is required because: 4 | //! 1. V8 platform must be initialized before any JsRuntime instances are created 5 | //! See: https://docs.rs/deno_core/latest/deno_core/struct.JsRuntime.html#method.init_platform 6 | //! 2. V8 platform initialization is process-wide and cannot be done more than once 7 | //! See: https://v8.github.io/api/head/classv8_1_1V8.html (V8::Dispose is permanent) 8 | //! 3. Multiple Editor instances can be created sequentially (e.g., in tests) as long as 9 | //! they share the same V8 platform initialized once at process startup 10 | //! See: https://docs.rs/deno_core/latest/deno_core/struct.JsRuntime.html 11 | //! 12 | //! Without this, creating multiple Editor instances sequentially causes segfaults 13 | //! because V8 cannot be reinitialized after disposal. 14 | 15 | use std::sync::Once; 16 | 17 | static INIT_V8: Once = Once::new(); 18 | 19 | /// Initialize V8 platform exactly once per process. 20 | /// 21 | /// This must be called before creating any JsRuntime instances. 22 | /// Safe to call multiple times - only the first call has any effect. 23 | pub fn init() { 24 | INIT_V8.call_once(|| { 25 | deno_core::JsRuntime::init_platform(None, false); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/view/ui/mod.rs: -------------------------------------------------------------------------------- 1 | //! UI rendering modules 2 | //! 3 | //! This module contains all rendering logic for the editor UI, 4 | //! separated into focused submodules: 5 | //! - `menu` - Menu bar rendering 6 | //! - `tabs` - Tab bar rendering for multiple buffers 7 | //! - `status_bar` - Status bar and prompt/minibuffer display 8 | //! - `suggestions` - Autocomplete and command palette UI 9 | //! - `split_rendering` - Split pane layout and rendering 10 | //! - `file_explorer` - File tree explorer rendering 11 | //! - `scrollbar` - Reusable scrollbar widget 12 | //! - `file_browser` - File open dialog popup 13 | 14 | pub mod file_browser; 15 | pub mod file_explorer; 16 | pub mod menu; 17 | pub mod scrollbar; 18 | pub mod split_rendering; 19 | pub mod status_bar; 20 | pub mod suggestions; 21 | pub mod tabs; 22 | pub mod view_pipeline; 23 | 24 | // Re-export main types for convenience 25 | pub use file_browser::{FileBrowserLayout, FileBrowserRenderer}; 26 | pub use file_explorer::FileExplorerRenderer; 27 | pub use menu::{context_keys, MenuContext, MenuRenderer, MenuState}; 28 | pub use scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState}; 29 | pub use split_rendering::SplitRenderer; 30 | pub use status_bar::StatusBarRenderer; 31 | pub use suggestions::SuggestionsRenderer; 32 | pub use tabs::TabsRenderer; 33 | -------------------------------------------------------------------------------- /tests/fixtures/scroll_test_file.txt: -------------------------------------------------------------------------------- 1 | 2 | static int lfs_migrate_to_dom(int fd_src, int fd_dst, char *name, 3 | __u64 migration_flags, 4 | unsigned long long bandwidth_bytes_sec, 5 | long stats_interval_sec); 6 | 7 | struct pool_to_id_cbdata { 8 | const char *pool; 9 | __u32 id; 10 | }; 11 | 12 | static int find_comp_id_by_pool(struct llapi_layout *layout, void *cbdata); 13 | static int find_mirror_id_by_pool(struct llapi_layout *layout, void *cbdata); 14 | 15 | enum setstripe_origin { 16 | SO_SETSTRIPE, 17 | SO_MIGRATE, 18 | SO_MIGRATE_MDT, 19 | SO_MIRROR_CREATE, 20 | SO_MIRROR_EXTEND, 21 | SO_MIRROR_SPLIT, 22 | SO_MIRROR_DELETE, 23 | }; 24 | 25 | static int lfs_setstripe_internal(int argc, char **argv, 26 | enum setstripe_origin opc); 27 | 28 | static inline int lfs_setstripe(int argc, char **argv) 29 | { 30 | return lfs_setstripe_internal(argc, argv, SO_SETSTRIPE); 31 | } 32 | 33 | static inline int lfs_setstripe_migrate(int argc, char **argv) 34 | { 35 | return lfs_setstripe_internal(argc, argv, SO_MIGRATE); 36 | } 37 | 38 | static inline int lfs_mirror_create(int argc, char **argv) 39 | { 40 | return lfs_setstripe_internal(argc, argv, SO_MIRROR_CREATE); 41 | } 42 | 43 | static inline int lfs_mirror_extend(int argc, char **argv) 44 | { 45 | return lfs_setstripe_internal(argc, argv, SO_MIRROR_EXTEND); 46 | } 47 | 48 | -------------------------------------------------------------------------------- /tests/fixtures/syntax_highlighting/hello.ts: -------------------------------------------------------------------------------- 1 | // TypeScript highlighting test 2 | interface User { 3 | name: string; 4 | age: number; 5 | readonly id: string; 6 | } 7 | 8 | type Status = "active" | "inactive"; 9 | 10 | class UserManager { 11 | private users: Map = new Map(); 12 | 13 | constructor(public readonly serviceName: string) {} 14 | 15 | async addUser(user: User): Promise { 16 | this.users.set(user.id, user); 17 | console.log(`Added user: ${user.name}`); 18 | } 19 | 20 | getUser(id: string): User | undefined { 21 | return this.users.get(id); 22 | } 23 | } 24 | 25 | // Generic function 26 | function identity(arg: T): T { 27 | return arg; 28 | } 29 | 30 | // Arrow function with type annotation 31 | const double = (x: number): number => x * 2; 32 | 33 | // Constants and variables 34 | const PI = 3.14159; 35 | let counter = 0; 36 | const isEnabled: boolean = true; 37 | const items: string[] = ["one", "two", "three"]; 38 | 39 | // Async/await 40 | async function fetchData(url: string): Promise { 41 | const response = await fetch(url); 42 | if (!response.ok) { 43 | throw new Error(`HTTP error: ${response.status}`); 44 | } 45 | return response; 46 | } 47 | 48 | // Export and import keywords 49 | export { UserManager, User, Status }; 50 | export default UserManager; 51 | -------------------------------------------------------------------------------- /tests/e2e/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod ansi_cursor; 2 | pub mod auto_indent; 3 | pub mod auto_revert; 4 | pub mod basic; 5 | pub mod binary_file; 6 | pub mod buffer_lifecycle; 7 | pub mod command_palette; 8 | pub mod crlf_rendering; 9 | pub mod document_model; 10 | pub mod emacs_actions; 11 | pub mod explorer_menu; 12 | pub mod file_browser; 13 | pub mod file_explorer; 14 | pub mod git; 15 | pub mod gutter; 16 | pub mod large_file_mode; 17 | pub mod lifecycle; 18 | pub mod line_wrapping; 19 | pub mod live_grep; 20 | pub mod lsp; 21 | pub mod margin; 22 | pub mod markdown_compose; 23 | pub mod menu_bar; 24 | pub mod merge_conflict; 25 | pub mod mouse; 26 | pub mod movement; 27 | pub mod multicursor; 28 | pub mod plugin; 29 | pub mod position_history; 30 | pub mod position_history_bugs; 31 | pub mod position_history_debug; 32 | pub mod position_history_truncate_debug; 33 | pub mod prompt; 34 | pub mod prompt_editing; 35 | pub mod recovery; 36 | pub mod rendering; 37 | pub mod scroll_clearing; 38 | pub mod scrolling; 39 | pub mod search; 40 | pub mod selection; 41 | pub mod session; 42 | pub mod slow_filesystem; 43 | pub mod smart_editing; 44 | pub mod split_tabs; 45 | pub mod split_view; 46 | pub mod split_view_expectations; 47 | pub mod tab_scrolling; 48 | pub mod terminal_resize; 49 | pub mod test_scrollbar_keybinds_cursor; 50 | pub mod theme; 51 | pub mod undo_redo; 52 | pub mod unicode_cursor; 53 | pub mod virtual_lines; 54 | pub mod visual_regression; 55 | -------------------------------------------------------------------------------- /scripts/png_to_ansi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Convert PNG image to ANSI color text output. 4 | Each pixel becomes a full block character (█) with 24-bit true color. 5 | """ 6 | 7 | import sys 8 | from PIL import Image 9 | 10 | 11 | def png_to_ansi(image_path: str) -> str: 12 | """Convert a PNG file to ANSI colored text output.""" 13 | img = Image.open(image_path) 14 | img = img.convert('RGB') 15 | 16 | width, height = img.size 17 | pixels = img.load() 18 | 19 | output = [] 20 | 21 | for y in range(height): 22 | row = [] 23 | for x in range(width): 24 | r, g, b = pixels[x, y] 25 | # Use 24-bit true color ANSI escape code 26 | # \x1b[38;2;R;G;Bm sets foreground color 27 | row.append(f'\x1b[38;2;{r};{g};{b}m█') 28 | output.append(''.join(row) + '\x1b[0m') # Reset at end of each row 29 | 30 | return '\n'.join(output) 31 | 32 | 33 | def main(): 34 | if len(sys.argv) != 2: 35 | print(f"Usage: {sys.argv[0]} ", file=sys.stderr) 36 | sys.exit(1) 37 | 38 | image_path = sys.argv[1] 39 | 40 | try: 41 | result = png_to_ansi(image_path) 42 | print(result) 43 | except FileNotFoundError: 44 | print(f"Error: File '{image_path}' not found", file=sys.stderr) 45 | sys.exit(1) 46 | except Exception as e: 47 | print(f"Error: {e}", file=sys.stderr) 48 | sys.exit(1) 49 | 50 | 51 | if __name__ == '__main__': 52 | main() 53 | -------------------------------------------------------------------------------- /docs/internal/MARKDOWN.md: -------------------------------------------------------------------------------- 1 | # Markdown Compose Mode - Remaining Work 2 | 3 | Plugin: `plugins/markdown_compose.ts` 4 | 5 | ## Pending Features 6 | 7 | ### Not Yet Started 8 | - **Multi-pass transforms**: design allows chaining; current implementation supports single transform per viewport 9 | - **Visual-line navigation**: up/down should operate on display lines in Compose mode; currently behaves like Source mode 10 | - **Column guides rendering**: stored in state but not drawn 11 | - **Context-sensitive Enter**: Enter in compose mode should be context-aware (continue lists, add bullets, double-newline for paragraphs). Requires plugin hook for key interception 12 | 13 | ### Partial Implementation 14 | - **Wrapping as transform**: wrapping happens in renderer, not as a token-inserting transform step. Plugins cannot control wrapping strategy 15 | - **Base token stream**: identity view uses raw string, not token format. Only plugin transforms use tokens 16 | 17 | ## Architecture Gap 18 | 19 | The design envisions: 20 | 1. Source → base token stream (Text/Newline/Space) 21 | 2. Plugin transforms rewrite tokens (Newline → Space for soft breaks) 22 | 3. Layout transform inserts break tokens for wrapping 23 | 4. Renderer draws final token stream 24 | 25 | **Current reality**: source → raw string (identity) OR plugin tokens, then renderer wraps during line construction. Plugins can't fully control text flow. 26 | 27 | ## Next Steps 28 | 1. **Column guides**: render vertical lines at `compose_column_guides` positions 29 | 2. **Visual navigation**: bind up/down to visual-line movement in Compose mode 30 | 3. **Markdown plugin**: parse incrementally, rewrite paragraph newlines to spaces, emit structure styling, detect hard breaks 31 | -------------------------------------------------------------------------------- /themes/dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dark", 3 | "editor": { 4 | "bg": [30, 30, 30], 5 | "fg": [212, 212, 212], 6 | "cursor": [82, 139, 255], 7 | "selection_bg": [38, 79, 120], 8 | "current_line_bg": [40, 40, 40], 9 | "line_number_fg": [100, 100, 100], 10 | "line_number_bg": [30, 30, 30] 11 | }, 12 | "ui": { 13 | "tab_active_fg": "Yellow", 14 | "tab_active_bg": "Blue", 15 | "tab_inactive_fg": "White", 16 | "tab_inactive_bg": "DarkGray", 17 | "tab_separator_bg": "Black", 18 | "status_bar_fg": "White", 19 | "status_bar_bg": "DarkGray", 20 | "prompt_fg": "White", 21 | "prompt_bg": "Black", 22 | "prompt_selection_fg": "White", 23 | "prompt_selection_bg": [58, 79, 120], 24 | "popup_border_fg": "Gray", 25 | "popup_bg": [30, 30, 30], 26 | "popup_selection_bg": [58, 79, 120], 27 | "popup_text_fg": "White", 28 | "suggestion_bg": [30, 30, 30], 29 | "suggestion_selected_bg": [58, 79, 120], 30 | "help_bg": "Black", 31 | "help_fg": "White", 32 | "help_key_fg": "Cyan", 33 | "help_separator_fg": "DarkGray", 34 | "help_indicator_fg": "Red", 35 | "help_indicator_bg": "Black", 36 | "split_separator_fg": [100, 100, 100] 37 | }, 38 | "search": { 39 | "match_bg": [100, 100, 20], 40 | "match_fg": [255, 255, 255] 41 | }, 42 | "diagnostic": { 43 | "error_fg": "Red", 44 | "error_bg": [60, 20, 20], 45 | "warning_fg": "Yellow", 46 | "warning_bg": [60, 50, 0], 47 | "info_fg": "Blue", 48 | "info_bg": [0, 30, 60], 49 | "hint_fg": "Gray", 50 | "hint_bg": [30, 30, 30] 51 | }, 52 | "syntax": { 53 | "keyword": [86, 156, 214], 54 | "string": [206, 145, 120], 55 | "comment": [106, 153, 85], 56 | "function": [220, 220, 170], 57 | "type": [78, 201, 176], 58 | "variable": [156, 220, 254], 59 | "constant": [79, 193, 255], 60 | "operator": [212, 212, 212] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /plugins/clangd_support.md: -------------------------------------------------------------------------------- 1 | # Clangd Helper Plugin 2 | 3 | Fresh bundles `plugins/clangd_support.ts` so clangd users get a small helper plugin out of the box. 4 | 5 | ## Commands 6 | 7 | * `Clangd: Switch Source/Header` calls `textDocument/switchSourceHeader` for the active cpp-style buffer and opens the returned URI if there is a match. 8 | * `Clangd: Open Project Config` searches the current directory tree for a `.clangd` file and opens it in the editor. 9 | 10 | Those commands are registered in the command palette after the plugin loads; TypeScript plugins can register their own commands by calling `editor.registerCommand`. 11 | 12 | ## Notifications 13 | 14 | The plugin listens for `lsp/custom_notification` events emitted by the core and filters for clangd-specific methods (`textDocument/clangd.fileStatus`, `$/memoryUsage`, etc.). When clangd sends `textDocument/clangd.fileStatus`, the plugin surfaces it as a status message (`Clangd file status: …`). The editor renders this plugin-provided status slot alongside the usual diagnostics/cursor info, so the notification stays visible without overwriting core messages. 15 | 16 | Use `editor.setStatus` to set a plugin status message and `editor.setStatus("")` to clear it; the core `Editor::set_status_message` call clears the plugin slot so core actions regain priority. 17 | 18 | ## Project setup heuristic 19 | 20 | `Clangd: Project Setup` opens a readonly panel that inspects the current workspace root and reports whether the files clangd needs are present (`compile_commands.json`, `.clangd`, etc.). The panel also guesses the build system (CMake, Bazel, Make) by looking for markers like `CMakeLists.txt` or `WORKSPACE` and prints quick tips for generating the missing artifacts (e.g., `cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON build`, `bear -- make`). This panel gives you a quick readiness check before enabling heavier clangd features on projects such as Lustre or other Makefile-heavy trees. 21 | -------------------------------------------------------------------------------- /themes/light.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "light", 3 | "editor": { 4 | "bg": [255, 255, 255], 5 | "fg": [0, 0, 0], 6 | "cursor": [0, 0, 255], 7 | "selection_bg": [173, 214, 255], 8 | "current_line_bg": [245, 245, 245], 9 | "line_number_fg": [140, 140, 140], 10 | "line_number_bg": [255, 255, 255] 11 | }, 12 | "ui": { 13 | "tab_active_fg": "Black", 14 | "tab_active_bg": "Cyan", 15 | "tab_inactive_fg": "Black", 16 | "tab_inactive_bg": "Gray", 17 | "tab_separator_bg": "White", 18 | "status_bar_fg": "White", 19 | "status_bar_bg": "DarkGray", 20 | "prompt_fg": "White", 21 | "prompt_bg": "Black", 22 | "prompt_selection_fg": "Black", 23 | "prompt_selection_bg": [173, 214, 255], 24 | "popup_border_fg": "DarkGray", 25 | "popup_bg": [255, 255, 255], 26 | "popup_selection_bg": [173, 214, 255], 27 | "popup_text_fg": "Black", 28 | "suggestion_bg": [255, 255, 255], 29 | "suggestion_selected_bg": [173, 214, 255], 30 | "help_bg": "White", 31 | "help_fg": "Black", 32 | "help_key_fg": "Blue", 33 | "help_separator_fg": "Gray", 34 | "help_indicator_fg": "Red", 35 | "help_indicator_bg": "White", 36 | "split_separator_fg": [140, 140, 140] 37 | }, 38 | "search": { 39 | "match_bg": [255, 255, 150], 40 | "match_fg": [0, 0, 0] 41 | }, 42 | "diagnostic": { 43 | "error_fg": "Red", 44 | "error_bg": [255, 220, 220], 45 | "warning_fg": [128, 128, 0], 46 | "warning_bg": [255, 255, 200], 47 | "info_fg": "Blue", 48 | "info_bg": [220, 240, 255], 49 | "hint_fg": "DarkGray", 50 | "hint_bg": [240, 240, 240] 51 | }, 52 | "syntax": { 53 | "keyword": [0, 0, 255], 54 | "string": [163, 21, 21], 55 | "comment": [0, 128, 0], 56 | "function": [121, 94, 38], 57 | "type": [38, 127, 153], 58 | "variable": [0, 0, 0], 59 | "constant": [0, 112, 193], 60 | "operator": [0, 0, 0] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /themes/high-contrast.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "high-contrast", 3 | "editor": { 4 | "bg": [0, 0, 0], 5 | "fg": [255, 255, 255], 6 | "cursor": "Yellow", 7 | "selection_bg": [0, 100, 200], 8 | "current_line_bg": [20, 20, 20], 9 | "line_number_fg": [140, 140, 140], 10 | "line_number_bg": [0, 0, 0] 11 | }, 12 | "ui": { 13 | "tab_active_fg": [0, 0, 0], 14 | "tab_active_bg": [100, 149, 237], 15 | "tab_inactive_fg": [70, 110, 180], 16 | "tab_inactive_bg": "Black", 17 | "tab_separator_bg": [30, 45, 90], 18 | "menu_bg": [100, 100, 110], 19 | "status_bar_fg": "White", 20 | "status_bar_bg": "DarkGray", 21 | "prompt_fg": "White", 22 | "prompt_bg": "Black", 23 | "prompt_selection_fg": "White", 24 | "prompt_selection_bg": [0, 100, 200], 25 | "popup_border_fg": "LightCyan", 26 | "popup_bg": [20, 25, 35], 27 | "popup_selection_bg": [0, 100, 200], 28 | "popup_text_fg": "White", 29 | "suggestion_bg": [20, 25, 35], 30 | "suggestion_selected_bg": [0, 100, 200], 31 | "help_bg": [20, 25, 35], 32 | "help_fg": "White", 33 | "help_key_fg": "LightCyan", 34 | "help_separator_fg": "White", 35 | "help_indicator_fg": "Red", 36 | "help_indicator_bg": [0, 0, 0], 37 | "split_separator_fg": [140, 140, 140] 38 | }, 39 | "search": { 40 | "match_bg": "Yellow", 41 | "match_fg": "Black" 42 | }, 43 | "diagnostic": { 44 | "error_fg": "Red", 45 | "error_bg": [100, 0, 0], 46 | "warning_fg": "Yellow", 47 | "warning_bg": [100, 100, 0], 48 | "info_fg": "Cyan", 49 | "info_bg": [0, 50, 100], 50 | "hint_fg": "White", 51 | "hint_bg": [50, 50, 50] 52 | }, 53 | "syntax": { 54 | "keyword": "Cyan", 55 | "string": "Green", 56 | "comment": "Gray", 57 | "function": "Yellow", 58 | "type": "Magenta", 59 | "variable": "White", 60 | "constant": "LightBlue", 61 | "operator": "White" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/e2e/position_history_debug.rs: -------------------------------------------------------------------------------- 1 | use crate::common::harness::EditorTestHarness; 2 | use crossterm::event::{KeyCode, KeyModifiers}; 3 | 4 | #[test] 5 | fn test_debug_position_history() { 6 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 7 | 8 | // Buffer 1 9 | harness.type_text("Buffer 1").unwrap(); 10 | println!("Buffer 1: cursor at {}", harness.cursor_position()); 11 | 12 | // Create Buffer 2 13 | harness 14 | .send_key(KeyCode::Char('n'), KeyModifiers::CONTROL) 15 | .unwrap(); 16 | println!( 17 | "After Ctrl+N: cursor at {}, content: {:?}", 18 | harness.cursor_position(), 19 | harness.get_buffer_content().unwrap() 20 | ); 21 | 22 | harness.type_text("Buffer 2").unwrap(); 23 | println!("Buffer 2: cursor at {}", harness.cursor_position()); 24 | 25 | // Create Buffer 3 26 | harness 27 | .send_key(KeyCode::Char('n'), KeyModifiers::CONTROL) 28 | .unwrap(); 29 | println!( 30 | "After Ctrl+N: cursor at {}, content: {:?}", 31 | harness.cursor_position(), 32 | harness.get_buffer_content().unwrap() 33 | ); 34 | 35 | harness.type_text("Buffer 3").unwrap(); 36 | println!( 37 | "Buffer 3: cursor at {}, content: {:?}", 38 | harness.cursor_position(), 39 | harness.get_buffer_content().unwrap() 40 | ); 41 | 42 | // Navigate back 43 | println!("\nNavigating back..."); 44 | harness.send_key(KeyCode::Left, KeyModifiers::ALT).unwrap(); 45 | println!( 46 | "After Alt+Left: cursor at {}, content: {:?}", 47 | harness.cursor_position(), 48 | harness.get_buffer_content().unwrap() 49 | ); 50 | 51 | // Check content 52 | let content = harness.get_buffer_content().unwrap(); 53 | println!("Final content: {content:?}"); 54 | assert_eq!( 55 | content, "Buffer 2", 56 | "Should be in Buffer 2 after navigating back" 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /plugins/lib/types.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Shared Types for Fresh Editor Plugin Library 5 | * 6 | * Common interfaces and types used across LSP-related plugins. 7 | */ 8 | 9 | /** 10 | * RGB color tuple for overlays and highlighting 11 | */ 12 | export type RGB = [number, number, number]; 13 | 14 | /** 15 | * File location with line and column 16 | */ 17 | export interface Location { 18 | file: string; 19 | line: number; 20 | column: number; 21 | } 22 | 23 | /** 24 | * Options for opening a panel 25 | */ 26 | export interface PanelOptions { 27 | /** Text property entries to display */ 28 | entries: TextPropertyEntry[]; 29 | /** Split ratio (0.0 to 1.0), default 0.3 */ 30 | ratio?: number; 31 | /** Whether to show line numbers, default false for panels */ 32 | showLineNumbers?: boolean; 33 | /** Whether editing is disabled, default true for panels */ 34 | editingDisabled?: boolean; 35 | } 36 | 37 | /** 38 | * State of a managed panel 39 | */ 40 | export interface PanelState { 41 | isOpen: boolean; 42 | bufferId: number | null; 43 | splitId: number | null; 44 | sourceSplitId: number | null; 45 | sourceBufferId: number | null; 46 | } 47 | 48 | /** 49 | * Options for NavigationController 50 | */ 51 | export interface NavigationOptions { 52 | /** Function to call when selection changes */ 53 | onSelectionChange?: (item: T, index: number) => void; 54 | /** Label for status messages (e.g., "Diagnostic", "Reference") */ 55 | itemLabel?: string; 56 | /** Whether to wrap around at boundaries */ 57 | wrap?: boolean; 58 | } 59 | 60 | /** 61 | * Highlight pattern for syntax highlighting 62 | */ 63 | export interface HighlightPattern { 64 | /** Function to test if line matches */ 65 | match: (line: string) => boolean; 66 | /** Color to apply */ 67 | rgb: RGB; 68 | /** Whether to underline */ 69 | underline?: boolean; 70 | /** Prefix for overlay IDs */ 71 | overlayIdPrefix?: string; 72 | } 73 | -------------------------------------------------------------------------------- /themes/solarized-dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solarized-dark", 3 | "editor": { 4 | "bg": [0, 43, 54], 5 | "fg": [131, 148, 150], 6 | "cursor": [38, 139, 210], 7 | "selection_bg": [7, 54, 66], 8 | "current_line_bg": [7, 54, 66], 9 | "line_number_fg": [88, 110, 117], 10 | "line_number_bg": [0, 43, 54] 11 | }, 12 | "ui": { 13 | "tab_active_fg": [253, 246, 227], 14 | "tab_active_bg": [38, 139, 210], 15 | "tab_inactive_fg": [131, 148, 150], 16 | "tab_inactive_bg": [7, 54, 66], 17 | "tab_separator_bg": [0, 43, 54], 18 | "status_bar_fg": [0, 43, 54], 19 | "status_bar_bg": [147, 161, 161], 20 | "prompt_fg": [0, 43, 54], 21 | "prompt_bg": [181, 137, 0], 22 | "prompt_selection_fg": [253, 246, 227], 23 | "prompt_selection_bg": [38, 139, 210], 24 | "popup_border_fg": [88, 110, 117], 25 | "popup_bg": [7, 54, 66], 26 | "popup_selection_bg": [38, 139, 210], 27 | "popup_text_fg": [131, 148, 150], 28 | "suggestion_bg": [7, 54, 66], 29 | "suggestion_selected_bg": [38, 139, 210], 30 | "help_bg": [0, 43, 54], 31 | "help_fg": [131, 148, 150], 32 | "help_key_fg": [42, 161, 152], 33 | "help_separator_fg": [88, 110, 117], 34 | "help_indicator_fg": [220, 50, 47], 35 | "help_indicator_bg": [0, 43, 54], 36 | "split_separator_fg": [88, 110, 117] 37 | }, 38 | "search": { 39 | "match_bg": [181, 137, 0], 40 | "match_fg": [253, 246, 227] 41 | }, 42 | "diagnostic": { 43 | "error_fg": [220, 50, 47], 44 | "error_bg": [42, 43, 54], 45 | "warning_fg": [181, 137, 0], 46 | "warning_bg": [30, 54, 54], 47 | "info_fg": [38, 139, 210], 48 | "info_bg": [0, 50, 66], 49 | "hint_fg": [88, 110, 117], 50 | "hint_bg": [0, 43, 54] 51 | }, 52 | "syntax": { 53 | "keyword": [133, 153, 0], 54 | "string": [42, 161, 152], 55 | "comment": [88, 110, 117], 56 | "function": [38, 139, 210], 57 | "type": [181, 137, 0], 58 | "variable": [131, 148, 150], 59 | "constant": [203, 75, 22], 60 | "operator": [131, 148, 150] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /themes/nord.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nord", 3 | "editor": { 4 | "bg": [46, 52, 64], 5 | "fg": [216, 222, 233], 6 | "cursor": [136, 192, 208], 7 | "selection_bg": [67, 76, 94], 8 | "current_line_bg": [59, 66, 82], 9 | "line_number_fg": [76, 86, 106], 10 | "line_number_bg": [46, 52, 64] 11 | }, 12 | "ui": { 13 | "tab_active_fg": [236, 239, 244], 14 | "tab_active_bg": [67, 76, 94], 15 | "tab_inactive_fg": [216, 222, 233], 16 | "tab_inactive_bg": [59, 66, 82], 17 | "tab_separator_bg": [46, 52, 64], 18 | "status_bar_fg": [46, 52, 64], 19 | "status_bar_bg": [136, 192, 208], 20 | "prompt_fg": [46, 52, 64], 21 | "prompt_bg": [163, 190, 140], 22 | "prompt_selection_fg": [236, 239, 244], 23 | "prompt_selection_bg": [94, 129, 172], 24 | "popup_border_fg": [76, 86, 106], 25 | "popup_bg": [59, 66, 82], 26 | "popup_selection_bg": [94, 129, 172], 27 | "popup_text_fg": [216, 222, 233], 28 | "suggestion_bg": [59, 66, 82], 29 | "suggestion_selected_bg": [94, 129, 172], 30 | "help_bg": [46, 52, 64], 31 | "help_fg": [216, 222, 233], 32 | "help_key_fg": [136, 192, 208], 33 | "help_separator_fg": [76, 86, 106], 34 | "help_indicator_fg": [191, 97, 106], 35 | "help_indicator_bg": [46, 52, 64], 36 | "split_separator_fg": [76, 86, 106] 37 | }, 38 | "search": { 39 | "match_bg": [235, 203, 139], 40 | "match_fg": [46, 52, 64] 41 | }, 42 | "diagnostic": { 43 | "error_fg": [191, 97, 106], 44 | "error_bg": [59, 46, 50], 45 | "warning_fg": [235, 203, 139], 46 | "warning_bg": [59, 56, 46], 47 | "info_fg": [129, 161, 193], 48 | "info_bg": [46, 52, 64], 49 | "hint_fg": [76, 86, 106], 50 | "hint_bg": [46, 52, 64] 51 | }, 52 | "syntax": { 53 | "keyword": [129, 161, 193], 54 | "string": [163, 190, 140], 55 | "comment": [76, 86, 106], 56 | "function": [136, 192, 208], 57 | "type": [143, 188, 187], 58 | "variable": [216, 222, 233], 59 | "constant": [180, 142, 173], 60 | "operator": [129, 161, 193] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /themes/dracula.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dracula", 3 | "editor": { 4 | "bg": [40, 42, 54], 5 | "fg": [248, 248, 242], 6 | "cursor": [255, 121, 198], 7 | "selection_bg": [68, 71, 90], 8 | "current_line_bg": [68, 71, 90], 9 | "line_number_fg": [98, 114, 164], 10 | "line_number_bg": [40, 42, 54] 11 | }, 12 | "ui": { 13 | "tab_active_fg": [248, 248, 242], 14 | "tab_active_bg": [189, 147, 249], 15 | "tab_inactive_fg": [248, 248, 242], 16 | "tab_inactive_bg": [68, 71, 90], 17 | "tab_separator_bg": [40, 42, 54], 18 | "status_bar_fg": [40, 42, 54], 19 | "status_bar_bg": [189, 147, 249], 20 | "prompt_fg": [40, 42, 54], 21 | "prompt_bg": [80, 250, 123], 22 | "prompt_selection_fg": [248, 248, 242], 23 | "prompt_selection_bg": [189, 147, 249], 24 | "popup_border_fg": [98, 114, 164], 25 | "popup_bg": [68, 71, 90], 26 | "popup_selection_bg": [189, 147, 249], 27 | "popup_text_fg": [248, 248, 242], 28 | "suggestion_bg": [68, 71, 90], 29 | "suggestion_selected_bg": [189, 147, 249], 30 | "help_bg": [40, 42, 54], 31 | "help_fg": [248, 248, 242], 32 | "help_key_fg": [139, 233, 253], 33 | "help_separator_fg": [98, 114, 164], 34 | "help_indicator_fg": [255, 85, 85], 35 | "help_indicator_bg": [40, 42, 54], 36 | "split_separator_fg": [98, 114, 164] 37 | }, 38 | "search": { 39 | "match_bg": [241, 250, 140], 40 | "match_fg": [40, 42, 54] 41 | }, 42 | "diagnostic": { 43 | "error_fg": [255, 85, 85], 44 | "error_bg": [64, 42, 54], 45 | "warning_fg": [241, 250, 140], 46 | "warning_bg": [64, 60, 42], 47 | "info_fg": [139, 233, 253], 48 | "info_bg": [40, 56, 70], 49 | "hint_fg": [98, 114, 164], 50 | "hint_bg": [40, 42, 54] 51 | }, 52 | "syntax": { 53 | "keyword": [255, 121, 198], 54 | "string": [241, 250, 140], 55 | "comment": [98, 114, 164], 56 | "function": [80, 250, 123], 57 | "type": [139, 233, 253], 58 | "variable": [248, 248, 242], 59 | "constant": [189, 147, 249], 60 | "operator": [255, 121, 198] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/fixtures/markdown_sample.md: -------------------------------------------------------------------------------- 1 | # Markdown Compose Mode Test 2 | 3 | This is a **beautiful** document that tests the *Markdown compose mode* in Fresh editor. 4 | 5 | ## Features 6 | 7 | The compose mode provides semi-WYSIWYG rendering with: 8 | 9 | - Soft breaks for paragraph wrapping 10 | - **Bold text** styling 11 | - *Italic text* styling 12 | - `inline code` highlighting 13 | - [Links to resources](https://example.com) 14 | 15 | ### Code Blocks 16 | 17 | Here's a code example: 18 | 19 | ```rust 20 | fn main() { 21 | println!("Hello, Fresh!"); 22 | } 23 | ``` 24 | 25 | ### Lists and Tasks 26 | 27 | 1. First ordered item 28 | 2. Second ordered item 29 | 3. Third item with hard break 30 | 31 | Unordered lists work too: 32 | 33 | * Item one 34 | * Item two 35 | * Nested item 36 | * Item three 37 | 38 | Task lists: 39 | 40 | - [ ] Unchecked task 41 | - [x] Checked task 42 | - [ ] Another task 43 | 44 | ### Block Quotes 45 | 46 | > This is a block quote. 47 | > It can span multiple lines. 48 | > 49 | > And have multiple paragraphs. 50 | 51 | ### Horizontal Rules 52 | 53 | Content above 54 | 55 | --- 56 | 57 | Content below 58 | 59 | ## Soft vs Hard Breaks 60 | 61 | This paragraph demonstrates soft breaks. Each line will flow together when rendered in compose mode, creating a nicely wrapped paragraph that adapts to the terminal width. 62 | 63 | This is on the next line but will merge with the previous. 64 | 65 | Empty lines create new paragraphs. 66 | 67 | Lines ending with two spaces 68 | create hard breaks. 69 | 70 | Lines ending with backslash\ 71 | also create hard breaks. 72 | 73 | ## Inline Styles 74 | 75 | You can combine **bold** and *italic* for ***bold italic*** text. 76 | 77 | Here's ~~strikethrough~~ text. 78 | 79 | Mix `code` with **bold** and *italic* freely. 80 | 81 | ## Links 82 | 83 | Check out [Fresh Editor](https://github.com/user/fresh) for more info. 84 | 85 | [Reference-style links][1] are also supported. 86 | 87 | [1]: https://example.com 88 | 89 | ## Conclusion 90 | 91 | This document tests various Markdown features for the compose mode renderer. 92 | -------------------------------------------------------------------------------- /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": "dark", 3 | "editor": { 4 | "tab_size": 4, 5 | "auto_indent": true, 6 | "line_numbers": true, 7 | "relative_line_numbers": false, 8 | "scroll_offset": 3, 9 | "syntax_highlighting": true, 10 | "highlight_timeout_ms": 5, 11 | "snapshot_interval": 100, 12 | "enable_inlay_hints": true 13 | }, 14 | "keybindings": [], 15 | "languages": { 16 | "rust": { 17 | "extensions": ["rs"], 18 | "grammar": "rust", 19 | "comment_prefix": "//", 20 | "auto_indent": true 21 | } 22 | }, 23 | "lsp": { 24 | "rust": { 25 | "command": "rust-analyzer", 26 | "args": [], 27 | "enabled": true, 28 | "process_limits": { 29 | "max_memory_mb": null, 30 | "max_cpu_percent": 90, 31 | "enabled": true 32 | } 33 | }, 34 | "python": { 35 | "command": "pylsp", 36 | "args": [], 37 | "enabled": true 38 | }, 39 | "javascript": { 40 | "command": "typescript-language-server", 41 | "args": ["--stdio"], 42 | "enabled": true 43 | }, 44 | "typescript": { 45 | "command": "typescript-language-server", 46 | "args": ["--stdio"], 47 | "enabled": true 48 | }, 49 | "html": { 50 | "command": "vscode-html-languageserver-bin", 51 | "args": ["--stdio"], 52 | "enabled": true 53 | }, 54 | "css": { 55 | "command": "vscode-css-languageserver-bin", 56 | "args": ["--stdio"], 57 | "enabled": true 58 | }, 59 | "c": { 60 | "command": "clangd", 61 | "args": [], 62 | "enabled": true 63 | }, 64 | "cpp": { 65 | "command": "clangd", 66 | "args": [], 67 | "enabled": true 68 | }, 69 | "go": { 70 | "command": "gopls", 71 | "args": [], 72 | "enabled": true 73 | }, 74 | "json": { 75 | "command": "vscode-json-languageserver", 76 | "args": ["--stdio"], 77 | "enabled": true 78 | } 79 | }, 80 | "_comment_lsp": "Process limits: max_memory_mb defaults to 50% of system memory if null, max_cpu_percent defaults to 90% of total CPU. Only enabled on Linux by default." 81 | } 82 | -------------------------------------------------------------------------------- /scripts/record-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Record a Fresh editor demo using asciinema 4 | # 5 | # Usage: 6 | # ./scripts/record-demo.sh [file] # Record editing a file 7 | # ./scripts/record-demo.sh # Record with no file 8 | # ./scripts/record-demo.sh --render # Convert last recording to GIF 9 | # 10 | # Requirements: 11 | # - asciinema: pip install asciinema (or via package manager) 12 | # - agg (for GIF): cargo install agg 13 | # 14 | 15 | set -e 16 | 17 | RECORDING_PATH="/tmp/fresh-recording.cast" 18 | OUTPUT_GIF="docs/demo.gif" 19 | 20 | render_gif() { 21 | if [[ ! -f "$RECORDING_PATH" ]]; then 22 | echo "Error: No recording found at $RECORDING_PATH" 23 | echo "Run a recording first with: $0 [file]" 24 | exit 1 25 | fi 26 | 27 | if ! command -v agg &> /dev/null; then 28 | echo "Error: 'agg' not found. Install with: cargo install agg" 29 | exit 1 30 | fi 31 | 32 | echo "Rendering GIF..." 33 | agg --theme dracula \ 34 | --font-size 14 \ 35 | --speed 1.5 \ 36 | "$RECORDING_PATH" \ 37 | "$OUTPUT_GIF" 38 | 39 | echo "Done! GIF saved to: $OUTPUT_GIF" 40 | } 41 | 42 | record_demo() { 43 | if ! command -v asciinema &> /dev/null; then 44 | echo "Error: 'asciinema' not found." 45 | echo "Install with: pip install asciinema (or via your package manager)" 46 | exit 1 47 | fi 48 | 49 | # Build fresh first to avoid recording compilation 50 | echo "Building fresh..." 51 | cargo build --quiet 52 | 53 | local cmd="./target/release/fresh" 54 | if [[ -n "$1" ]]; then 55 | cmd="$cmd $1" 56 | fi 57 | 58 | echo "Starting recording..." 59 | echo "Press Ctrl-D or type 'exit' when done." 60 | echo "" 61 | 62 | asciinema rec \ 63 | --overwrite \ 64 | --title "Fresh Editor Demo" \ 65 | --command "$cmd" \ 66 | "$RECORDING_PATH" 67 | 68 | echo "" 69 | echo "Recording saved to: $RECORDING_PATH" 70 | echo "To convert to GIF: $0 --render" 71 | } 72 | 73 | # Main 74 | case "${1:-}" in 75 | --render|-r) 76 | render_gif 77 | ;; 78 | --help|-h) 79 | echo "Usage: $0 [file] Record editing a file" 80 | echo " $0 --render Convert recording to GIF" 81 | echo " $0 --help Show this help" 82 | ;; 83 | *) 84 | record_demo "$1" 85 | ;; 86 | esac 87 | -------------------------------------------------------------------------------- /tests/test_overlay_colors.rs: -------------------------------------------------------------------------------- 1 | /// Test that overlay colors are correctly applied and rendered 2 | use fresh::config::LARGE_FILE_THRESHOLD_BYTES; 3 | use fresh::model::event::CursorId; 4 | use fresh::model::event::{Event, OverlayFace as EventOverlayFace}; 5 | use fresh::state::EditorState; 6 | use fresh::view::overlay::OverlayNamespace; 7 | 8 | #[test] 9 | fn test_overlay_background_color_direct() { 10 | // Create a state with some content 11 | let mut state = EditorState::new(80, 24, LARGE_FILE_THRESHOLD_BYTES as usize); 12 | 13 | // Insert text using proper event so marker list is updated 14 | let text = "// TODO: test".to_string(); 15 | state.apply(&Event::Insert { 16 | position: 0, 17 | text: text.clone(), 18 | cursor_id: CursorId(0), 19 | }); 20 | 21 | println!("Buffer content: {:?}", state.buffer.to_string().unwrap()); 22 | println!("Buffer size: {}", state.buffer.len()); 23 | 24 | // Directly add an overlay with orange background 25 | state.apply(&Event::AddOverlay { 26 | namespace: Some(OverlayNamespace::from_string("test_todo".to_string())), 27 | range: 3..7, // "TODO" 28 | face: EventOverlayFace::Background { 29 | color: (255, 165, 0), // Orange 30 | }, 31 | priority: 10, 32 | message: None, 33 | }); 34 | 35 | // Check that overlay was created by checking all positions 36 | println!("Checking overlays at different positions:"); 37 | for pos in 0..13 { 38 | let overlays_at_pos = state.overlays.at_position(pos, &state.marker_list); 39 | if !overlays_at_pos.is_empty() { 40 | println!(" Position {}: {} overlay(s)", pos, overlays_at_pos.len()); 41 | } 42 | } 43 | 44 | let overlays = state.overlays.at_position(5, &state.marker_list); // Middle of "TODO" 45 | println!("Overlays at position 5: {}", overlays.len()); 46 | 47 | assert_eq!(overlays.len(), 1, "Should have one overlay"); 48 | 49 | // Check the overlay face 50 | let overlay = overlays[0]; 51 | match &overlay.face { 52 | fresh::view::overlay::OverlayFace::Background { color } => { 53 | println!("Overlay color: {:?}", color); 54 | assert!( 55 | matches!(color, ratatui::style::Color::Rgb(255, 165, 0)), 56 | "Expected RGB(255, 165, 0) but got {:?}", 57 | color 58 | ); 59 | } 60 | _ => panic!("Expected Background face"), 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /plugins/examples/README.md: -------------------------------------------------------------------------------- 1 | # Example Plugins 2 | 3 | This directory contains example plugins demonstrating the editor's plugin system. These are educational examples showing specific API features. 4 | 5 | For the complete API reference, see **[docs/plugin-api.md](../../docs/plugin-api.md)**. 6 | 7 | ## Available Examples 8 | 9 | ### hello_world.ts 10 | 11 | A simple "Hello World" plugin that demonstrates: 12 | - Registering a custom command 13 | - Setting status messages 14 | - Basic plugin structure 15 | 16 | ### async_demo.ts 17 | 18 | Demonstrates async process spawning: 19 | - Running external commands with `spawnProcess` 20 | - Processing stdout/stderr 21 | - Handling exit codes 22 | 23 | ### buffer_query_demo.ts 24 | 25 | Demonstrates buffer queries: 26 | - Getting buffer metadata with `getBufferInfo` 27 | - Listing all open buffers 28 | - Querying cursor and viewport information 29 | 30 | ### virtual_buffer_demo.ts 31 | 32 | Demonstrates virtual buffer creation: 33 | - Creating virtual buffers with `createVirtualBufferInSplit` 34 | - Using text properties for embedded metadata 35 | - Defining custom modes with keybindings 36 | - Handling "go to" navigation from results 37 | 38 | ### bookmarks.ts 39 | 40 | A complete bookmark management example: 41 | - Managing persistent state across sessions 42 | - Creating navigation commands 43 | - Using overlays for visual markers 44 | 45 | ### git_grep.ts 46 | 47 | Git grep implementation demonstrating: 48 | - Spawning async git processes 49 | - Parsing structured output 50 | - Opening files at specific line:column positions 51 | - Interactive search with prompt API 52 | 53 | ## Writing Your Own Plugin 54 | 55 | 1. Create a `.ts` file in the plugins directory 56 | 2. Use the `editor` global object to access the API 57 | 3. Register commands with `editor.registerCommand()` 58 | 4. The plugin will be automatically loaded when the editor starts 59 | 60 | Example template: 61 | 62 | ```typescript 63 | /// 64 | 65 | // Define the command handler 66 | globalThis.my_command = function(): void { 67 | editor.setStatus("My command executed!"); 68 | }; 69 | 70 | // Register the command 71 | editor.registerCommand( 72 | "My Custom Command", 73 | "Does something cool", 74 | "my_command", 75 | "normal" 76 | ); 77 | 78 | // Initialization message 79 | editor.debug("My custom plugin loaded"); 80 | ``` 81 | 82 | ## Further Reading 83 | 84 | - **Getting Started:** [docs/PLUGIN_DEVELOPMENT.md](../../docs/PLUGIN_DEVELOPMENT.md) 85 | - **API Reference:** [docs/plugin-api.md](../../docs/plugin-api.md) 86 | -------------------------------------------------------------------------------- /plugins/welcome.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Welcome Plugin 5 | * Shows a welcome message and registers demo commands 6 | */ 7 | 8 | // Show welcome message in status bar 9 | editor.setStatus("Plugins are working! Welcome Plugin loaded successfully!"); 10 | 11 | // Register commands that use built-in actions 12 | editor.registerCommand( 13 | "Plugin Demo: Open Help", 14 | "Open the editor help page (uses built-in action)", 15 | "show_help", 16 | "normal" 17 | ); 18 | 19 | editor.registerCommand( 20 | "Plugin Demo: Save File", 21 | "Save the current file (uses built-in action)", 22 | "save", 23 | "normal" 24 | ); 25 | 26 | // Register commands with custom TypeScript callbacks 27 | globalThis.plugin_say_hello = function(): void { 28 | editor.insertAtCursor("Hello from TypeScript! The plugin system is working!\n"); 29 | editor.setStatus("Inserted greeting at cursor position"); 30 | editor.debug("Plugin callback executed: say_hello"); 31 | }; 32 | 33 | editor.registerCommand( 34 | "Plugin Demo: Say Hello", 35 | "Insert a friendly greeting into the buffer", 36 | "plugin_say_hello", 37 | "normal" 38 | ); 39 | 40 | globalThis.plugin_insert_time = function(): void { 41 | const time = new Date().toLocaleTimeString(); 42 | editor.insertAtCursor(`Current time: ${time}\n`); 43 | editor.setStatus("Inserted time at cursor position"); 44 | editor.debug(`Plugin callback executed: insert_time at ${time}`); 45 | }; 46 | 47 | editor.registerCommand( 48 | "Plugin Demo: Insert Time", 49 | "Insert the current time at cursor position", 50 | "plugin_insert_time", 51 | "normal" 52 | ); 53 | 54 | globalThis.plugin_insert_comment = function(): void { 55 | editor.insertAtCursor("// This comment was inserted by a TypeScript plugin!\n"); 56 | editor.setStatus("Comment inserted by plugin"); 57 | editor.debug("Plugin callback executed: insert_comment"); 58 | }; 59 | 60 | editor.registerCommand( 61 | "Plugin Demo: Insert Comment", 62 | "Insert a sample comment at cursor position", 63 | "plugin_insert_comment", 64 | "normal" 65 | ); 66 | 67 | // Debug output 68 | editor.debug("Welcome plugin initialized successfully!"); 69 | editor.debug("Registered 5 commands - try Ctrl+P to see them!"); 70 | editor.debug(" - 'Plugin Demo: Open Help' - toggles help screen (built-in action)"); 71 | editor.debug(" - 'Plugin Demo: Save File' - saves current file (built-in action)"); 72 | editor.debug(" - 'Plugin Demo: Say Hello' - inserts greeting (TypeScript callback)"); 73 | editor.debug(" - 'Plugin Demo: Insert Time' - inserts current time (TypeScript callback)"); 74 | editor.debug(" - 'Plugin Demo: Insert Comment' - inserts sample comment (TypeScript callback)"); 75 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | This directory contains production-ready plugins for the editor. Plugins are written in **TypeScript** and run in a sandboxed Deno environment. They are automatically loaded when the editor starts. 4 | 5 | ## Available Plugins 6 | 7 | ### Core Plugins 8 | 9 | | Plugin | Description | 10 | |--------|-------------| 11 | | `welcome.ts` | Displays welcome message on startup | 12 | | `manual_help.ts` | Manual page and keyboard shortcuts display | 13 | | `diagnostics_panel.ts` | LSP diagnostics panel with navigation | 14 | | `search_replace.ts` | Search and replace functionality | 15 | | `path_complete.ts` | Path completion in prompts | 16 | 17 | ### Git Integration 18 | 19 | | Plugin | Description | 20 | |--------|-------------| 21 | | `git_grep.ts` | Interactive search through git-tracked files | 22 | | `git_find_file.ts` | Fuzzy file finder for git repositories | 23 | | `git_blame.ts` | Git blame view with commit navigation | 24 | | `git_log.ts` | Git log viewer with history browsing | 25 | 26 | ### Code Enhancement 27 | 28 | | Plugin | Description | 29 | |--------|-------------| 30 | | `todo_highlighter.ts` | Highlights TODO/FIXME/HACK keywords in comments | 31 | | `color_highlighter.ts` | Highlights color codes with their actual colors | 32 | | `find_references.ts` | Find references across the codebase | 33 | | `clangd_support.ts` | Clangd-specific LSP features (switch header/source) | 34 | 35 | ### Editing Modes 36 | 37 | | Plugin | Description | 38 | |--------|-------------| 39 | | `markdown_compose.ts` | Semi-WYSIWYG markdown editing with soft breaks | 40 | | `merge_conflict.ts` | 3-way merge conflict resolution | 41 | 42 | ### Development/Testing 43 | 44 | | Plugin | Description | 45 | |--------|-------------| 46 | | `test_view_marker.ts` | Testing utilities for view markers | 47 | 48 | --- 49 | 50 | ## Example Plugins 51 | 52 | The `examples/` directory contains educational examples demonstrating specific API features: 53 | 54 | | Example | Description | 55 | |---------|-------------| 56 | | `hello_world.ts` | Minimal plugin demonstrating command registration | 57 | | `async_demo.ts` | Async process spawning | 58 | | `buffer_query_demo.ts` | Buffer state querying | 59 | | `virtual_buffer_demo.ts` | Creating virtual buffers with text properties | 60 | | `bookmarks.ts` | Bookmark management example | 61 | | `git_grep.ts` | Git grep implementation example | 62 | 63 | --- 64 | 65 | ## Plugin Development 66 | 67 | For plugin development guides, see: 68 | - **Getting Started:** [`docs/PLUGIN_DEVELOPMENT.md`](../docs/PLUGIN_DEVELOPMENT.md) 69 | - **API Reference:** [`docs/plugin-api.md`](../docs/plugin-api.md) 70 | - **Examples:** [`examples/README.md`](examples/README.md) 71 | - **Clangd Plugin:** [`clangd_support.md`](clangd_support.md) 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fresh 2 | 3 | [Visit the official Fresh website](https://sinelaw.github.io/fresh/) 4 | 5 | **[📦 Installation Instructions](#installation)** 6 | 7 | A terminal-based text editor. 8 | 9 | ## Discovery & Ease of Use 10 | 11 | Fresh is designed for discovery. It features native UIs, a full Menu system, and a powerful Command Palette. With full mouse support, transitioning from graphical editors is seamless. 12 | 13 | ## Modern Extensibility 14 | 15 | Extend Fresh easily using modern tools. Plugins are written in TypeScript and run securely in a sandboxed Deno environment, providing access to a modern JavaScript ecosystem without compromising stability. 16 | 17 | ## Zero-Latency Performance 18 | 19 | Fresh is engineered for speed. It delivers a near zero-latency experience, with text appearing instantly. The editor is designed to be light and fast, reliably opening and editing huge files up to multi-gigabyte sizes without slowdown. 20 | 21 | ## Comprehensive Feature Set 22 | 23 | - **File Management**: open/save/new/close, file explorer, tabs, auto-revert, git file finder 24 | - **Editing**: undo/redo, multi-cursor, block selection, smart indent, comments, clipboard 25 | - **Search & Replace**: incremental search, find in selection, query replace, git grep 26 | - **Navigation**: go to line/bracket, word movement, position history, bookmarks, error navigation 27 | - **Views & Layout**: split panes, line numbers, line wrap, backgrounds, markdown preview 28 | - **Language Server (LSP)**: go to definition, references, hover, code actions, rename, diagnostics, autocompletion 29 | - **Productivity**: command palette, menu bar, keyboard macros, git log, diagnostics panel 30 | - **Plugins & Extensibility**: TypeScript plugins, color highlighter, TODO highlighter, merge conflicts, path complete, keymaps 31 | 32 | ![Fresh Screenshot](docs/screenshot1.png) 33 | ![Fresh Screenshot](docs/screenshot2.png) 34 | ![Fresh Screenshot](docs/screenshot3.png) 35 | 36 | ## Installation 37 | 38 | ### Via npm (recommended) 39 | 40 | ```bash 41 | npm install -g @fresh-editor/fresh-editor 42 | ``` 43 | 44 | ### Via npx (for a quick test) 45 | 46 | ```bash 47 | npx @fresh-editor/fresh-editor 48 | ``` 49 | 50 | ### Pre-built binaries 51 | 52 | Download the latest release for your platform from the [releases page](https://github.com/sinelaw/fresh/releases). 53 | 54 | ### From crates.io 55 | 56 | ```bash 57 | cargo install fresh-editor 58 | ``` 59 | 60 | ### From source 61 | 62 | ```bash 63 | git clone https://github.com/sinelaw/fresh.git 64 | cd fresh 65 | cargo build --release 66 | ./target/release/fresh [file] 67 | ``` 68 | 69 | ## Documentation 70 | 71 | - [User Guide](docs/USER_GUIDE.md) 72 | - [Plugin Development](docs/PLUGIN_DEVELOPMENT.md) 73 | - [Architecture](docs/ARCHITECTURE.md) 74 | 75 | ## License 76 | 77 | Copyright (c) Noam Lewis 78 | 79 | This project is licensed under the GNU General Public License v2.0 (GPL-2.0). 80 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fresh-editor" 3 | version = "0.1.15" 4 | authors = ["Noam Lewis"] 5 | edition = "2021" 6 | description = "A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins" 7 | license = "GPL-2.0" 8 | repository = "https://github.com/sinelaw/fresh" 9 | keywords = ["editor", "terminal", "tui", "text-editor", "lsp"] 10 | categories = ["command-line-utilities", "text-editors"] 11 | 12 | [[bin]] 13 | name = "fresh" 14 | path = "src/main.rs" 15 | 16 | [lib] 17 | name = "fresh" 18 | path = "src/lib.rs" 19 | 20 | [dependencies] 21 | crossterm = { version = "0.29.0", features = ["osc52"] } 22 | ratatui = "0.29.0" 23 | serde = { version = "1.0", features = ["derive"] } 24 | serde_json = "1.0" 25 | chrono = "0.4" 26 | clap = { version = "4.5", features = ["derive"] } 27 | tracing = "0.1" 28 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 29 | 30 | tree-sitter = "0.25.10" 31 | tree-sitter-highlight = "0.25.10" 32 | tree-sitter-rust = "0.24.0" 33 | 34 | lsp-types = "0.97" 35 | url = "2.5" 36 | tokio = { version = "1.48", features = ["rt", "rt-multi-thread", "io-util", "sync", "process", "time", "macros", "fs"] } 37 | async-trait = "0.1" 38 | lru = "0.16" 39 | ignore = "0.4" 40 | regex = "1.12" 41 | ctrlc = "3.5" 42 | libc = "0.2" 43 | nix = { version = "0.30", features = ["signal", "pthread", "resource"] } 44 | deno_core = "0.371.0" # TypeScript plugin runtime (stable version) 45 | deno_ast = { version = "0.51.0", features = ["transpiling"] } # TypeScript transpilation 46 | deno_error = "0.7" # Error types for deno_core ops (matches deno_core's dependency) 47 | tree-sitter-python = "0.25.0" 48 | tree-sitter-javascript = "0.25.0" 49 | tree-sitter-typescript = "0.23.2" 50 | tree-sitter-html = "0.23.2" 51 | tree-sitter-css = "0.25.0" 52 | tree-sitter-c = "0.24.1" 53 | tree-sitter-cpp = "0.23.4" 54 | tree-sitter-go = "0.25.0" 55 | tree-sitter-json = "0.24.8" 56 | tree-sitter-java = "0.23.5" 57 | tree-sitter-c-sharp = "0.23.1" 58 | tree-sitter-php = "0.24.2" 59 | tree-sitter-ruby = "0.23.1" 60 | tree-sitter-bash = "0.25.0" 61 | tree-sitter-lua = "0.2.0" 62 | anyhow = "1.0.100" 63 | dirs = "6.0" # For XDG/platform-specific directory detection 64 | pulldown-cmark = "0.13" # Markdown parsing for LSP hover docs 65 | sha2 = "0.10" # SHA-256 checksums for recovery file integrity 66 | arboard = "3.6" # Cross-platform system clipboard access 67 | notify = "8.2.0" 68 | syntect = "5.2" # TextMate grammar support for syntax highlighting 69 | # tree-sitter-markdown = "0.7.1" # Disabled due to tree-sitter version conflict (uses 0.19.5 instead of 0.25.x) 70 | 71 | [dev-dependencies] 72 | proptest = "1.9" 73 | tempfile = "3.23.0" 74 | insta = { version = "1.44", features = ["yaml"] } 75 | vt100 = "0.15" # Virtual terminal emulator for testing real ANSI output 76 | ctor = "0.6.1" 77 | 78 | [profile.release] 79 | debug = true 80 | split-debuginfo = "packed" 81 | 82 | # The profile that 'dist' will build with 83 | [profile.dist] 84 | inherits = "release" 85 | lto = "thin" 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/e2e/ansi_cursor.rs: -------------------------------------------------------------------------------- 1 | //! Test cursor visibility with ANSI escape codes in file content 2 | //! 3 | //! Bug: When a file starts with ANSI escape codes (like log files with colors), 4 | //! the hardware cursor position is incorrectly set to (0, 0) instead of the 5 | //! actual cursor position in the content area. 6 | 7 | use crate::common::harness::EditorTestHarness; 8 | use tempfile::TempDir; 9 | 10 | /// Compare cursor position between ANSI and plain text files. 11 | /// Both should have the cursor on the first character of content (row 2, after gutter). 12 | /// 13 | /// This test reproduces a bug where files starting with ANSI escape codes 14 | /// cause the cursor to be positioned at (0, 0) instead of the correct location. 15 | #[test] 16 | fn test_cursor_ansi_vs_plain_comparison() { 17 | eprintln!("[TEST] Starting test_cursor_ansi_vs_plain_comparison"); 18 | let temp_dir = TempDir::new().unwrap(); 19 | 20 | // Create both files 21 | let plain_path = temp_dir.path().join("plain.txt"); 22 | let ansi_path = temp_dir.path().join("ansi.log"); 23 | 24 | eprintln!("[TEST] Writing files..."); 25 | std::fs::write(&plain_path, "Hello world\n").unwrap(); 26 | // ANSI content: \x1b[2m is "dim", \x1b[0m is "reset" 27 | std::fs::write(&ansi_path, "\x1b[2m2025-11-23T17:51:33Z\x1b[0m INFO test\n").unwrap(); 28 | 29 | // Test plain text first (baseline) 30 | eprintln!("[TEST] Creating first harness for plain file..."); 31 | let mut plain_harness = EditorTestHarness::new(80, 24).unwrap(); 32 | eprintln!("[TEST] Opening plain file..."); 33 | plain_harness.open_file(&plain_path).unwrap(); 34 | eprintln!("[TEST] Rendering plain file..."); 35 | plain_harness.render().unwrap(); 36 | eprintln!("[TEST] Getting plain cursor position..."); 37 | let plain_cursor_pos = plain_harness.screen_cursor_position(); 38 | eprintln!("[TEST] Plain cursor pos: {:?}", plain_cursor_pos); 39 | 40 | // Drop the first harness before creating the second to avoid multiple plugin threads 41 | eprintln!("[TEST] Dropping first harness..."); 42 | drop(plain_harness); 43 | 44 | // Test ANSI file 45 | eprintln!("[TEST] Creating second harness for ANSI file..."); 46 | let mut ansi_harness = EditorTestHarness::new(80, 24).unwrap(); 47 | eprintln!("[TEST] Opening ANSI file..."); 48 | ansi_harness.open_file(&ansi_path).unwrap(); 49 | eprintln!("[TEST] Rendering ANSI file..."); 50 | ansi_harness.render().unwrap(); 51 | eprintln!("[TEST] Getting ANSI cursor position..."); 52 | let ansi_cursor_pos = ansi_harness.screen_cursor_position(); 53 | eprintln!("[TEST] ANSI cursor pos: {:?}", ansi_cursor_pos); 54 | 55 | // The Y coordinate (row) should be the same for both - cursor on content row 56 | assert_eq!( 57 | plain_cursor_pos.1, ansi_cursor_pos.1, 58 | "Cursor row should be the same for plain ({:?}) and ANSI ({:?}) files. \ 59 | ANSI cursor is at (0,0) which indicates a bug in cursor position calculation \ 60 | when file starts with escape codes.", 61 | plain_cursor_pos, ansi_cursor_pos 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /tests/common/snapshots/e2e_tests__common__visual_testing__Comprehensive UI A__state_a.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/common/visual_testing.rs 3 | expression: "&screen_text" 4 | --- 5 | File Edit View Selection Go Explorer Help 6 | ┌ File Explorer (Ctrl+E) ──×─┐ main.rs × 7 | │▼ project_root 3 items │ 1 │ // Main entry point █ 8 | │ ▼ src 1 item │ 2 │ fn main() { █ 9 | │ main.rs 0.4 KB │ 3 │ let hello = "world"; █ 10 | │ Cargo.toml 0.0 KB │ 4 │ let hello = "again"; █ 11 | │ README.md 0.1 KB │ 5 │ let hello = "once more"; █ 12 | │ │ 6 │ println!("{}", hello); █ 13 | │ │ 7 │ } █ 14 | │ │ 8 │ █ 15 | │ │ 9 │ // Helper function █ 16 | │ │ 10 │ fn helper(x: i32) -> i32 { █ 17 | │ │ 11 │ let unused_var = 5; █ 18 | │ │● 12 │ let another_unused = 10; █ 19 | │ │ 13 │ x * 2 █ 20 | │ │ 14 │ } █ 21 | │ │ 15 │ █ 22 | │ │ 16 │ // More code to enable scrolling █ 23 | │ │ 17 │ fn long_function() { █ 24 | │ │ 18 │ println!("Line 1"); █ 25 | │ │ 19 │ println!("Line 2"); █ 26 | │ │ 20 │ println!("Line 3"); █ 27 | │ │ 21 │ println!("Line 4"); █ 28 | │ │ 22 │ println!("Line 5"); █ 29 | │ │ 23 │ } █ 30 | │ │ 24 │ █ 31 | │ │~ █ 32 | └────────────────────────────┘~ █ 33 | src/main.rs | Ln 5, Col 11 | E:1 | 3 cursors | Added cursor at match (3) Palette: Ctrl+P 34 | -------------------------------------------------------------------------------- /plugins/examples/hello_world.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Hello World TypeScript Plugin for Fresh Editor 5 | * 6 | * This is a simple example plugin that demonstrates: 7 | * - Querying editor state (buffer info, cursor position) 8 | * - Sending commands (status messages, text insertion) 9 | * - Using async/await for plugin actions 10 | */ 11 | 12 | // Global action: Display buffer information 13 | globalThis.show_buffer_info = function (): void { 14 | const bufferId = editor.getActiveBufferId(); 15 | const path = editor.getBufferPath(bufferId); 16 | const length = editor.getBufferLength(bufferId); 17 | const modified = editor.isBufferModified(bufferId); 18 | const cursorPos = editor.getCursorPosition(); 19 | 20 | const status = `Buffer ${bufferId}: ${path || "[untitled]"} | ${length} bytes | ${ 21 | modified ? "modified" : "saved" 22 | } | cursor@${cursorPos}`; 23 | 24 | editor.setStatus(status); 25 | editor.debug(`Buffer info: ${status}`); 26 | }; 27 | 28 | // Global action: Insert timestamp at cursor 29 | globalThis.insert_timestamp = function (): void { 30 | const bufferId = editor.getActiveBufferId(); 31 | const cursorPos = editor.getCursorPosition(); 32 | const timestamp = new Date().toISOString(); 33 | 34 | const success = editor.insertText(bufferId, cursorPos, timestamp); 35 | if (success) { 36 | editor.setStatus(`Inserted timestamp: ${timestamp}`); 37 | } else { 38 | editor.setStatus("Failed to insert timestamp"); 39 | } 40 | }; 41 | 42 | // Global action: Highlight current line (demo overlay) 43 | globalThis.highlight_region = function (): void { 44 | const bufferId = editor.getActiveBufferId(); 45 | const cursorPos = editor.getCursorPosition(); 46 | 47 | // Highlight 10 characters around cursor 48 | const start = Math.max(0, cursorPos - 5); 49 | const end = cursorPos + 5; 50 | 51 | // Use namespace "demo" for batch operations 52 | const success = editor.addOverlay( 53 | bufferId, 54 | "demo", // namespace 55 | start, 56 | end, 57 | 255, // Red 58 | 255, // Green 59 | 0, // Blue (yellow highlight) 60 | false // No underline 61 | ); 62 | 63 | if (success) { 64 | editor.setStatus(`Highlighted region ${start}-${end}`); 65 | } 66 | }; 67 | 68 | // Global action: Remove highlight 69 | globalThis.clear_highlight = function (): void { 70 | const bufferId = editor.getActiveBufferId(); 71 | // Clear all overlays in the "demo" namespace 72 | const success = editor.clearNamespace(bufferId, "demo"); 73 | if (success) { 74 | editor.setStatus("Cleared highlight"); 75 | } 76 | }; 77 | 78 | // Global async action: Demonstrate async/await 79 | globalThis.async_demo = async function (): Promise { 80 | editor.setStatus("Starting async operation..."); 81 | 82 | // Simulate some async work 83 | await Promise.resolve(); 84 | 85 | const bufferId = editor.getActiveBufferId(); 86 | const length = editor.getBufferLength(bufferId); 87 | 88 | editor.setStatus(`Async operation complete! Buffer has ${length} bytes`); 89 | }; 90 | 91 | // Log that plugin loaded 92 | editor.debug("Hello World plugin loaded!"); 93 | editor.setStatus("Hello World plugin ready"); 94 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Release script for fresh-editor 5 | # Usage: ./scripts/release.sh [--dry-run] 6 | 7 | DRY_RUN=false 8 | if [[ "${1:-}" == "--dry-run" ]]; then 9 | DRY_RUN=true 10 | echo "=== DRY RUN MODE ===" 11 | fi 12 | 13 | # Colors for output 14 | RED='\033[0;31m' 15 | GREEN='\033[0;32m' 16 | YELLOW='\033[1;33m' 17 | NC='\033[0m' # No Color 18 | 19 | info() { echo -e "${GREEN}[INFO]${NC} $1"; } 20 | warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } 21 | error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } 22 | 23 | # Ensure we're in the repo root 24 | cd "$(dirname "$0")/.." 25 | 26 | # Check for uncommitted changes 27 | if [[ -n $(git status --porcelain) ]]; then 28 | error "Uncommitted changes detected. Please commit or stash them first." 29 | fi 30 | 31 | # Get current version from Cargo.toml 32 | CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') 33 | info "Current version: $CURRENT_VERSION" 34 | 35 | # Parse version components 36 | IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" 37 | 38 | # Bump minor version, reset patch 39 | NEW_MINOR=$((MINOR + 1)) 40 | NEW_VERSION="$MAJOR.$NEW_MINOR.0" 41 | info "New version: $NEW_VERSION" 42 | 43 | if $DRY_RUN; then 44 | info "[DRY RUN] Would update Cargo.toml version to $NEW_VERSION" 45 | else 46 | # Update version in Cargo.toml 47 | sed -i "s/^version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" Cargo.toml 48 | info "Updated Cargo.toml" 49 | fi 50 | 51 | # Regenerate Cargo.lock 52 | info "Updating Cargo.lock..." 53 | if $DRY_RUN; then 54 | info "[DRY RUN] Would run: cargo check" 55 | else 56 | cargo check --quiet 57 | fi 58 | 59 | # Run tests 60 | info "Running tests..." 61 | if $DRY_RUN; then 62 | info "[DRY RUN] Would run: cargo test" 63 | else 64 | cargo test --quiet 65 | fi 66 | 67 | # Dry run cargo publish 68 | info "Running cargo publish --dry-run..." 69 | cargo publish --dry-run 70 | 71 | if $DRY_RUN; then 72 | info "[DRY RUN] Would create git commit and tag v$NEW_VERSION" 73 | info "[DRY RUN] Would push to origin" 74 | info "[DRY RUN] Would run: cargo publish" 75 | 76 | # Revert Cargo.toml change in dry run 77 | git checkout Cargo.toml 2>/dev/null || true 78 | 79 | echo "" 80 | info "=== DRY RUN COMPLETE ===" 81 | info "Run without --dry-run to perform the actual release" 82 | exit 0 83 | fi 84 | 85 | # Commit version bump 86 | info "Committing version bump..." 87 | git add Cargo.toml Cargo.lock 2>/dev/null || git add Cargo.toml 88 | git commit -m "Release v$NEW_VERSION" 89 | 90 | # Create and push tag 91 | TAG="v$NEW_VERSION" 92 | info "Creating tag $TAG..." 93 | git tag "$TAG" 94 | 95 | # Push commit and tag 96 | info "Pushing to origin..." 97 | git push origin HEAD 98 | git push origin "$TAG" 99 | 100 | # Publish to crates.io 101 | info "Publishing to crates.io..." 102 | cargo publish 103 | 104 | echo "" 105 | info "=== RELEASE COMPLETE ===" 106 | info "Released version $NEW_VERSION" 107 | info " - Tag: $TAG" 108 | info " - crates.io: https://crates.io/crates/fresh-editor" 109 | info " - GitHub release will be created by cargo-dist CI" 110 | -------------------------------------------------------------------------------- /examples/script_mode_demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Demo script showing how to use Fresh editor in script mode 3 | # This demonstrates programmatic control of the editor via JSON commands 4 | 5 | set -e 6 | 7 | echo "=== Fresh Editor Script Mode Demo ===" 8 | echo 9 | 10 | # Check if fresh is built 11 | if ! cargo build --quiet 2>/dev/null; then 12 | echo "Error: Failed to build fresh editor" 13 | exit 1 14 | fi 15 | 16 | echo "1. Starting editor in script mode and typing some code..." 17 | echo 18 | 19 | # Create a temporary file for commands 20 | COMMANDS=$(mktemp) 21 | trap "rm -f $COMMANDS" EXIT 22 | 23 | cat > "$COMMANDS" << 'EOF' 24 | {"type": "type_text", "text": "// Demo: Script Mode in Action"} 25 | {"type": "key", "code": "Enter"} 26 | {"type": "key", "code": "Enter"} 27 | {"type": "type_text", "text": "fn greet(name: &str) {"} 28 | {"type": "key", "code": "Enter"} 29 | {"type": "type_text", "text": " println!(\"Hello, {}!\", name);"} 30 | {"type": "key", "code": "Enter"} 31 | {"type": "type_text", "text": "}"} 32 | {"type": "render"} 33 | {"type": "status"} 34 | {"type": "get_buffer"} 35 | {"type": "export_test", "test_name": "test_demo_session"} 36 | {"type": "quit"} 37 | EOF 38 | 39 | # Send commands to the editor and parse responses 40 | cat "$COMMANDS" | cargo run --quiet -- --script-mode 2>/dev/null | while read -r line; do 41 | # Parse the response type 42 | type=$(echo "$line" | python3 -c "import json, sys; print(json.loads(sys.stdin.read())['type'])") 43 | 44 | case "$type" in 45 | "ok") 46 | msg=$(echo "$line" | python3 -c "import json, sys; d=json.loads(sys.stdin.read()); print(d.get('message', 'Success'))") 47 | echo "✓ $msg" 48 | ;; 49 | "screen") 50 | echo "--- Screen Render ---" 51 | echo "$line" | python3 -c "import json, sys; print(json.loads(sys.stdin.read())['content'])" 52 | echo "--------------------" 53 | ;; 54 | "status") 55 | echo "--- Status ---" 56 | echo "$line" | python3 -m json.tool 57 | echo "--------------" 58 | ;; 59 | "buffer") 60 | echo "--- Buffer Content ---" 61 | echo "$line" | python3 -c "import json, sys; print(json.loads(sys.stdin.read())['content'])" 62 | echo "---------------------" 63 | ;; 64 | "test_code") 65 | echo "--- Generated Test ---" 66 | echo "$line" | python3 -c "import json, sys; print(json.loads(sys.stdin.read())['code'])" 67 | echo "---------------------" 68 | ;; 69 | "error") 70 | msg=$(echo "$line" | python3 -c "import json, sys; print(json.loads(sys.stdin.read())['message'])") 71 | echo "✗ Error: $msg" 72 | ;; 73 | esac 74 | done 75 | 76 | echo 77 | echo "2. Demo complete!" 78 | echo 79 | echo "Key features demonstrated:" 80 | echo " - Typing text programmatically" 81 | echo " - Sending keyboard events (Enter, etc.)" 82 | echo " - Rendering the screen" 83 | echo " - Getting editor status" 84 | echo " - Retrieving buffer content" 85 | echo " - Generating test code from interactions" 86 | echo 87 | echo "This script mode enables:" 88 | echo " - LLM control of the editor" 89 | echo " - Automated testing" 90 | echo " - Integration with external tools" 91 | echo " - Recording and replaying editor sessions" 92 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | - develop 10 | 11 | env: 12 | CARGO_TERM_COLOR: always 13 | 14 | # ensure that the workflow is only triggered once per PR, subsequent pushes to the PR will cancel 15 | # and restart the workflow. See https://docs.github.com/en/actions/using-jobs/using-concurrency 16 | concurrency: 17 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | fmt: 22 | name: fmt 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v6 27 | - name: Install Rust stable 28 | uses: dtolnay/rust-toolchain@stable 29 | with: 30 | components: rustfmt 31 | - name: check formatting 32 | run: cargo fmt -- --check 33 | - name: Cache Cargo dependencies 34 | uses: Swatinem/rust-cache@v2 35 | clippy: 36 | name: clippy 37 | runs-on: ubuntu-latest 38 | permissions: 39 | checks: write 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v6 43 | - name: Install Rust stable 44 | uses: dtolnay/rust-toolchain@stable 45 | with: 46 | components: clippy 47 | - name: Run clippy action 48 | uses: clechasseur/rs-clippy-check@v5 49 | - name: Cache Cargo dependencies 50 | uses: Swatinem/rust-cache@v2 51 | doc: 52 | # run docs generation on nightly rather than stable. This enables features like 53 | # https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an 54 | # API be documented as only available in some specific platforms. 55 | name: doc 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v6 59 | - name: Install Rust nightly 60 | uses: dtolnay/rust-toolchain@nightly 61 | - name: Run cargo doc 62 | run: cargo doc --no-deps --all-features 63 | env: 64 | RUSTDOCFLAGS: --cfg docsrs 65 | test: 66 | runs-on: ${{ matrix.os }} 67 | name: test ${{ matrix.os }} 68 | strategy: 69 | fail-fast: false 70 | matrix: 71 | os: [ubuntu-latest, macos-latest, windows-latest] 72 | steps: 73 | # if your project needs OpenSSL, uncomment this to fix Windows builds. 74 | # it's commented out by default as the install command takes 5-10m. 75 | # - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append 76 | # if: runner.os == 'Windows' 77 | # - run: vcpkg install openssl:x64-windows-static-md 78 | # if: runner.os == 'Windows' 79 | - uses: actions/checkout@v6 80 | - name: Install Rust 81 | uses: dtolnay/rust-toolchain@stable 82 | - name: Install cargo-nextest 83 | uses: taiki-e/install-action@v2 84 | with: 85 | tool: cargo-nextest 86 | # enable this ci template to run regardless of whether the lockfile is checked in or not 87 | - name: cargo generate-lockfile 88 | if: hashFiles('Cargo.lock') == '' 89 | run: cargo generate-lockfile 90 | - name: Run tests 91 | run: cargo nextest run -j=4 --no-fail-fast --locked --all-features --all-targets 92 | - name: Cache Cargo dependencies 93 | uses: Swatinem/rust-cache@v2 94 | -------------------------------------------------------------------------------- /plugins/examples/buffer_query_demo.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Buffer Query Demo Plugin 5 | * Demonstrates the buffer query APIs in Phase 2 6 | */ 7 | 8 | // Show buffer info 9 | globalThis.show_buffer_info_demo = function(): void { 10 | const bufferId = editor.getActiveBufferId(); 11 | const info = editor.getBufferInfo(bufferId); 12 | 13 | if (info) { 14 | const msg = `Buffer ${info.id}: ${info.path || "[No Name]"} (${ 15 | info.modified ? "modified" : "saved" 16 | }, ${info.length} bytes)`; 17 | editor.setStatus(msg); 18 | } else { 19 | editor.setStatus("No buffer info available"); 20 | } 21 | }; 22 | 23 | editor.registerCommand( 24 | "Query Demo: Show Buffer Info", 25 | "Display information about the current buffer", 26 | "show_buffer_info_demo", 27 | "normal" 28 | ); 29 | 30 | // Show cursor position with selection info 31 | globalThis.show_cursor_info_demo = function(): void { 32 | const cursor = editor.getPrimaryCursor(); 33 | 34 | if (cursor) { 35 | let msg: string; 36 | if (cursor.selection) { 37 | msg = `Cursor at ${cursor.position}, selection: ${cursor.selection.start}-${cursor.selection.end} (${ 38 | cursor.selection.end - cursor.selection.start 39 | } chars)`; 40 | } else { 41 | msg = `Cursor at byte position ${cursor.position} (no selection)`; 42 | } 43 | editor.setStatus(msg); 44 | } else { 45 | editor.setStatus("No cursor info available"); 46 | } 47 | }; 48 | 49 | editor.registerCommand( 50 | "Query Demo: Show Cursor Position", 51 | "Display cursor position and selection info", 52 | "show_cursor_info_demo", 53 | "normal" 54 | ); 55 | 56 | // Count all cursors (multi-cursor support) 57 | globalThis.count_cursors_demo = function(): void { 58 | const cursors = editor.getAllCursors(); 59 | editor.setStatus(`Active cursors: ${cursors.length}`); 60 | }; 61 | 62 | editor.registerCommand( 63 | "Query Demo: Count All Cursors", 64 | "Display the number of active cursors", 65 | "count_cursors_demo", 66 | "normal" 67 | ); 68 | 69 | // List all buffers 70 | globalThis.list_all_buffers_demo = function(): void { 71 | const buffers = editor.listBuffers(); 72 | let modifiedCount = 0; 73 | 74 | for (const buf of buffers) { 75 | if (buf.modified) { 76 | modifiedCount++; 77 | } 78 | } 79 | 80 | editor.setStatus(`Open buffers: ${buffers.length} (${modifiedCount} modified)`); 81 | }; 82 | 83 | editor.registerCommand( 84 | "Query Demo: List All Buffers", 85 | "Show count of open buffers", 86 | "list_all_buffers_demo", 87 | "normal" 88 | ); 89 | 90 | // Show viewport info 91 | globalThis.show_viewport_demo = function(): void { 92 | const vp = editor.getViewport(); 93 | 94 | if (vp) { 95 | const msg = `Viewport: ${vp.width}x${vp.height}, top_byte=${vp.top_byte}, left_col=${vp.left_column}`; 96 | editor.setStatus(msg); 97 | } else { 98 | editor.setStatus("No viewport info available"); 99 | } 100 | }; 101 | 102 | editor.registerCommand( 103 | "Query Demo: Show Viewport Info", 104 | "Display viewport dimensions and scroll position", 105 | "show_viewport_demo", 106 | "normal" 107 | ); 108 | 109 | editor.setStatus("Buffer Query Demo plugin loaded! Try the 'Query Demo' commands."); 110 | editor.debug("Buffer Query Demo plugin initialized (TypeScript version)"); 111 | -------------------------------------------------------------------------------- /types/fresh.d.ts.template: -------------------------------------------------------------------------------- 1 | /** 2 | * Fresh Editor TypeScript Plugin API 3 | * 4 | * This file provides type definitions for the Fresh editor's TypeScript plugin system. 5 | * Plugins have access to the global `editor` object which provides methods to: 6 | * - Query editor state (buffers, cursors, viewports) 7 | * - Modify buffer content (insert, delete text) 8 | * - Add visual decorations (overlays, highlighting) 9 | * - Interact with the editor UI (status messages, prompts) 10 | * 11 | * Note: types/fresh.d.ts is auto-generated from this template and src/ts_runtime.rs 12 | * 13 | * ## Core Concepts 14 | * 15 | * ### Buffers 16 | * A buffer holds text content and may or may not be associated with a file. 17 | * Each buffer has a unique numeric ID that persists for the editor session. 18 | * Buffers track their content, modification state, cursor positions, and path. 19 | * All text operations (insert, delete, read) use byte offsets, not character indices. 20 | * 21 | * ### Splits 22 | * A split is a viewport pane that displays a buffer. The editor can have multiple 23 | * splits arranged in a tree layout. Each split shows exactly one buffer, but the 24 | * same buffer can be displayed in multiple splits. Use split IDs to control which 25 | * pane displays which buffer. 26 | * 27 | * ### Virtual Buffers 28 | * Special buffers created by plugins to display structured data like search results, 29 | * diagnostics, or git logs. Virtual buffers support text properties (metadata attached 30 | * to text ranges) that plugins can query when the user selects a line. Unlike normal 31 | * buffers, virtual buffers are typically read-only and not backed by files. 32 | * 33 | * ### Text Properties 34 | * Metadata attached to text ranges in virtual buffers. Each entry has text content 35 | * and a properties object with arbitrary key-value pairs. Use `getTextPropertiesAtCursor` 36 | * to retrieve properties at the cursor position (e.g., to get file/line info for "go to"). 37 | * 38 | * ### Overlays 39 | * Visual decorations applied to buffer text without modifying content. Overlays can 40 | * change text color and add underlines. Use overlay IDs to manage them; prefix IDs 41 | * enable batch removal (e.g., "lint:" prefix for all linter highlights). 42 | * 43 | * ### Modes 44 | * Keybinding contexts that determine how keypresses are interpreted. Each buffer has 45 | * a mode (e.g., "normal", "insert", "special"). Custom modes can inherit from parents 46 | * and define buffer-local keybindings. Virtual buffers typically use custom modes. 47 | */ 48 | 49 | declare global { 50 | /** 51 | * Global editor API object available to all TypeScript plugins 52 | */ 53 | const editor: EditorAPI; 54 | } 55 | 56 | /** 57 | * Buffer identifier (unique numeric ID) 58 | */ 59 | type BufferId = number; 60 | 61 | /** View token wire format for view transforms */ 62 | interface ViewTokenWire { 63 | /** Source byte offset (null for injected view-only content) */ 64 | source_offset: number | null; 65 | /** Token kind: Text, Newline, Space, or Break */ 66 | kind: ViewTokenWireKind; 67 | } 68 | 69 | /** View token kind discriminated union */ 70 | type ViewTokenWireKind = 71 | | { Text: string } 72 | | "Newline" 73 | | "Space" 74 | | "Break"; 75 | 76 | /** Layout hints for compose mode */ 77 | interface LayoutHints { 78 | /** Optional compose width for centering/wrapping */ 79 | compose_width?: number | null; 80 | /** Optional column guides for tables */ 81 | column_guides?: number[] | null; 82 | } 83 | 84 | -------------------------------------------------------------------------------- /src/view/stream.rs: -------------------------------------------------------------------------------- 1 | //! View stream representation for rendering 2 | //! 3 | //! This module defines a lightweight, source-anchored view stream that can be 4 | //! transformed (e.g., by plugins) before layout. It keeps mappings back to 5 | //! source offsets for hit-testing and cursor positioning. 6 | 7 | use crate::state::EditorState; 8 | use crate::view::overlay::OverlayFace; 9 | use crate::view::virtual_text::VirtualTextPosition; 10 | use ratatui::style::Style; 11 | 12 | /// Kind of token in the view stream 13 | #[derive(Debug, Clone, PartialEq)] 14 | pub enum ViewTokenKind { 15 | /// Plain text slice 16 | Text(String), 17 | /// Newline in the source 18 | Newline, 19 | /// Whitespace (commonly used when transforming newlines to spaces) 20 | Space, 21 | /// Virtual text (injected, not in source) 22 | VirtualText { 23 | text: String, 24 | style: Style, 25 | position: VirtualTextPosition, 26 | priority: i32, 27 | }, 28 | /// Style span start/end (source-anchored) 29 | StyleStart(Style), 30 | StyleEnd, 31 | /// Overlay span (for decorations) 32 | Overlay(OverlayFace), 33 | } 34 | 35 | /// A view token with source mapping 36 | #[derive(Debug, Clone, PartialEq)] 37 | pub struct ViewToken { 38 | /// Byte offset in source for this token, if any 39 | pub source_offset: Option, 40 | /// The token kind 41 | pub kind: ViewTokenKind, 42 | } 43 | 44 | /// A view stream for a viewport 45 | #[derive(Debug, Clone)] 46 | pub struct ViewStream { 47 | pub tokens: Vec, 48 | /// Mapping from view token index to source offset (if present) 49 | pub source_map: Vec>, 50 | } 51 | 52 | impl ViewStream { 53 | pub fn new() -> Self { 54 | Self { 55 | tokens: Vec::new(), 56 | source_map: Vec::new(), 57 | } 58 | } 59 | 60 | pub fn push(&mut self, token: ViewToken) { 61 | self.source_map.push(token.source_offset); 62 | self.tokens.push(token); 63 | } 64 | } 65 | 66 | /// Build a base view stream for a viewport range (byte offsets) 67 | /// This stream contains plain text and newline tokens only; overlays and virtual 68 | /// text are not included here (they remain applied during rendering). 69 | pub fn build_base_stream(state: &mut EditorState, start: usize, end: usize) -> ViewStream { 70 | let mut stream = ViewStream::new(); 71 | 72 | if start >= end { 73 | return stream; 74 | } 75 | 76 | let text = state.get_text_range(start, end); 77 | 78 | let mut current_offset = start; 79 | let mut buffer = String::new(); 80 | 81 | for ch in text.chars() { 82 | if ch == '\n' { 83 | if !buffer.is_empty() { 84 | stream.push(ViewToken { 85 | source_offset: Some(current_offset - buffer.len()), 86 | kind: ViewTokenKind::Text(buffer.clone()), 87 | }); 88 | buffer.clear(); 89 | } 90 | stream.push(ViewToken { 91 | source_offset: Some(current_offset), 92 | kind: ViewTokenKind::Newline, 93 | }); 94 | current_offset += 1; 95 | } else { 96 | buffer.push(ch); 97 | current_offset += ch.len_utf8(); 98 | } 99 | } 100 | 101 | if !buffer.is_empty() { 102 | stream.push(ViewToken { 103 | source_offset: Some(current_offset - buffer.len()), 104 | kind: ViewTokenKind::Text(buffer), 105 | }); 106 | } 107 | 108 | stream 109 | } 110 | -------------------------------------------------------------------------------- /tests/common/snapshots/e2e_tests__common__visual_testing__Comprehensive UI B__state_b.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/common/visual_testing.rs 3 | expression: "&screen_text" 4 | --- 5 | File Edit View Selection Go Explorer Help 6 | file1.rs × × 7 | 1 │ // File 1 - Contains a very long line that will require horizontal scrolling to see the end of it completely wh█ 8 | 2 │ fn main() { █ 9 | 3 │ let very_long_variable_name_that_extends_beyond_normal_view = "This is a string with a lot of content that █ 10 | 4 │ println!("{}", very_long_variable_name_that_extends_beyond_normal_view); █ 11 | 5 │ } █ 12 | 6 │ █ 13 | ~ █ 14 | ~ █ 15 | ~ █ 16 | ~ █ 17 | ~ █ 18 | ~ █ 19 | ~ █ 20 | ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 21 | file1.rs × file2.rs* × × 22 | 1 │ █ 23 | 2 │ fn helper() { █ 24 | 3 │ let x = 42; █ 25 | 4 │ let y = x * 2; █ 26 | 5 │ println!("Result: {}", y); █ 27 | 6 │ } █ 28 | 7 │ █ 29 | ~ █ 30 | ~ █ 31 | ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ 32 | │ Show Signature Help Show function parameter hints builtin│ 33 | └──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ 34 | Command: help 35 | -------------------------------------------------------------------------------- /plugins/examples/virtual_buffer_demo.ts: -------------------------------------------------------------------------------- 1 | // Virtual Buffer Demo Plugin 2 | // Demonstrates the virtual buffer API for creating diagnostic panels, search results, etc. 3 | 4 | // Register a command to show a demo virtual buffer 5 | editor.registerCommand( 6 | "Virtual Buffer Demo", 7 | "Show a demo virtual buffer with sample diagnostics", 8 | "show_virtual_buffer_demo", 9 | "normal" 10 | ); 11 | 12 | // Define a custom mode for the demo buffer 13 | editor.defineMode( 14 | "demo-list", // mode name 15 | null, // no parent mode 16 | [ 17 | ["Return", "demo_goto_item"], 18 | ["n", "demo_next_item"], 19 | ["p", "demo_prev_item"], 20 | ["q", "demo_close_buffer"], 21 | ], 22 | true // read-only 23 | ); 24 | 25 | // Register actions for the mode 26 | globalThis.demo_goto_item = () => { 27 | const bufferId = editor.getActiveBufferId(); 28 | const props = editor.getTextPropertiesAtCursor(bufferId); 29 | 30 | if (props.length > 0) { 31 | const location = props[0].location as { file: string; line: number; column: number } | undefined; 32 | if (location) { 33 | editor.openFile(location.file, location.line, location.column || 0); 34 | editor.setStatus(`Jumped to ${location.file}:${location.line}`); 35 | } else { 36 | editor.setStatus("No location info for this item"); 37 | } 38 | } else { 39 | editor.setStatus("No properties at cursor position"); 40 | } 41 | }; 42 | 43 | globalThis.demo_next_item = () => { 44 | editor.setStatus("Next item (not implemented in demo)"); 45 | }; 46 | 47 | globalThis.demo_prev_item = () => { 48 | editor.setStatus("Previous item (not implemented in demo)"); 49 | }; 50 | 51 | globalThis.demo_close_buffer = () => { 52 | editor.setStatus("Close buffer (not implemented in demo)"); 53 | }; 54 | 55 | // Main action: show the virtual buffer 56 | globalThis.show_virtual_buffer_demo = async () => { 57 | editor.setStatus("Creating virtual buffer demo..."); 58 | 59 | // Create sample diagnostic entries 60 | const entries = [ 61 | { 62 | text: "[ERROR] src/main.rs:42:10 - undefined variable 'foo'\n", 63 | properties: { 64 | severity: "error", 65 | location: { file: "src/main.rs", line: 42, column: 10 }, 66 | message: "undefined variable 'foo'", 67 | }, 68 | }, 69 | { 70 | text: "[WARNING] src/lib.rs:100:5 - unused variable 'bar'\n", 71 | properties: { 72 | severity: "warning", 73 | location: { file: "src/lib.rs", line: 100, column: 5 }, 74 | message: "unused variable 'bar'", 75 | }, 76 | }, 77 | { 78 | text: "[INFO] src/utils.rs:25:1 - consider using 'if let' instead of 'match'\n", 79 | properties: { 80 | severity: "info", 81 | location: { file: "src/utils.rs", line: 25, column: 1 }, 82 | message: "consider using 'if let' instead of 'match'", 83 | }, 84 | }, 85 | { 86 | text: "[HINT] src/config.rs:8:20 - type annotation unnecessary\n", 87 | properties: { 88 | severity: "hint", 89 | location: { file: "src/config.rs", line: 8, column: 20 }, 90 | message: "type annotation unnecessary", 91 | }, 92 | }, 93 | ]; 94 | 95 | // Create the virtual buffer in a horizontal split 96 | try { 97 | const bufferId = await editor.createVirtualBufferInSplit({ 98 | name: "*Demo Diagnostics*", 99 | mode: "demo-list", 100 | read_only: true, 101 | entries: entries, 102 | ratio: 0.7, // Original pane takes 70%, demo buffer takes 30% 103 | panel_id: "demo-diagnostics", 104 | show_line_numbers: false, 105 | show_cursors: true, 106 | }); 107 | 108 | editor.setStatus(`Created demo virtual buffer (ID: ${bufferId}) with ${entries.length} items - Press RET to jump to location`); 109 | } catch (error) { 110 | const errorMessage = error instanceof Error ? error.message : String(error); 111 | editor.setStatus(`Failed to create virtual buffer: ${errorMessage}`); 112 | } 113 | }; 114 | 115 | // Log that the plugin loaded 116 | editor.debug("Virtual buffer demo plugin loaded"); 117 | -------------------------------------------------------------------------------- /tests/focused_bug_test.rs: -------------------------------------------------------------------------------- 1 | // Focused test to reproduce the auto-indent bug found by property testing 2 | mod common; 3 | 4 | use common::harness::EditorTestHarness; 5 | use crossterm::event::{KeyCode, KeyModifiers}; 6 | 7 | #[test] 8 | fn test_enter_after_brace_no_autoindent() { 9 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 10 | harness.enable_shadow_validation(); 11 | 12 | // Type a brace 13 | harness.type_text("{").unwrap(); 14 | 15 | // Press Enter 16 | harness 17 | .send_key(KeyCode::Enter, KeyModifiers::NONE) 18 | .unwrap(); 19 | 20 | // Should be just "{\n", not "{\n " (no auto-indent) 21 | let buffer = harness.get_buffer_content().unwrap(); 22 | let shadow = harness.get_shadow_string(); 23 | 24 | println!("Buffer: {:?}", buffer); 25 | println!("Shadow: {:?}", shadow); 26 | 27 | assert_eq!( 28 | buffer, shadow, 29 | "Buffer should match shadow (no auto-indent)" 30 | ); 31 | assert_eq!(buffer, "{\n", "Buffer should be just brace and newline"); 32 | } 33 | 34 | #[test] 35 | fn test_simple_sequence_from_e2e() { 36 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 37 | harness.enable_shadow_validation(); 38 | 39 | // Simplified from test_basic_editing_operations 40 | harness.type_text("Hello").unwrap(); 41 | harness.type_text("World").unwrap(); 42 | 43 | // Move left 5 times to get back to between Hello and World 44 | for _ in 0..5 { 45 | harness.send_key(KeyCode::Left, KeyModifiers::NONE).unwrap(); 46 | } 47 | 48 | // Press Enter to create newline 49 | harness 50 | .send_key(KeyCode::Enter, KeyModifiers::NONE) 51 | .unwrap(); 52 | 53 | let buffer = harness.get_buffer_content().unwrap(); 54 | let shadow = harness.get_shadow_string(); 55 | 56 | println!("Buffer: {:?}", buffer); 57 | println!("Shadow: {:?}", shadow); 58 | 59 | assert_eq!(buffer, shadow, "Buffer should match shadow"); 60 | assert_eq!( 61 | buffer, "Hello\nWorld", 62 | "Buffer should have newline between words" 63 | ); 64 | } 65 | 66 | #[test] 67 | fn test_minimal_proptest_failure() { 68 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 69 | harness.enable_shadow_validation(); 70 | 71 | println!("\n=== Step-by-step debugging ==="); 72 | 73 | // Step 1: Enter 74 | harness 75 | .send_key(KeyCode::Enter, KeyModifiers::NONE) 76 | .unwrap(); 77 | println!( 78 | "After Enter: buffer={:?}, cursor={}", 79 | harness.get_buffer_content().unwrap(), 80 | harness.cursor_position() 81 | ); 82 | 83 | // Step 2: Type "a0" 84 | harness.type_text("a0").unwrap(); 85 | println!( 86 | "After 'a0': buffer={:?}, cursor={}", 87 | harness.get_buffer_content().unwrap(), 88 | harness.cursor_position() 89 | ); 90 | 91 | // Step 3: Left 92 | harness.send_key(KeyCode::Left, KeyModifiers::NONE).unwrap(); 93 | println!( 94 | "After Left: buffer={:?}, cursor={}", 95 | harness.get_buffer_content().unwrap(), 96 | harness.cursor_position() 97 | ); 98 | 99 | // Step 4: Home 100 | harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap(); 101 | println!( 102 | "After Home: buffer={:?}, cursor={}", 103 | harness.get_buffer_content().unwrap(), 104 | harness.cursor_position() 105 | ); 106 | 107 | // Step 5: Type "b" 108 | harness.type_text("b").unwrap(); 109 | println!( 110 | "After 'b': buffer={:?}, cursor={}", 111 | harness.get_buffer_content().unwrap(), 112 | harness.cursor_position() 113 | ); 114 | 115 | let buffer = harness.get_buffer_content().unwrap(); 116 | let shadow = harness.get_shadow_string(); 117 | 118 | println!("\n=== Final state ==="); 119 | println!("Buffer: {:?}", buffer); 120 | println!("Shadow: {:?}", shadow); 121 | println!("Cursor: {}", harness.cursor_position()); 122 | 123 | assert_eq!(buffer, shadow, "Buffer should match shadow"); 124 | assert_eq!(buffer, "\nba0", "Should be newline, b, a, 0"); 125 | } 126 | -------------------------------------------------------------------------------- /tests/merge_parser_test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for merge conflict parser regex 3 | * Run with: node tests/merge_parser_test.js 4 | */ 5 | 6 | // The exact regex from the plugin 7 | // Note: use \r?\n to handle both LF and CRLF line endings 8 | const conflictRegex = /^<<<<<<<[^\r\n]*\r?\n([\s\S]*?)(?:^\|\|\|\|\|\|\|[^\r\n]*\r?\n([\s\S]*?))?^=======\r?\n([\s\S]*?)^>>>>>>>[^\r\n]*$/gm; 9 | 10 | function parseConflicts(content) { 11 | const conflicts = []; 12 | 13 | // Reset regex state 14 | conflictRegex.lastIndex = 0; 15 | 16 | let match; 17 | while ((match = conflictRegex.exec(content)) !== null) { 18 | conflicts.push({ 19 | startOffset: match.index, 20 | endOffset: match.index + match[0].length, 21 | ours: match[1] || "", 22 | base: match[2] || "", 23 | theirs: match[3] || "", 24 | }); 25 | } 26 | 27 | return conflicts; 28 | } 29 | 30 | // Test cases 31 | const tests = [ 32 | { 33 | name: "Simple 2-way conflict", 34 | content: `<<<<<<< HEAD 35 | ours 36 | ======= 37 | theirs 38 | >>>>>>> branch 39 | `, 40 | expected: { count: 1, hasBase: false }, 41 | }, 42 | { 43 | name: "Diff3-style conflict with base section", 44 | content: `} 45 | 46 | static int showdf(char *mntdir, struct obd_statfs *stat, 47 | <<<<<<< HEAD 48 | char *uuid, enum mntdf_flags flags, 49 | char *type, int index, int rc) 50 | ||||||| parent of a3f05d81f6 (LU-18243 lfs: Add --output and --no-header options to lfs df command) 51 | const char *uuid, enum mntdf_flags flags, 52 | char *type, int index, int rc) 53 | ======= 54 | const char *uuid, enum mntdf_flags flags, 55 | char *type, int index, int rc, enum showdf_fields fields, 56 | enum showdf_fields *field_order, int field_count) 57 | >>>>>>> a3f05d81f6 (LU-18243 lfs: Add --output and --no-header options to lfs df command) 58 | { 59 | int base = flags & MNTDF_DECIMAL ? 1000 : 1024; 60 | char *suffix = flags & MNTDF_DECIMAL ? "kMGTPEZY" : "KMGTPEZY"; 61 | int shift = flags & MNTDF_COOKED ? 0 : 10; 62 | `, 63 | expected: { count: 1, hasBase: true }, 64 | }, 65 | { 66 | name: "Multiple conflicts", 67 | content: `<<<<<<< HEAD 68 | first ours 69 | ======= 70 | first theirs 71 | >>>>>>> branch 72 | 73 | middle text 74 | 75 | <<<<<<< HEAD 76 | second ours 77 | ======= 78 | second theirs 79 | >>>>>>> branch 80 | `, 81 | expected: { count: 2, hasBase: false }, 82 | }, 83 | { 84 | name: "Conflict with multiline content", 85 | content: `<<<<<<< HEAD 86 | line 1 87 | line 2 88 | line 3 89 | ======= 90 | other line 1 91 | other line 2 92 | >>>>>>> branch 93 | `, 94 | expected: { count: 1, hasBase: false }, 95 | }, 96 | ]; 97 | 98 | // Run tests 99 | let passed = 0; 100 | let failed = 0; 101 | 102 | for (const test of tests) { 103 | const result = parseConflicts(test.content); 104 | 105 | let success = true; 106 | let error = ""; 107 | 108 | if (result.length !== test.expected.count) { 109 | success = false; 110 | error = `Expected ${test.expected.count} conflicts, got ${result.length}`; 111 | } else if (test.expected.hasBase !== undefined && result.length > 0) { 112 | const hasBase = result[0].base.trim().length > 0; 113 | if (hasBase !== test.expected.hasBase) { 114 | success = false; 115 | error = `Expected hasBase=${test.expected.hasBase}, got hasBase=${hasBase}`; 116 | } 117 | } 118 | 119 | if (success) { 120 | console.log(`✓ ${test.name}`); 121 | passed++; 122 | } else { 123 | console.log(`✗ ${test.name}: ${error}`); 124 | console.log(" Content preview:", test.content.substring(0, 100).replace(/\n/g, "\\n") + "..."); 125 | if (result.length > 0) { 126 | console.log(" Parsed ours:", JSON.stringify(result[0].ours.substring(0, 50))); 127 | console.log(" Parsed base:", JSON.stringify(result[0].base.substring(0, 50))); 128 | console.log(" Parsed theirs:", JSON.stringify(result[0].theirs.substring(0, 50))); 129 | } 130 | failed++; 131 | } 132 | } 133 | 134 | console.log(`\n${passed} passed, ${failed} failed`); 135 | 136 | if (failed > 0) { 137 | process.exit(1); 138 | } 139 | -------------------------------------------------------------------------------- /tests/common/fixtures.rs: -------------------------------------------------------------------------------- 1 | // Test file fixtures 2 | 3 | use std::fs; 4 | use std::io::Write; 5 | use std::path::PathBuf; 6 | use std::sync::{Mutex, OnceLock}; 7 | use tempfile::TempDir; 8 | 9 | /// Manages temporary test files 10 | pub struct TestFixture { 11 | _temp_dir: TempDir, 12 | pub path: PathBuf, 13 | } 14 | 15 | impl TestFixture { 16 | /// Create a new temporary file with given content 17 | pub fn new(filename: &str, content: &str) -> std::io::Result { 18 | let temp_dir = tempfile::tempdir()?; 19 | let path = temp_dir.path().join(filename); 20 | 21 | let mut file = fs::File::create(&path)?; 22 | file.write_all(content.as_bytes())?; 23 | file.flush()?; 24 | 25 | Ok(TestFixture { 26 | _temp_dir: temp_dir, 27 | path, 28 | }) 29 | } 30 | 31 | /// Create an empty temporary file 32 | pub fn empty(filename: &str) -> std::io::Result { 33 | Self::new(filename, "") 34 | } 35 | 36 | /// Read the current content of the file 37 | pub fn read_content(&self) -> std::io::Result { 38 | fs::read_to_string(&self.path) 39 | } 40 | 41 | /// Get or create a shared large file (61MB) for all tests. 42 | /// Uses locking to ensure only one test creates the file, even when tests run in parallel. 43 | /// All concurrent tests share the same file, which is much more efficient than creating 44 | /// separate files per test. 45 | /// 46 | /// The file persists across test runs in the system temp directory and is reused. 47 | /// 48 | /// Note: The test_name parameter is kept for API compatibility but is no longer used 49 | /// since all tests share the same file. 50 | pub fn big_txt_for_test(_test_name: &str) -> std::io::Result { 51 | // Global lock and path storage for thread-safe initialization 52 | static BIG_TXT_INIT: OnceLock> = OnceLock::new(); 53 | 54 | let path_mutex = BIG_TXT_INIT.get_or_init(|| { 55 | // Create path in system temp directory with predictable name 56 | let path = std::env::temp_dir().join("fresh-test-BIG.txt"); 57 | Mutex::new(path) 58 | }); 59 | 60 | // Lock to ensure only one test creates the file 61 | let path = path_mutex.lock().unwrap().clone(); 62 | 63 | // Check if file already exists 64 | if !path.exists() { 65 | eprintln!("Generating shared large test file (61MB, one-time)..."); 66 | let mut file = fs::File::create(&path)?; 67 | 68 | // Each line: "@00000000: " + 'x' repeated to fill ~80 chars total + "\n" 69 | // Byte offset prefix is 12 chars ("@00000000: "), so ~68 x's per line 70 | let size_mb = 61; 71 | let target_bytes = size_mb * 1024 * 1024; 72 | 73 | let mut byte_offset = 0; 74 | 75 | while byte_offset < target_bytes { 76 | let line = format!("@{:08}: {}\n", byte_offset, "x".repeat(68)); 77 | file.write_all(line.as_bytes())?; 78 | byte_offset += line.len(); 79 | } 80 | 81 | file.flush()?; 82 | let line_count = byte_offset / 81; // Each line is 81 bytes 83 | eprintln!( 84 | "Generated shared large test file with ~{} lines ({} bytes) at {path:?}", 85 | line_count, byte_offset 86 | ); 87 | } 88 | 89 | Ok(path) 90 | } 91 | } 92 | 93 | /// Create a consistent temporary directory for a test 94 | /// This ensures snapshot tests use the same paths on each run 95 | pub fn test_temp_dir(test_name: &str) -> std::io::Result { 96 | let path = std::env::temp_dir().join(format!("editor-test-{test_name}")); 97 | if path.exists() { 98 | fs::remove_dir_all(&path)?; 99 | } 100 | fs::create_dir_all(&path)?; 101 | Ok(path) 102 | } 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use super::*; 107 | 108 | #[test] 109 | fn test_fixture_new() { 110 | let fixture = TestFixture::new("test.txt", "hello world").unwrap(); 111 | assert_eq!(fixture.read_content().unwrap(), "hello world"); 112 | } 113 | 114 | #[test] 115 | fn test_fixture_empty() { 116 | let fixture = TestFixture::empty("empty.txt").unwrap(); 117 | assert_eq!(fixture.read_content().unwrap(), ""); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/e2e/position_history_truncate_debug.rs: -------------------------------------------------------------------------------- 1 | use crate::common::harness::EditorTestHarness; 2 | use crossterm::event::{KeyCode, KeyModifiers}; 3 | 4 | #[test] 5 | fn test_debug_truncate() { 6 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 7 | 8 | // Create 3 buffers 9 | println!("Creating Buffer 1"); 10 | harness.type_text("Buffer 1").unwrap(); 11 | println!( 12 | "Buffer 1 content: {:?}", 13 | harness.get_buffer_content().unwrap() 14 | ); 15 | let hist = &harness.editor().position_history; 16 | println!( 17 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 18 | hist.len(), 19 | hist.current_index(), 20 | hist.can_go_back(), 21 | hist.can_go_forward() 22 | ); 23 | 24 | println!("\nCreating Buffer 2"); 25 | harness 26 | .send_key(KeyCode::Char('n'), KeyModifiers::CONTROL) 27 | .unwrap(); 28 | harness.type_text("Buffer 2").unwrap(); 29 | println!( 30 | "Buffer 2 content: {:?}", 31 | harness.get_buffer_content().unwrap() 32 | ); 33 | let hist = &harness.editor().position_history; 34 | println!( 35 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 36 | hist.len(), 37 | hist.current_index(), 38 | hist.can_go_back(), 39 | hist.can_go_forward() 40 | ); 41 | 42 | println!("\nCreating Buffer 3"); 43 | harness 44 | .send_key(KeyCode::Char('n'), KeyModifiers::CONTROL) 45 | .unwrap(); 46 | harness.type_text("Buffer 3").unwrap(); 47 | println!( 48 | "Buffer 3 content: {:?}", 49 | harness.get_buffer_content().unwrap() 50 | ); 51 | let hist = &harness.editor().position_history; 52 | println!( 53 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 54 | hist.len(), 55 | hist.current_index(), 56 | hist.can_go_back(), 57 | hist.can_go_forward() 58 | ); 59 | 60 | // Navigate back twice 61 | println!("\n=== Navigate back (first) ==="); 62 | harness.send_key(KeyCode::Left, KeyModifiers::ALT).unwrap(); 63 | println!( 64 | "After first back: content = {:?}", 65 | harness.get_buffer_content().unwrap() 66 | ); 67 | let hist = &harness.editor().position_history; 68 | println!( 69 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 70 | hist.len(), 71 | hist.current_index(), 72 | hist.can_go_back(), 73 | hist.can_go_forward() 74 | ); 75 | 76 | println!("\n=== Navigate back (second) ==="); 77 | harness.send_key(KeyCode::Left, KeyModifiers::ALT).unwrap(); 78 | println!( 79 | "After second back: content = {:?}", 80 | harness.get_buffer_content().unwrap() 81 | ); 82 | let hist = &harness.editor().position_history; 83 | println!( 84 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 85 | hist.len(), 86 | hist.current_index(), 87 | hist.can_go_back(), 88 | hist.can_go_forward() 89 | ); 90 | assert_eq!(harness.get_buffer_content().unwrap(), "Buffer 1"); 91 | 92 | // Create a new buffer - this should truncate forward history 93 | println!("\n=== Create Buffer 4 (should truncate forward history) ==="); 94 | harness 95 | .send_key(KeyCode::Char('n'), KeyModifiers::CONTROL) 96 | .unwrap(); 97 | harness.type_text("Buffer 4").unwrap(); 98 | println!( 99 | "Buffer 4 content: {:?}", 100 | harness.get_buffer_content().unwrap() 101 | ); 102 | let hist = &harness.editor().position_history; 103 | println!( 104 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 105 | hist.len(), 106 | hist.current_index(), 107 | hist.can_go_back(), 108 | hist.can_go_forward() 109 | ); 110 | 111 | // Try to navigate forward - should not be able to go to Buffer 2 or 3 112 | println!("\n=== Navigate forward (should stay in Buffer 4) ==="); 113 | harness.send_key(KeyCode::Right, KeyModifiers::ALT).unwrap(); 114 | println!( 115 | "After forward: content = {:?}", 116 | harness.get_buffer_content().unwrap() 117 | ); 118 | let hist = &harness.editor().position_history; 119 | println!( 120 | "History: len={}, current_idx={:?}, can_back={}, can_fwd={}", 121 | hist.len(), 122 | hist.current_index(), 123 | hist.can_go_back(), 124 | hist.can_go_forward() 125 | ); 126 | 127 | // Should still be in Buffer 4 (at the end of history) 128 | assert_eq!( 129 | harness.get_buffer_content().unwrap(), 130 | "Buffer 4", 131 | "Should still be in Buffer 4 after forward" 132 | ); 133 | } 134 | -------------------------------------------------------------------------------- /plugins/lib/virtual-buffer-factory.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Options for creating a virtual buffer 5 | */ 6 | export interface VirtualBufferOptions { 7 | /** Display name (e.g., "*Commit Details*") */ 8 | name: string; 9 | /** Mode name for keybindings */ 10 | mode: string; 11 | /** Text property entries */ 12 | entries: TextPropertyEntry[]; 13 | /** Whether to show line numbers (default false) */ 14 | showLineNumbers?: boolean; 15 | /** Whether editing is disabled (default true) */ 16 | editingDisabled?: boolean; 17 | /** Whether buffer is read-only (default true) */ 18 | readOnly?: boolean; 19 | } 20 | 21 | /** 22 | * Options for creating a virtual buffer in a new split 23 | */ 24 | export interface SplitBufferOptions extends VirtualBufferOptions { 25 | /** Split ratio (default 0.3) */ 26 | ratio?: number; 27 | /** Panel ID for idempotent operations */ 28 | panelId?: string; 29 | } 30 | 31 | /** 32 | * VirtualBufferFactory - Simplified virtual buffer creation 33 | * 34 | * Provides convenience methods for creating virtual buffers with 35 | * sensible defaults for read-only panel views. 36 | * 37 | * @example 38 | * ```typescript 39 | * // Create buffer as a tab in current split (e.g., help, manual) 40 | * const bufferId = await VirtualBufferFactory.create({ 41 | * name: "*Help*", 42 | * mode: "help-manual", 43 | * entries: helpEntries, 44 | * }); 45 | * 46 | * // Create buffer in existing split (e.g., commit detail view) 47 | * const bufferId = await VirtualBufferFactory.createInSplit(splitId, { 48 | * name: "*Commit Details*", 49 | * mode: "git-commit-detail", 50 | * entries: detailEntries, 51 | * }); 52 | * 53 | * // Create buffer in new split 54 | * const bufferId = await VirtualBufferFactory.createWithSplit({ 55 | * name: "*References*", 56 | * mode: "references-list", 57 | * entries: refEntries, 58 | * ratio: 0.4, 59 | * }); 60 | * ``` 61 | */ 62 | export const VirtualBufferFactory = { 63 | /** 64 | * Create a virtual buffer as a new tab in the current split 65 | * This is ideal for documentation, help panels, and content that should 66 | * appear alongside other buffers rather than in a separate split. 67 | * 68 | * @param options - Buffer configuration 69 | * @returns Buffer ID 70 | */ 71 | async create(options: VirtualBufferOptions): Promise { 72 | const { 73 | name, 74 | mode, 75 | entries, 76 | showLineNumbers = false, 77 | editingDisabled = true, 78 | readOnly = true, 79 | } = options; 80 | 81 | return await editor.createVirtualBuffer({ 82 | name, 83 | mode, 84 | read_only: readOnly, 85 | entries, 86 | show_line_numbers: showLineNumbers, 87 | editing_disabled: editingDisabled, 88 | }); 89 | }, 90 | 91 | /** 92 | * Create a virtual buffer in an existing split 93 | * 94 | * @param splitId - Target split ID 95 | * @param options - Buffer configuration 96 | * @returns Buffer ID 97 | */ 98 | async createInSplit(splitId: number, options: VirtualBufferOptions): Promise { 99 | const { 100 | name, 101 | mode, 102 | entries, 103 | showLineNumbers = false, 104 | editingDisabled = true, 105 | readOnly = true, 106 | } = options; 107 | 108 | return await editor.createVirtualBufferInExistingSplit({ 109 | name, 110 | mode, 111 | read_only: readOnly, 112 | entries, 113 | split_id: splitId, 114 | show_line_numbers: showLineNumbers, 115 | editing_disabled: editingDisabled, 116 | }); 117 | }, 118 | 119 | /** 120 | * Create a virtual buffer in a new split 121 | * 122 | * @param options - Buffer and split configuration 123 | * @returns Buffer ID 124 | */ 125 | async createWithSplit(options: SplitBufferOptions): Promise { 126 | const { 127 | name, 128 | mode, 129 | entries, 130 | ratio = 0.3, 131 | panelId, 132 | showLineNumbers = false, 133 | editingDisabled = true, 134 | readOnly = true, 135 | } = options; 136 | 137 | return await editor.createVirtualBufferInSplit({ 138 | name, 139 | mode, 140 | read_only: readOnly, 141 | entries, 142 | ratio, 143 | panel_id: panelId, 144 | show_line_numbers: showLineNumbers, 145 | editing_disabled: editingDisabled, 146 | }); 147 | }, 148 | 149 | /** 150 | * Update content of an existing virtual buffer 151 | * 152 | * @param bufferId - Buffer to update 153 | * @param entries - New entries 154 | */ 155 | updateContent(bufferId: number, entries: TextPropertyEntry[]): void { 156 | editor.setVirtualBufferContent(bufferId, entries); 157 | }, 158 | }; 159 | -------------------------------------------------------------------------------- /tests/property_tests.rs: -------------------------------------------------------------------------------- 1 | // Property-based tests using proptest 2 | // These tests generate random sequences of operations and verify invariants 3 | 4 | mod common; 5 | 6 | use common::harness::EditorTestHarness; 7 | use crossterm::event::{KeyCode, KeyModifiers}; 8 | use proptest::prelude::*; 9 | 10 | /// Generate random edit operations 11 | #[derive(Debug, Clone)] 12 | enum EditOp { 13 | TypeChar(char), 14 | TypeString(String), 15 | Backspace, 16 | Delete, 17 | Enter, 18 | Left, 19 | Right, 20 | Home, 21 | End, 22 | } 23 | 24 | impl EditOp { 25 | /// Apply this operation to the test harness 26 | fn apply(&self, harness: &mut EditorTestHarness) -> std::io::Result<()> { 27 | match self { 28 | EditOp::TypeChar(ch) => harness.type_text(&ch.to_string()), 29 | EditOp::TypeString(s) => harness.type_text(s), 30 | EditOp::Backspace => harness.send_key(KeyCode::Backspace, KeyModifiers::NONE), 31 | EditOp::Delete => harness.send_key(KeyCode::Delete, KeyModifiers::NONE), 32 | EditOp::Enter => harness.send_key(KeyCode::Enter, KeyModifiers::NONE), 33 | EditOp::Left => harness.send_key(KeyCode::Left, KeyModifiers::NONE), 34 | EditOp::Right => harness.send_key(KeyCode::Right, KeyModifiers::NONE), 35 | EditOp::Home => harness.send_key(KeyCode::Home, KeyModifiers::NONE), 36 | EditOp::End => harness.send_key(KeyCode::End, KeyModifiers::NONE), 37 | } 38 | } 39 | } 40 | 41 | /// Strategy for generating random edit operations 42 | fn edit_op_strategy() -> impl Strategy { 43 | prop_oneof![ 44 | // Typing operations (more common) 45 | 3 => any::() 46 | .prop_filter("printable ASCII", |c| c.is_ascii() && !c.is_ascii_control()) 47 | .prop_map(EditOp::TypeChar), 48 | 2 => "[a-zA-Z0-9 ]{1,10}" 49 | .prop_map(EditOp::TypeString), 50 | // Navigation operations 51 | 1 => Just(EditOp::Left), 52 | 1 => Just(EditOp::Right), 53 | 1 => Just(EditOp::Home), 54 | 1 => Just(EditOp::End), 55 | // Editing operations 56 | 2 => Just(EditOp::Backspace), 57 | 2 => Just(EditOp::Delete), 58 | 1 => Just(EditOp::Enter), 59 | ] 60 | } 61 | 62 | proptest! { 63 | #![proptest_config(ProptestConfig { 64 | cases: 100, 65 | max_shrink_iters: 1000, 66 | ..ProptestConfig::default() 67 | })] 68 | 69 | /// Property test: piece tree should always match shadow string after any sequence of edits 70 | #[test] 71 | fn prop_piece_tree_matches_shadow(ops in prop::collection::vec(edit_op_strategy(), 1..50)) { 72 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 73 | harness.enable_shadow_validation(); 74 | 75 | // Apply all operations 76 | for op in &ops { 77 | op.apply(&mut harness).unwrap(); 78 | } 79 | 80 | // Get final state 81 | let buffer_content = harness.get_buffer_content().unwrap(); 82 | let shadow_content = harness.get_shadow_string(); 83 | 84 | // They should match! 85 | prop_assert_eq!( 86 | &buffer_content, 87 | shadow_content, 88 | "Piece tree diverged from shadow string after {} operations\nOperations: {:#?}", 89 | ops.len(), 90 | ops 91 | ); 92 | } 93 | 94 | /// Property test: cursor position should always be valid 95 | #[test] 96 | fn prop_cursor_position_valid(ops in prop::collection::vec(edit_op_strategy(), 1..50)) { 97 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 98 | 99 | for op in &ops { 100 | op.apply(&mut harness).unwrap(); 101 | 102 | let cursor_pos = harness.cursor_position(); 103 | let buffer_len = harness.buffer_len(); 104 | 105 | prop_assert!( 106 | cursor_pos <= buffer_len, 107 | "Cursor position {} exceeds buffer length {} after operation {:?}", 108 | cursor_pos, 109 | buffer_len, 110 | op 111 | ); 112 | } 113 | } 114 | 115 | /// Property test: buffer length should match shadow length 116 | #[test] 117 | fn prop_buffer_length_matches_shadow(ops in prop::collection::vec(edit_op_strategy(), 1..50)) { 118 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 119 | harness.enable_shadow_validation(); 120 | 121 | for op in &ops { 122 | op.apply(&mut harness).unwrap(); 123 | } 124 | 125 | let buffer_len = harness.buffer_len(); 126 | let shadow_len = harness.get_shadow_string().len(); 127 | 128 | prop_assert_eq!( 129 | buffer_len, 130 | shadow_len, 131 | "Buffer length {} doesn't match shadow length {} after {} operations\nOperations: {:#?}", 132 | buffer_len, 133 | shadow_len, 134 | ops.len(), 135 | ops 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /plugins/examples/async_demo.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Async Process Demo Plugin 5 | * Demonstrates spawning external processes asynchronously with async/await 6 | */ 7 | 8 | // Git status 9 | globalThis.async_git_status = async function(): Promise { 10 | editor.setStatus("Running git status..."); 11 | 12 | try { 13 | const result = await editor.spawnProcess("git", ["status", "--short"]); 14 | if (result.exit_code === 0) { 15 | if (result.stdout === "" || result.stdout === "\n") { 16 | editor.setStatus("Git: Working tree clean"); 17 | } else { 18 | const count = result.stdout.split("\n").filter(line => line.trim()).length; 19 | editor.setStatus(`Git: ${count} files changed`); 20 | } 21 | } else { 22 | editor.setStatus(`Git status failed: ${result.stderr}`); 23 | } 24 | } catch (e) { 25 | editor.setStatus(`Git status error: ${e}`); 26 | } 27 | }; 28 | 29 | editor.registerCommand( 30 | "Async Demo: Git Status", 31 | "Run git status and show output", 32 | "async_git_status", 33 | "normal" 34 | ); 35 | 36 | // Current directory 37 | globalThis.async_pwd = async function(): Promise { 38 | try { 39 | const result = await editor.spawnProcess("pwd"); 40 | if (result.exit_code === 0) { 41 | const dir = result.stdout.trim(); 42 | editor.setStatus(`Current directory: ${dir}`); 43 | } else { 44 | editor.setStatus("pwd failed"); 45 | } 46 | } catch (e) { 47 | editor.setStatus(`pwd error: ${e}`); 48 | } 49 | }; 50 | 51 | editor.registerCommand( 52 | "Async Demo: Current Directory", 53 | "Show current directory using pwd", 54 | "async_pwd", 55 | "normal" 56 | ); 57 | 58 | // List files 59 | globalThis.async_ls = async function(): Promise { 60 | editor.setStatus("Listing files..."); 61 | 62 | try { 63 | const result = await editor.spawnProcess("ls", ["-1"]); 64 | if (result.exit_code === 0) { 65 | const count = result.stdout.split("\n").filter(line => line.trim()).length; 66 | editor.setStatus(`Found ${count} files/directories`); 67 | } else { 68 | editor.setStatus("ls failed"); 69 | } 70 | } catch (e) { 71 | editor.setStatus(`ls error: ${e}`); 72 | } 73 | }; 74 | 75 | editor.registerCommand( 76 | "Async Demo: List Files", 77 | "List files in current directory", 78 | "async_ls", 79 | "normal" 80 | ); 81 | 82 | // Git branch 83 | globalThis.async_git_branch = async function(): Promise { 84 | try { 85 | const result = await editor.spawnProcess("git", ["branch", "--show-current"]); 86 | if (result.exit_code === 0) { 87 | const branch = result.stdout.trim(); 88 | if (branch !== "") { 89 | editor.setStatus(`Git branch: ${branch}`); 90 | } else { 91 | editor.setStatus("Not on any branch (detached HEAD)"); 92 | } 93 | } else { 94 | editor.setStatus("Not a git repository"); 95 | } 96 | } catch (e) { 97 | editor.setStatus(`Git branch error: ${e}`); 98 | } 99 | }; 100 | 101 | editor.registerCommand( 102 | "Async Demo: Git Branch", 103 | "Show current git branch", 104 | "async_git_branch", 105 | "normal" 106 | ); 107 | 108 | // Echo test 109 | globalThis.async_echo = async function(): Promise { 110 | try { 111 | const result = await editor.spawnProcess("echo", ["Hello from async process!"]); 112 | editor.setStatus(`Echo output: ${result.stdout.trim()}`); 113 | } catch (e) { 114 | editor.setStatus(`Echo error: ${e}`); 115 | } 116 | }; 117 | 118 | editor.registerCommand( 119 | "Async Demo: Echo Test", 120 | "Test with simple echo command", 121 | "async_echo", 122 | "normal" 123 | ); 124 | 125 | // With working directory 126 | globalThis.async_with_cwd = async function(): Promise { 127 | try { 128 | const result = await editor.spawnProcess("pwd", [], "/tmp"); 129 | const dir = result.stdout.trim(); 130 | editor.setStatus(`Working dir was: ${dir}`); 131 | } catch (e) { 132 | editor.setStatus(`pwd error: ${e}`); 133 | } 134 | }; 135 | 136 | editor.registerCommand( 137 | "Async Demo: With Working Dir", 138 | "Run command in /tmp directory", 139 | "async_with_cwd", 140 | "normal" 141 | ); 142 | 143 | // Error handling 144 | globalThis.async_error = async function(): Promise { 145 | try { 146 | const result = await editor.spawnProcess("this_command_does_not_exist"); 147 | if (result.exit_code !== 0) { 148 | editor.setStatus(`Command failed (as expected): exit ${result.exit_code}`); 149 | } else { 150 | editor.setStatus("Unexpected success"); 151 | } 152 | } catch (e) { 153 | editor.setStatus(`Command failed with error: ${e}`); 154 | } 155 | }; 156 | 157 | editor.registerCommand( 158 | "Async Demo: Error Handling", 159 | "Demonstrate error handling with non-existent command", 160 | "async_error", 161 | "normal" 162 | ); 163 | 164 | editor.setStatus("Async Demo plugin loaded! Try the 'Async Demo' commands."); 165 | editor.debug("Async Demo plugin initialized with native async/await support"); 166 | -------------------------------------------------------------------------------- /plugins/path_complete.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Path Completion Plugin 5 | * 6 | * Provides path autocompletion for file prompts (Open File, Save File As). 7 | * Shows directory contents and filters based on user input. 8 | */ 9 | 10 | // Parse the input to extract directory path and search pattern 11 | function parsePath(input: string): { dir: string; pattern: string; isAbsolute: boolean } { 12 | if (input === "") { 13 | return { dir: ".", pattern: "", isAbsolute: false }; 14 | } 15 | 16 | const isAbsolute = input.startsWith("/"); 17 | 18 | // Find the last path separator 19 | const lastSlash = input.lastIndexOf("/"); 20 | 21 | if (lastSlash === -1) { 22 | // No slash, searching in current directory 23 | return { dir: ".", pattern: input, isAbsolute: false }; 24 | } 25 | 26 | if (lastSlash === 0) { 27 | // Root directory 28 | return { dir: "/", pattern: input.slice(1), isAbsolute: true }; 29 | } 30 | 31 | // Has directory component 32 | const dir = input.slice(0, lastSlash); 33 | const pattern = input.slice(lastSlash + 1); 34 | 35 | return { dir: dir || "/", pattern, isAbsolute }; 36 | } 37 | 38 | // Filter and sort entries based on pattern 39 | function filterEntries(entries: DirEntry[], pattern: string): DirEntry[] { 40 | const patternLower = pattern.toLowerCase(); 41 | 42 | // Filter entries that match the pattern 43 | const filtered = entries.filter((entry) => { 44 | const nameLower = entry.name.toLowerCase(); 45 | // Match if pattern is prefix of name (case-insensitive) 46 | return nameLower.startsWith(patternLower); 47 | }); 48 | 49 | // Sort: directories first, then alphabetically 50 | filtered.sort((a, b) => { 51 | // Directories come first 52 | if (a.is_dir && !b.is_dir) return -1; 53 | if (!a.is_dir && b.is_dir) return 1; 54 | // Alphabetical within same type 55 | return a.name.localeCompare(b.name); 56 | }); 57 | 58 | return filtered; 59 | } 60 | 61 | // Convert directory entries to suggestions 62 | function entriesToSuggestions(entries: DirEntry[], basePath: string): PromptSuggestion[] { 63 | return entries.map((entry) => { 64 | // Build full path 65 | let fullPath: string; 66 | if (basePath === ".") { 67 | fullPath = entry.name; 68 | } else if (basePath === "/") { 69 | fullPath = "/" + entry.name; 70 | } else { 71 | fullPath = basePath + "/" + entry.name; 72 | } 73 | 74 | // Add trailing slash for directories 75 | const displayName = entry.is_dir ? entry.name + "/" : entry.name; 76 | const value = entry.is_dir ? fullPath + "/" : fullPath; 77 | 78 | return { 79 | text: displayName, 80 | description: entry.is_dir ? "directory" : undefined, 81 | value: value, 82 | disabled: false, 83 | }; 84 | }); 85 | } 86 | 87 | function missingFileSuggestion( 88 | input: string, 89 | pattern: string, 90 | ): PromptSuggestion | null { 91 | if (pattern === "" || input === "") { 92 | return null; 93 | } 94 | 95 | let absolutePath = input; 96 | if (!editor.pathIsAbsolute(absolutePath)) { 97 | let cwd: string; 98 | try { 99 | cwd = editor.getCwd(); 100 | } catch { 101 | return null; 102 | } 103 | absolutePath = editor.pathJoin(cwd, absolutePath); 104 | } 105 | 106 | if (editor.fileExists(absolutePath)) { 107 | return null; 108 | } 109 | 110 | return { 111 | text: `${input} (new file)`, 112 | description: "File does not exist yet", 113 | value: input, 114 | }; 115 | } 116 | 117 | // Generate path completions for the given input 118 | function generateCompletions(input: string): PromptSuggestion[] { 119 | const { dir, pattern } = parsePath(input); 120 | 121 | // Read the directory 122 | const entries = editor.readDir(dir); 123 | const newFileSuggestion = missingFileSuggestion(input, pattern); 124 | 125 | if (!entries) { 126 | // Directory doesn't exist or can't be read 127 | return newFileSuggestion ? [newFileSuggestion] : []; 128 | } 129 | 130 | // Filter hidden files (starting with .) unless pattern starts with . 131 | const showHidden = pattern.startsWith("."); 132 | const visibleEntries = entries.filter((e) => showHidden || !e.name.startsWith(".")); 133 | 134 | // Filter by pattern 135 | const filtered = filterEntries(visibleEntries, pattern); 136 | 137 | // Limit results 138 | const limited = filtered.slice(0, 100); 139 | 140 | // Convert to suggestions 141 | const suggestions = entriesToSuggestions(limited, dir); 142 | if (newFileSuggestion) { 143 | suggestions.push(newFileSuggestion); 144 | } 145 | return suggestions; 146 | } 147 | 148 | // Handle prompt changes for file prompts 149 | globalThis.onPathCompletePromptChanged = function (args: { prompt_type: string; input: string }): boolean { 150 | if (args.prompt_type !== "open-file" && args.prompt_type !== "save-file-as") { 151 | return true; // Not our prompt 152 | } 153 | 154 | const suggestions = generateCompletions(args.input); 155 | editor.setPromptSuggestions(suggestions); 156 | 157 | return true; 158 | }; 159 | 160 | // Register event handler 161 | editor.on("prompt_changed", "onPathCompletePromptChanged"); 162 | 163 | editor.debug("Path completion plugin loaded successfully"); 164 | -------------------------------------------------------------------------------- /docs/fresh.txt: -------------------------------------------------------------------------------- 1 | 2 | ███████╗██████╗ ███████╗███████╗██╗ ██╗ 3 | ██╔════╝██╔══██╗██╔════╝██╔════╝██║ ██║ 4 | █████╗ ██████╔╝█████╗ ███████╗███████║ 5 | ██╔══╝ ██╔══██╗██╔══╝ ╚════██║██╔══██║ 6 | ██║ ██║ ██║███████╗███████║██║ ██║ 7 | ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝ 8 | 9 | The Terminal Text Editor 10 | 11 | Easy To Use | TypeScript Extensible | Light And Fast | Huge File Support 12 | 13 | ╔════════════════════════════════════════════════════════════╗ 14 | ║ FEATURE OVERVIEW ║ 15 | ╚════════════════════════════════════════════════════════════╝ 16 | 17 | 📁 File Management 18 | ──────────────────────────────────────────────────────────── 19 | • Open, save, save-as, new file, close buffer, revert 20 | • File explorer sidebar with create/delete/rename 21 | • Tab-based buffer switching with scroll overflow 22 | • Auto-revert when files change on disk 23 | • Git file finder (fuzzy search tracked files) 24 | 25 | ✏️ Editing 26 | ──────────────────────────────────────────────────────────── 27 | • Unlimited undo/redo with full history 28 | • Multi-cursor editing (add above/below, next match) 29 | • Block/column selection mode 30 | • Word, line, and expanding selection 31 | • Smart indent/dedent and auto-indentation 32 | • Toggle line comments (language-aware) 33 | • Clipboard with system integration 34 | 35 | 🔍 Search & Replace 36 | ──────────────────────────────────────────────────────────── 37 | • Incremental search with match highlighting 38 | • Search within selection 39 | • Replace and replace-all 40 | • Interactive query-replace (y/n/!/q per match) 41 | • Git grep across entire repository 42 | 43 | 🧭 Navigation 44 | ──────────────────────────────────────────────────────────── 45 | • Go to line, go to matching bracket 46 | • Word and document movement 47 | • Position history (back/forward) 48 | • Numbered bookmarks (0-9) with jump 49 | • Jump to next/previous error 50 | 51 | 🖥️ Views & Layout 52 | ──────────────────────────────────────────────────────────── 53 | • Horizontal and vertical split panes 54 | • Resizable splits with keyboard/mouse 55 | • Toggle line numbers, line wrap, hidden files 56 | • ANSI art background images with blend control 57 | • Markdown compose/preview mode 58 | 59 | 🤖 Language Server (LSP) 60 | ──────────────────────────────────────────────────────────── 61 | • Go to definition, find references 62 | • Hover documentation and signature help 63 | • Code actions and quick fixes 64 | • Rename symbol across project 65 | • Real-time diagnostics (errors/warnings) 66 | • Autocompletion with snippets 67 | 68 | 🎯 Productivity 69 | ──────────────────────────────────────────────────────────── 70 | • Command palette (Ctrl+P) for all actions 71 | • Full menu bar with mouse/keyboard navigation 72 | • Keyboard macros (record/playback, slots 0-9) 73 | • Git log viewer with diff display 74 | • Diagnostics panel for all errors 75 | 76 | 🔌 Plugins & Extensibility 77 | ──────────────────────────────────────────────────────────── 78 | • TypeScript plugins in sandboxed Deno runtime 79 | • Color highlighter (hex/rgb colors in code) 80 | • TODO/FIXME highlighter 81 | • Git merge conflict resolver 82 | • Path autocomplete 83 | • Customizable keymaps (JSON config) 84 | 85 | ╔════════════════════════════════════════════════════════════╗ 86 | ║ GETTING STARTED ║ 87 | ╚════════════════════════════════════════════════════════════╝ 88 | 89 | ⭐ Quick Start 90 | ──────────────────────────────────────────────────────────── 91 | • Open Files: Press Ctrl+O to browse and open any file 92 | • Command Palette: Hit Ctrl+P - your Swiss Army knife! 93 | • Use the Mouse! Scroll bars, menus, tabs all work 94 | • Menu Bar: Alt+F for File, Alt+E for Edit, etc. 95 | 96 | ⭐ Pro Tips 97 | ──────────────────────────────────────────────────────────── 98 | • Multi-cursor: Ctrl+D adds cursor at next match 99 | • Search: Ctrl+F finds, F3/Shift+F3 navigates matches 100 | • File Explorer: Ctrl+B toggles sidebar 101 | • Splits: Use View menu or command palette 102 | 103 | 📚 Documentation 104 | ──────────────────────────────────────────────────────────── 105 | • README.md - Quick start guide 106 | • docs/USER_GUIDE.md - Comprehensive documentation 107 | • docs/PLUGIN_DEVELOPMENT.md - Build your own plugins 108 | 109 | Press q or Esc to close | Shift+F1 for keyboard shortcuts 110 | -------------------------------------------------------------------------------- /tests/test_line_iterator_comprehensive.rs: -------------------------------------------------------------------------------- 1 | /// Comprehensive tests for LineIterator to catch position/content bugs 2 | use fresh::model::buffer::TextBuffer; 3 | 4 | #[test] 5 | fn test_line_iterator_simple() { 6 | let mut buffer = TextBuffer::from_bytes(b"Line 1\nLine 2\nLine 3\n".to_vec()); 7 | 8 | // Test starting at beginning 9 | let mut iter = buffer.line_iterator(0, 80); 10 | assert_eq!(iter.current_position(), 0); 11 | 12 | let (pos, content) = iter.next().unwrap(); 13 | assert_eq!(pos, 0); 14 | assert_eq!(content, "Line 1\n"); 15 | 16 | let (pos, content) = iter.next().unwrap(); 17 | assert_eq!(pos, 7); 18 | assert_eq!(content, "Line 2\n"); 19 | 20 | let (pos, content) = iter.next().unwrap(); 21 | assert_eq!(pos, 14); 22 | assert_eq!(content, "Line 3\n"); 23 | } 24 | 25 | #[test] 26 | fn test_line_iterator_empty_lines() { 27 | let mut buffer = TextBuffer::from_bytes(b"Line 1\n\nLine 3\n".to_vec()); 28 | 29 | // Test starting at position 0 30 | let mut iter = buffer.line_iterator(0, 80); 31 | let (pos, content) = iter.next().unwrap(); 32 | assert_eq!((pos, content.as_str()), (0, "Line 1\n")); 33 | 34 | let (pos, content) = iter.next().unwrap(); 35 | assert_eq!( 36 | (pos, content.as_str()), 37 | (7, "\n"), 38 | "Empty line should be just newline" 39 | ); 40 | 41 | let (pos, content) = iter.next().unwrap(); 42 | assert_eq!((pos, content.as_str()), (8, "Line 3\n")); 43 | 44 | // Test starting at position 7 (empty line) 45 | let mut iter = buffer.line_iterator(7, 80); 46 | assert_eq!(iter.current_position(), 7); 47 | 48 | let (pos, content) = iter.next().unwrap(); 49 | assert_eq!( 50 | (pos, content.as_str()), 51 | (7, "\n"), 52 | "Should return empty line, not previous content" 53 | ); 54 | 55 | let (pos, content) = iter.next().unwrap(); 56 | assert_eq!((pos, content.as_str()), (8, "Line 3\n")); 57 | } 58 | 59 | #[test] 60 | fn test_line_iterator_multiple_empty_lines() { 61 | let mut buffer = TextBuffer::from_bytes(b"Line 1\n\n\n\nLine 5\n".to_vec()); 62 | 63 | let mut iter = buffer.line_iterator(0, 80); 64 | assert_eq!(iter.next().unwrap().0, 0); // "Line 1\n" 65 | assert_eq!(iter.next().unwrap(), (7, "\n".to_string())); // Empty line 2 66 | assert_eq!(iter.next().unwrap(), (8, "\n".to_string())); // Empty line 3 67 | assert_eq!(iter.next().unwrap(), (9, "\n".to_string())); // Empty line 4 68 | assert_eq!(iter.next().unwrap().0, 10); // "Line 5\n" 69 | } 70 | 71 | #[test] 72 | fn test_line_iterator_starts_mid_piece() { 73 | // This test creates a buffer with edits that cause pieces to span multiple lines 74 | let mut buffer = TextBuffer::from_bytes(b"Line 1\nLine 2\nLine 3\n".to_vec()); 75 | 76 | // Insert at beginning to create a new piece 77 | buffer.insert_bytes(0, b"Prefix\n".to_vec()); 78 | // Content is now "Prefix\nLine 1\nLine 2\nLine 3\n" 79 | 80 | // Test iterating from within a piece 81 | let mut iter = buffer.line_iterator(7, 80); // Start at "Line 1" 82 | let (pos, content) = iter.next().unwrap(); 83 | assert_eq!((pos, content.as_str()), (7, "Line 1\n")); 84 | 85 | let (pos, content) = iter.next().unwrap(); 86 | assert_eq!((pos, content.as_str()), (14, "Line 2\n")); 87 | } 88 | 89 | #[test] 90 | fn test_line_iterator_after_multiple_edits() { 91 | let mut buffer = TextBuffer::from_bytes(b"ABC\n".to_vec()); 92 | 93 | // Create multiple pieces through edits 94 | buffer.insert_bytes(4, b"DEF\n".to_vec()); // "ABC\nDEF\n" 95 | buffer.insert_bytes(8, b"GHI\n".to_vec()); // "ABC\nDEF\nGHI\n" 96 | 97 | // Test iteration from each line 98 | let mut iter = buffer.line_iterator(0, 80); 99 | assert_eq!(iter.next().unwrap(), (0, "ABC\n".to_string())); 100 | assert_eq!(iter.next().unwrap(), (4, "DEF\n".to_string())); 101 | assert_eq!(iter.next().unwrap(), (8, "GHI\n".to_string())); 102 | 103 | // Test starting mid-buffer 104 | let mut iter = buffer.line_iterator(4, 80); 105 | assert_eq!(iter.current_position(), 4); 106 | assert_eq!(iter.next().unwrap(), (4, "DEF\n".to_string())); 107 | assert_eq!(iter.next().unwrap(), (8, "GHI\n".to_string())); 108 | } 109 | 110 | #[test] 111 | fn test_line_iterator_prev() { 112 | let mut buffer = TextBuffer::from_bytes(b"Line 1\n\nLine 3\n".to_vec()); 113 | 114 | let mut iter = buffer.line_iterator(8, 80); // Start at "Line 3" 115 | assert_eq!(iter.current_position(), 8); 116 | 117 | let (pos, content) = iter.prev().unwrap(); 118 | assert_eq!((pos, content.as_str()), (7, "\n")); // Empty line 119 | 120 | let (pos, content) = iter.prev().unwrap(); 121 | assert_eq!((pos, content.as_str()), (0, "Line 1\n")); 122 | 123 | assert!(iter.prev().is_none()); // No more lines before 124 | } 125 | 126 | #[test] 127 | fn test_line_iterator_no_trailing_newline() { 128 | let mut buffer = TextBuffer::from_bytes(b"Line 1\nLine 2".to_vec()); 129 | 130 | let mut iter = buffer.line_iterator(0, 80); 131 | assert_eq!(iter.next().unwrap(), (0, "Line 1\n".to_string())); 132 | assert_eq!(iter.next().unwrap(), (7, "Line 2".to_string())); // No trailing newline 133 | 134 | assert!(iter.next().is_none()); 135 | } 136 | 137 | #[test] 138 | fn test_line_iterator_single_char_lines() { 139 | let mut buffer = TextBuffer::from_bytes(b"a\nb\nc\n".to_vec()); 140 | 141 | let mut iter = buffer.line_iterator(0, 80); 142 | assert_eq!(iter.next().unwrap(), (0, "a\n".to_string())); 143 | assert_eq!(iter.next().unwrap(), (2, "b\n".to_string())); 144 | assert_eq!(iter.next().unwrap(), (4, "c\n".to_string())); 145 | } 146 | -------------------------------------------------------------------------------- /src/primitives/ansi_background.rs: -------------------------------------------------------------------------------- 1 | use crate::primitives::ansi::AnsiParser; 2 | use ratatui::style::Color; 3 | use std::path::Path; 4 | 5 | /// Default blend factor used to fade the background under text 6 | pub const DEFAULT_BACKGROUND_FADE: f32 = 0.22; 7 | 8 | /// Parsed ANSI art that can be sampled as a background 9 | pub struct AnsiBackground { 10 | width: usize, 11 | height: usize, 12 | /// Row-major map of colors (None = transparent) 13 | colors: Vec>, 14 | } 15 | 16 | impl AnsiBackground { 17 | /// Load ANSI art from a file on disk 18 | pub fn from_file(path: &Path) -> std::io::Result { 19 | let contents = std::fs::read_to_string(path)?; 20 | Ok(Self::from_text(&contents)) 21 | } 22 | 23 | /// Parse ANSI art from a string 24 | pub fn from_text(text: &str) -> Self { 25 | let mut parser = AnsiParser::new(); 26 | let mut rows: Vec>> = Vec::new(); 27 | let mut max_width = 0usize; 28 | 29 | for line in text.lines() { 30 | let mut row: Vec> = Vec::new(); 31 | 32 | for ch in line.chars() { 33 | if let Some(style) = parser.parse_char(ch) { 34 | // Prefer explicit foreground color, fall back to background 35 | let color = style 36 | .fg 37 | .or(style.bg) 38 | .and_then(color_to_rgb) 39 | .map(|(r, g, b)| Color::Rgb(r, g, b)); 40 | row.push(color); 41 | } 42 | } 43 | 44 | max_width = max_width.max(row.len()); 45 | rows.push(row); 46 | } 47 | 48 | let width = max_width; 49 | let height = rows.len(); 50 | 51 | // Normalize rows to consistent width so we can index quickly 52 | let mut colors = Vec::with_capacity(width * height); 53 | for row in rows { 54 | let mut padded = row; 55 | padded.resize(width, None); 56 | colors.extend(padded); 57 | } 58 | 59 | Self { 60 | width, 61 | height, 62 | colors, 63 | } 64 | } 65 | 66 | /// Get a faded background color for the given coordinate, wrapping if necessary 67 | pub fn faded_color(&self, x: usize, y: usize, base_bg: Color, opacity: f32) -> Option { 68 | if self.width == 0 || self.height == 0 { 69 | return None; 70 | } 71 | 72 | let wrapped_x = x % self.width; 73 | let wrapped_y = y % self.height; 74 | let idx = wrapped_y * self.width + wrapped_x; 75 | 76 | let fg_color = self.colors.get(idx).cloned().flatten()?; 77 | let fg_rgb = color_to_rgb(fg_color)?; 78 | let bg_rgb = color_to_rgb(base_bg)?; 79 | 80 | Some(Color::Rgb( 81 | blend_channel(fg_rgb.0, bg_rgb.0, opacity), 82 | blend_channel(fg_rgb.1, bg_rgb.1, opacity), 83 | blend_channel(fg_rgb.2, bg_rgb.2, opacity), 84 | )) 85 | } 86 | } 87 | 88 | fn blend_channel(fg: u8, bg: u8, opacity: f32) -> u8 { 89 | let fg_f = fg as f32; 90 | let bg_f = bg as f32; 91 | ((fg_f * opacity) + (bg_f * (1.0 - opacity))) 92 | .round() 93 | .clamp(0.0, 255.0) as u8 94 | } 95 | 96 | fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> { 97 | match color { 98 | Color::Rgb(r, g, b) => Some((r, g, b)), 99 | Color::Black => Some((0, 0, 0)), 100 | Color::Red => Some((205, 0, 0)), 101 | Color::Green => Some((0, 205, 0)), 102 | Color::Yellow => Some((205, 205, 0)), 103 | Color::Blue => Some((0, 0, 238)), 104 | Color::Magenta => Some((205, 0, 205)), 105 | Color::Cyan => Some((0, 205, 205)), 106 | Color::Gray => Some((229, 229, 229)), 107 | Color::DarkGray => Some((127, 127, 127)), 108 | Color::LightRed => Some((255, 0, 0)), 109 | Color::LightGreen => Some((0, 255, 0)), 110 | Color::LightYellow => Some((255, 255, 0)), 111 | Color::LightBlue => Some((92, 92, 255)), 112 | Color::LightMagenta => Some((255, 0, 255)), 113 | Color::LightCyan => Some((0, 255, 255)), 114 | Color::White => Some((255, 255, 255)), 115 | Color::Indexed(idx) => indexed_to_rgb(idx), 116 | Color::Reset => None, 117 | } 118 | } 119 | 120 | fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> { 121 | // 0-15 = ANSI 16-color palette, 16-231 = 6x6x6 cube, 232-255 = grayscale 122 | match idx { 123 | 0 => Some((0, 0, 0)), 124 | 1 => Some((205, 0, 0)), 125 | 2 => Some((0, 205, 0)), 126 | 3 => Some((205, 205, 0)), 127 | 4 => Some((0, 0, 238)), 128 | 5 => Some((205, 0, 205)), 129 | 6 => Some((0, 205, 205)), 130 | 7 => Some((229, 229, 229)), 131 | 8 => Some((127, 127, 127)), 132 | 9 => Some((255, 0, 0)), 133 | 10 => Some((0, 255, 0)), 134 | 11 => Some((255, 255, 0)), 135 | 12 => Some((92, 92, 255)), 136 | 13 => Some((255, 0, 255)), 137 | 14 => Some((0, 255, 255)), 138 | 15 => Some((255, 255, 255)), 139 | 16..=231 => { 140 | let i = idx - 16; 141 | let r = (i / 36) % 6; 142 | let g = (i / 6) % 6; 143 | let b = i % 6; 144 | Some((to_6cube(r), to_6cube(g), to_6cube(b))) 145 | } 146 | 232..=255 => { 147 | let shade = (idx - 232) * 10 + 8; 148 | Some((shade, shade, shade)) 149 | } 150 | } 151 | } 152 | 153 | fn to_6cube(idx: u8) -> u8 { 154 | [0, 95, 135, 175, 215, 255][idx as usize] 155 | } 156 | -------------------------------------------------------------------------------- /tests/e2e/terminal_resize.rs: -------------------------------------------------------------------------------- 1 | use crate::common::harness::EditorTestHarness; 2 | use tempfile::TempDir; 3 | 4 | /// Test that viewport uses full available area after terminal resize at startup 5 | /// 6 | /// This test reproduces a bug where: 7 | /// 1. Terminal starts with small size (e.g. 15 rows) 8 | /// 2. Program starts 9 | /// 3. Terminal is resized to larger size (e.g. 30 rows) 10 | /// 4. File is opened 11 | /// 5. Viewport should use all available rows but instead only shows a few lines 12 | /// matching the initial small size 13 | #[test] 14 | fn test_viewport_uses_full_area_after_startup_resize() { 15 | let temp_dir = TempDir::new().unwrap(); 16 | let file_path = temp_dir.path().join("test_file.txt"); 17 | 18 | // Create a test file with 50 lines so we have plenty of content 19 | let content: String = (1..=50).map(|i| format!("Line {}\n", i)).collect(); 20 | std::fs::write(&file_path, content).unwrap(); 21 | 22 | // 1. Start with small terminal size (15 rows) 23 | let mut harness = EditorTestHarness::new(80, 15).unwrap(); 24 | harness.render().unwrap(); 25 | 26 | // 2. Resize terminal to larger size (30 rows) before opening file 27 | harness.resize(80, 30).unwrap(); 28 | 29 | // 3. Open file after resize 30 | harness.open_file(&file_path).unwrap(); 31 | harness.render().unwrap(); 32 | 33 | // 4. Check how many lines are visible in the rendered screen 34 | let screen = harness.screen_to_string(); 35 | let screen_lines: Vec<&str> = screen.lines().collect(); 36 | 37 | println!("Terminal height: 30 rows"); 38 | println!("Total screen lines: {}", screen_lines.len()); 39 | println!("\nScreen content:"); 40 | for (i, line) in screen_lines.iter().enumerate() { 41 | println!("Row {:2}: {:?}", i, line); 42 | } 43 | 44 | // Count how many content lines are visible (lines with " │ " separator) 45 | // This filters out tab bar, status bar, etc. 46 | let content_lines: Vec<&str> = screen_lines 47 | .iter() 48 | .filter(|line| line.contains(" │ ")) 49 | .copied() 50 | .collect(); 51 | 52 | println!( 53 | "\nContent lines with ' │ ' separator: {}", 54 | content_lines.len() 55 | ); 56 | for (i, line) in content_lines.iter().enumerate() { 57 | println!("Content line {:2}: {:?}", i, line); 58 | } 59 | 60 | // With a 30-row terminal: 61 | // - 1 row for tab bar (at top) 62 | // - 1 row for status bar (at bottom) 63 | // - This leaves 28 rows for content 64 | // 65 | // The bug: If the viewport was initialized with the small size (15 rows), 66 | // we would only see ~13 content lines (15 - 2 for tab/status bars). 67 | // After the fix, we should see ~28 content lines. 68 | 69 | let expected_min_content_lines = 25; // Allow some margin for status bars, etc. 70 | 71 | assert!( 72 | content_lines.len() >= expected_min_content_lines, 73 | "Expected at least {} visible content lines after resize to 30 rows, but only found {}.\n\ 74 | This suggests the viewport is still using the old small size ({} rows) instead of \ 75 | the new size (30 rows).", 76 | expected_min_content_lines, 77 | content_lines.len(), 78 | 15 79 | ); 80 | 81 | // Additionally verify we can see lines beyond what would be visible in a 15-row terminal 82 | // In a 15-row terminal, we'd only see lines 1-13 (roughly) 83 | // After resize to 30 rows, we should be able to see more 84 | harness.assert_screen_contains("Line 20"); 85 | } 86 | 87 | /// Test that viewport correctly updates when resizing after file is already open 88 | #[test] 89 | fn test_viewport_updates_after_resize_with_open_file() { 90 | let temp_dir = TempDir::new().unwrap(); 91 | let file_path = temp_dir.path().join("test_file.txt"); 92 | 93 | // Create a test file with 50 lines 94 | let content: String = (1..=50).map(|i| format!("Line {}\n", i)).collect(); 95 | std::fs::write(&file_path, content).unwrap(); 96 | 97 | // Start with small terminal and open file 98 | let mut harness = EditorTestHarness::new(80, 15).unwrap(); 99 | harness.open_file(&file_path).unwrap(); 100 | 101 | // Count visible lines before resize 102 | let screen_before = harness.screen_to_string(); 103 | let content_lines_before: Vec<&str> = screen_before 104 | .lines() 105 | .filter(|line| line.contains(" │ ")) 106 | .collect(); 107 | 108 | println!( 109 | "Visible content lines before resize (15 rows): {}", 110 | content_lines_before.len() 111 | ); 112 | 113 | // Resize to larger terminal 114 | harness.resize(80, 30).unwrap(); 115 | 116 | // Count visible lines after resize 117 | let screen_after = harness.screen_to_string(); 118 | let content_lines_after: Vec<&str> = screen_after 119 | .lines() 120 | .filter(|line| line.contains(" │ ")) 121 | .collect(); 122 | 123 | println!( 124 | "Visible content lines after resize (30 rows): {}", 125 | content_lines_after.len() 126 | ); 127 | 128 | // After resize, we should see more lines 129 | assert!( 130 | content_lines_after.len() > content_lines_before.len(), 131 | "After resize from 15 to 30 rows, should see more content lines. \ 132 | Before: {}, After: {}", 133 | content_lines_before.len(), 134 | content_lines_after.len() 135 | ); 136 | 137 | // Should see at least 25 lines with 30-row terminal 138 | let expected_min_content_lines = 25; 139 | assert!( 140 | content_lines_after.len() >= expected_min_content_lines, 141 | "Expected at least {} visible content lines after resize to 30 rows, but only found {}", 142 | expected_min_content_lines, 143 | content_lines_after.len() 144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /plugins/lib/navigation-controller.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { NavigationOptions } from "./types.ts"; 4 | 5 | /** 6 | * NavigationController - Generic list navigation for panel plugins 7 | * 8 | * Handles the common pattern of: 9 | * - Maintaining selected index 10 | * - Boundary checking 11 | * - Status message updates 12 | * - Callback on selection change 13 | * 14 | * @example 15 | * ```typescript 16 | * const nav = new NavigationController({ 17 | * itemLabel: "Diagnostic", 18 | * onSelectionChange: (item, index) => { 19 | * updateHighlight(index); 20 | * } 21 | * }); 22 | * 23 | * // Set items when panel opens 24 | * nav.setItems(diagnostics); 25 | * 26 | * // Navigation commands 27 | * function next() { nav.next(); } 28 | * function prev() { nav.prev(); } 29 | * ``` 30 | */ 31 | export class NavigationController { 32 | private items: T[] = []; 33 | private currentIndex: number = 0; 34 | private options: NavigationOptions; 35 | 36 | constructor(options: NavigationOptions = {}) { 37 | this.options = { 38 | itemLabel: "Item", 39 | wrap: false, 40 | ...options, 41 | }; 42 | } 43 | 44 | /** 45 | * Set the items to navigate through 46 | * 47 | * @param items - Array of items 48 | * @param resetIndex - Whether to reset index to 0 (default true) 49 | */ 50 | setItems(items: T[], resetIndex: boolean = true): void { 51 | this.items = items; 52 | if (resetIndex) { 53 | this.currentIndex = 0; 54 | } else { 55 | // Clamp to valid range 56 | this.currentIndex = Math.min(this.currentIndex, Math.max(0, items.length - 1)); 57 | } 58 | } 59 | 60 | /** 61 | * Get all items 62 | */ 63 | getItems(): T[] { 64 | return this.items; 65 | } 66 | 67 | /** 68 | * Get the current selected index 69 | */ 70 | get selectedIndex(): number { 71 | return this.currentIndex; 72 | } 73 | 74 | /** 75 | * Set the selected index directly 76 | */ 77 | set selectedIndex(index: number) { 78 | if (this.items.length === 0) return; 79 | this.currentIndex = Math.max(0, Math.min(index, this.items.length - 1)); 80 | this.notifyChange(); 81 | } 82 | 83 | /** 84 | * Get the currently selected item 85 | */ 86 | get selected(): T | null { 87 | if (this.items.length === 0 || this.currentIndex >= this.items.length) { 88 | return null; 89 | } 90 | return this.items[this.currentIndex]; 91 | } 92 | 93 | /** 94 | * Get the total number of items 95 | */ 96 | get count(): number { 97 | return this.items.length; 98 | } 99 | 100 | /** 101 | * Check if there are any items 102 | */ 103 | get isEmpty(): boolean { 104 | return this.items.length === 0; 105 | } 106 | 107 | /** 108 | * Move to the next item 109 | */ 110 | next(): void { 111 | if (this.items.length === 0) return; 112 | 113 | if (this.options.wrap) { 114 | this.currentIndex = (this.currentIndex + 1) % this.items.length; 115 | } else { 116 | this.currentIndex = Math.min(this.currentIndex + 1, this.items.length - 1); 117 | } 118 | this.notifyChange(); 119 | } 120 | 121 | /** 122 | * Move to the previous item 123 | */ 124 | prev(): void { 125 | if (this.items.length === 0) return; 126 | 127 | if (this.options.wrap) { 128 | this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length; 129 | } else { 130 | this.currentIndex = Math.max(this.currentIndex - 1, 0); 131 | } 132 | this.notifyChange(); 133 | } 134 | 135 | /** 136 | * Move to the first item 137 | */ 138 | first(): void { 139 | if (this.items.length === 0) return; 140 | this.currentIndex = 0; 141 | this.notifyChange(); 142 | } 143 | 144 | /** 145 | * Move to the last item 146 | */ 147 | last(): void { 148 | if (this.items.length === 0) return; 149 | this.currentIndex = this.items.length - 1; 150 | this.notifyChange(); 151 | } 152 | 153 | /** 154 | * Jump to a specific index 155 | * 156 | * @param index - Target index 157 | */ 158 | jumpTo(index: number): void { 159 | if (this.items.length === 0) return; 160 | this.currentIndex = Math.max(0, Math.min(index, this.items.length - 1)); 161 | this.notifyChange(); 162 | } 163 | 164 | /** 165 | * Update the status message with current position 166 | * 167 | * @param customMessage - Optional custom message (overrides default) 168 | */ 169 | showStatus(customMessage?: string): void { 170 | if (this.items.length === 0) { 171 | editor.setStatus(`No ${this.options.itemLabel}s`); 172 | return; 173 | } 174 | 175 | const message = customMessage || 176 | `${this.options.itemLabel} ${this.currentIndex + 1}/${this.items.length}`; 177 | editor.setStatus(message); 178 | } 179 | 180 | /** 181 | * Reset the controller state 182 | */ 183 | reset(): void { 184 | this.items = []; 185 | this.currentIndex = 0; 186 | } 187 | 188 | /** 189 | * Find and select an item matching a predicate 190 | * 191 | * @param predicate - Function to test items 192 | * @returns true if found and selected, false otherwise 193 | */ 194 | findAndSelect(predicate: (item: T) => boolean): boolean { 195 | const index = this.items.findIndex(predicate); 196 | if (index !== -1) { 197 | this.currentIndex = index; 198 | this.notifyChange(); 199 | return true; 200 | } 201 | return false; 202 | } 203 | 204 | /** 205 | * Internal: Notify about selection change 206 | */ 207 | private notifyChange(): void { 208 | this.showStatus(); 209 | 210 | if (this.options.onSelectionChange && this.selected !== null) { 211 | this.options.onSelectionChange(this.selected, this.currentIndex); 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /plugins/git_grep.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /** 4 | * Git Grep Plugin 5 | * 6 | * Provides interactive git grep functionality with live search results. 7 | */ 8 | 9 | interface GrepMatch { 10 | file: string; 11 | line: number; 12 | column: number; 13 | content: string; 14 | } 15 | 16 | // State management 17 | let gitGrepResults: GrepMatch[] = []; 18 | 19 | // Parse git grep output line 20 | // Format: file:line:column:content 21 | function parseGitGrepLine(line: string): GrepMatch | null { 22 | const match = line.match(/^([^:]+):(\d+):(\d+):(.*)$/); 23 | if (match) { 24 | return { 25 | file: match[1], 26 | line: parseInt(match[2], 10), 27 | column: parseInt(match[3], 10), 28 | content: match[4].trimStart(), 29 | }; 30 | } 31 | return null; 32 | } 33 | 34 | // Parse git grep output into suggestions 35 | function parseGitGrepOutput(stdout: string): { 36 | results: GrepMatch[]; 37 | suggestions: PromptSuggestion[]; 38 | } { 39 | const results: GrepMatch[] = []; 40 | const suggestions: PromptSuggestion[] = []; 41 | 42 | for (const line of stdout.split("\n")) { 43 | if (!line.trim()) continue; 44 | const match = parseGitGrepLine(line); 45 | if (match) { 46 | results.push(match); 47 | suggestions.push({ 48 | text: `${match.file}:${match.line}:${match.column}`, 49 | description: match.content, 50 | value: `${match.file}:${match.line}:${match.column}`, 51 | disabled: false, 52 | }); 53 | 54 | // Limit to 100 results for performance 55 | if (results.length >= 100) { 56 | break; 57 | } 58 | } 59 | } 60 | 61 | return { results, suggestions }; 62 | } 63 | 64 | // Global function to start git grep 65 | globalThis.start_git_grep = function(): void { 66 | // Clear previous results 67 | gitGrepResults = []; 68 | 69 | // Start the prompt 70 | editor.startPrompt("Git grep: ", "git-grep"); 71 | editor.setStatus("Type to search..."); 72 | }; 73 | 74 | // React to prompt input changes 75 | globalThis.onGitGrepPromptChanged = function(args: { 76 | prompt_type: string; 77 | input: string; 78 | }): boolean { 79 | if (args.prompt_type !== "git-grep") { 80 | return true; // Not our prompt 81 | } 82 | 83 | const query = args.input; 84 | 85 | // Don't search for empty queries 86 | if (!query || query.trim() === "") { 87 | editor.setPromptSuggestions([]); 88 | return true; 89 | } 90 | 91 | // Spawn git grep asynchronously 92 | editor.spawnProcess("git", ["grep", "-n", "--column", "-I", "--", query]) 93 | .then((result) => { 94 | if (result.exit_code === 0) { 95 | // Parse results and update suggestions 96 | const { results, suggestions } = parseGitGrepOutput(result.stdout); 97 | gitGrepResults = results; 98 | 99 | // Update prompt with suggestions 100 | editor.setPromptSuggestions(suggestions); 101 | 102 | // Update status 103 | if (results.length > 0) { 104 | editor.setStatus(`Found ${results.length} matches`); 105 | } else { 106 | editor.setStatus("No matches found"); 107 | } 108 | } else if (result.exit_code === 1) { 109 | // No matches found (git grep returns 1) 110 | gitGrepResults = []; 111 | editor.setPromptSuggestions([]); 112 | editor.setStatus("No matches found"); 113 | } else { 114 | // Error occurred 115 | editor.setStatus(`Git grep error: ${result.stderr}`); 116 | } 117 | }) 118 | .catch((e) => { 119 | editor.setStatus(`Git grep error: ${e}`); 120 | }); 121 | 122 | return true; 123 | }; 124 | 125 | // Handle prompt confirmation (user pressed Enter) 126 | globalThis.onGitGrepPromptConfirmed = function(args: { 127 | prompt_type: string; 128 | selected_index: number | null; 129 | input: string; 130 | }): boolean { 131 | if (args.prompt_type !== "git-grep") { 132 | return true; // Not our prompt 133 | } 134 | 135 | editor.debug( 136 | `prompt-confirmed: selected_index=${args.selected_index}, num_results=${gitGrepResults.length}` 137 | ); 138 | 139 | // Check if user selected a suggestion 140 | if (args.selected_index !== null && gitGrepResults[args.selected_index]) { 141 | const selected = gitGrepResults[args.selected_index]; 142 | 143 | editor.debug(`Opening file: ${selected.file}:${selected.line}:${selected.column}`); 144 | 145 | // Open the file at the specific location 146 | editor.openFile(selected.file, selected.line, selected.column); 147 | editor.setStatus(`Opened ${selected.file}:${selected.line}:${selected.column}`); 148 | } else { 149 | // No selection 150 | editor.debug("No file selected - selected_index is null or out of bounds"); 151 | editor.setStatus("No file selected"); 152 | } 153 | 154 | return true; 155 | }; 156 | 157 | // Handle prompt cancellation (user pressed Escape) 158 | globalThis.onGitGrepPromptCancelled = function(args: { 159 | prompt_type: string; 160 | }): boolean { 161 | if (args.prompt_type !== "git-grep") { 162 | return true; // Not our prompt 163 | } 164 | 165 | // Clear results 166 | gitGrepResults = []; 167 | editor.setStatus("Git grep cancelled"); 168 | 169 | return true; 170 | }; 171 | 172 | // Register event handlers 173 | editor.on("prompt_changed", "onGitGrepPromptChanged"); 174 | editor.on("prompt_confirmed", "onGitGrepPromptConfirmed"); 175 | editor.on("prompt_cancelled", "onGitGrepPromptCancelled"); 176 | 177 | // Register command 178 | editor.registerCommand( 179 | "Git Grep", 180 | "Search for text in git-tracked files", 181 | "start_git_grep", 182 | "normal" 183 | ); 184 | 185 | // Log that plugin loaded successfully 186 | editor.debug("Git Grep plugin loaded successfully (TypeScript)"); 187 | editor.debug("Usage: Call start_git_grep() or use command palette 'Git Grep'"); 188 | editor.setStatus("Git Grep plugin ready"); 189 | -------------------------------------------------------------------------------- /src/services/signal_handler.rs: -------------------------------------------------------------------------------- 1 | /// Initialize signal handlers for SIGTERM and SIGINT. 2 | /// On Linux, dumps thread backtraces before terminating. 3 | /// On other platforms, this is a no-op (default terminal behavior applies). 4 | pub fn install_signal_handlers() { 5 | #[cfg(target_os = "linux")] 6 | linux::install_signal_handlers_with_backtrace(); 7 | } 8 | 9 | /// Linux-specific implementation with thread backtrace dumping 10 | #[cfg(target_os = "linux")] 11 | mod linux { 12 | use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; 13 | use std::collections::HashMap; 14 | use std::fs; 15 | use std::sync::atomic::{AtomicBool, Ordering}; 16 | use std::sync::Mutex; 17 | 18 | static SIGNAL_RECEIVED: AtomicBool = AtomicBool::new(false); 19 | static BACKTRACE_STORAGE: Mutex>> = Mutex::new(None); 20 | 21 | pub fn install_signal_handlers_with_backtrace() { 22 | // Initialize backtrace storage 23 | *BACKTRACE_STORAGE.lock().unwrap() = Some(HashMap::new()); 24 | 25 | // Install SIGUSR1 handler for individual thread backtrace capture 26 | install_backtrace_signal_handler(); 27 | 28 | let handler = move || { 29 | // Only handle the first signal 30 | if SIGNAL_RECEIVED.swap(true, Ordering::SeqCst) { 31 | return; 32 | } 33 | 34 | tracing::error!("Signal received, dumping thread backtraces..."); 35 | dump_all_thread_backtraces(); 36 | tracing::error!("Backtrace dump complete, terminating process"); 37 | 38 | // Terminate the process 39 | std::process::exit(130); // Standard exit code for Ctrl+C 40 | }; 41 | 42 | // Register SIGINT and SIGTERM handler 43 | if let Err(e) = ctrlc::set_handler(handler) { 44 | tracing::error!("Failed to set signal handler: {}", e); 45 | } 46 | } 47 | 48 | /// Install SIGUSR1 handler that captures backtrace for the receiving thread 49 | fn install_backtrace_signal_handler() { 50 | extern "C" fn backtrace_signal_handler(_: libc::c_int) { 51 | // Capture backtrace for this thread 52 | let backtrace = std::backtrace::Backtrace::force_capture(); 53 | let tid = unsafe { libc::syscall(libc::SYS_gettid) } as i32; 54 | 55 | // Store the backtrace 56 | if let Ok(mut storage) = BACKTRACE_STORAGE.lock() { 57 | if let Some(ref mut map) = *storage { 58 | map.insert(tid, format!("{}", backtrace)); 59 | } 60 | } 61 | } 62 | 63 | let handler = SigHandler::Handler(backtrace_signal_handler); 64 | let action = SigAction::new(handler, SaFlags::empty(), SigSet::empty()); 65 | 66 | unsafe { 67 | let _ = sigaction(Signal::SIGUSR1, &action); 68 | } 69 | } 70 | 71 | /// Dump backtraces for all threads to the tracing log 72 | fn dump_all_thread_backtraces() { 73 | // Clear any old backtraces 74 | if let Ok(mut storage) = BACKTRACE_STORAGE.lock() { 75 | if let Some(ref mut map) = *storage { 76 | map.clear(); 77 | } 78 | } 79 | 80 | // Get all thread IDs from /proc/self/task 81 | let thread_ids = get_all_thread_ids(); 82 | 83 | tracing::error!("=== Thread Backtrace Dump ==="); 84 | tracing::error!("Total threads: {}", thread_ids.len()); 85 | tracing::error!("Process ID: {}", std::process::id()); 86 | 87 | // Send SIGUSR1 to each thread to trigger backtrace capture 88 | for tid in &thread_ids { 89 | unsafe { 90 | // Send SIGUSR1 to the thread using tgkill 91 | libc::syscall( 92 | libc::SYS_tgkill, 93 | std::process::id() as i32, 94 | *tid, 95 | libc::SIGUSR1, 96 | ); 97 | } 98 | } 99 | 100 | // Give threads time to capture their backtraces 101 | std::thread::sleep(std::time::Duration::from_millis(100)); 102 | 103 | // Now print all captured backtraces 104 | let backtraces = BACKTRACE_STORAGE.lock().unwrap(); 105 | if let Some(ref map) = *backtraces { 106 | for (i, tid) in thread_ids.iter().enumerate() { 107 | // Read thread name from /proc 108 | let thread_name = read_thread_name(*tid); 109 | tracing::error!( 110 | "--- Thread {} (TID: {}, Name: {}) ---", 111 | i + 1, 112 | tid, 113 | thread_name 114 | ); 115 | 116 | if let Some(backtrace) = map.get(tid) { 117 | tracing::error!("Backtrace:\n{}", backtrace); 118 | } else { 119 | tracing::error!("(No backtrace captured for this thread)"); 120 | } 121 | } 122 | } 123 | 124 | tracing::error!("=== End Thread Backtrace Dump ==="); 125 | } 126 | 127 | /// Get all thread IDs (TIDs) in the process from /proc/self/task 128 | fn get_all_thread_ids() -> Vec { 129 | let mut thread_ids = Vec::new(); 130 | 131 | if let Ok(entries) = fs::read_dir("/proc/self/task") { 132 | for entry in entries.flatten() { 133 | if let Ok(file_name) = entry.file_name().into_string() { 134 | if let Ok(tid) = file_name.parse::() { 135 | thread_ids.push(tid); 136 | } 137 | } 138 | } 139 | } 140 | 141 | thread_ids.sort(); 142 | thread_ids 143 | } 144 | 145 | /// Read the thread name from /proc/self/task//comm 146 | fn read_thread_name(tid: i32) -> String { 147 | let path = format!("/proc/self/task/{}/comm", tid); 148 | fs::read_to_string(&path) 149 | .map(|s| s.trim().to_string()) 150 | .unwrap_or_else(|_| String::from("unknown")) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /plugins/lib/panel-manager.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { PanelOptions, PanelState } from "./types.ts"; 4 | 5 | /** 6 | * PanelManager - Manages panel lifecycle for split-view plugins 7 | * 8 | * Handles the common pattern of: 9 | * - Opening a virtual buffer in a split 10 | * - Tracking source split for navigation 11 | * - Closing and restoring previous state 12 | * - Updating panel content 13 | * 14 | * @example 15 | * ```typescript 16 | * const panel = new PanelManager("diagnostics", "diagnostics-list"); 17 | * 18 | * async function open() { 19 | * await panel.open({ entries: buildEntries(), ratio: 0.3 }); 20 | * } 21 | * 22 | * function close() { 23 | * panel.close(); 24 | * } 25 | * 26 | * function update() { 27 | * panel.updateContent(buildEntries()); 28 | * } 29 | * ``` 30 | */ 31 | export class PanelManager { 32 | private state: PanelState = { 33 | isOpen: false, 34 | bufferId: null, 35 | splitId: null, 36 | sourceSplitId: null, 37 | sourceBufferId: null, 38 | }; 39 | 40 | /** 41 | * Create a new PanelManager 42 | * 43 | * @param panelName - Display name for the panel (e.g., "*Diagnostics*") 44 | * @param modeName - Mode name for keybindings (e.g., "diagnostics-list") 45 | */ 46 | constructor( 47 | private readonly panelName: string, 48 | private readonly modeName: string 49 | ) {} 50 | 51 | /** 52 | * Check if the panel is currently open 53 | */ 54 | get isOpen(): boolean { 55 | return this.state.isOpen; 56 | } 57 | 58 | /** 59 | * Get the panel's buffer ID (null if not open) 60 | */ 61 | get bufferId(): number | null { 62 | return this.state.bufferId; 63 | } 64 | 65 | /** 66 | * Get the panel's split ID (null if not open) 67 | */ 68 | get splitId(): number | null { 69 | return this.state.splitId; 70 | } 71 | 72 | /** 73 | * Get the source split ID (where user was before opening panel) 74 | */ 75 | get sourceSplitId(): number | null { 76 | return this.state.sourceSplitId; 77 | } 78 | 79 | /** 80 | * Get the source buffer ID (to restore when closing) 81 | */ 82 | get sourceBufferId(): number | null { 83 | return this.state.sourceBufferId; 84 | } 85 | 86 | /** 87 | * Open the panel in a new split 88 | * 89 | * If already open, updates the content instead. 90 | * 91 | * @param options - Panel configuration 92 | * @returns The buffer ID of the panel 93 | */ 94 | async open(options: PanelOptions): Promise { 95 | const { entries, ratio = 0.3, showLineNumbers = false, editingDisabled = true } = options; 96 | 97 | if (this.state.isOpen && this.state.bufferId !== null) { 98 | // Panel already open - just update content 99 | this.updateContent(entries); 100 | return this.state.bufferId; 101 | } 102 | 103 | // Save current context 104 | this.state.sourceSplitId = editor.getActiveSplitId(); 105 | this.state.sourceBufferId = editor.getActiveBufferId(); 106 | 107 | // Create virtual buffer in split 108 | const bufferId = await editor.createVirtualBufferInSplit({ 109 | name: this.panelName, 110 | mode: this.modeName, 111 | read_only: true, 112 | entries, 113 | ratio, 114 | panel_id: this.panelName, 115 | show_line_numbers: showLineNumbers, 116 | editing_disabled: editingDisabled, 117 | }); 118 | 119 | // Track state 120 | this.state.bufferId = bufferId; 121 | this.state.splitId = editor.getActiveSplitId(); 122 | this.state.isOpen = true; 123 | 124 | return bufferId; 125 | } 126 | 127 | /** 128 | * Close the panel and restore previous state 129 | */ 130 | close(): void { 131 | if (!this.state.isOpen) { 132 | return; 133 | } 134 | 135 | // Close the split containing the panel 136 | if (this.state.splitId !== null) { 137 | editor.closeSplit(this.state.splitId); 138 | } 139 | 140 | // Focus back on source split 141 | if (this.state.sourceSplitId !== null) { 142 | editor.focusSplit(this.state.sourceSplitId); 143 | } 144 | 145 | // Reset state 146 | this.reset(); 147 | } 148 | 149 | /** 150 | * Update the panel content without closing/reopening 151 | * 152 | * @param entries - New entries to display 153 | */ 154 | updateContent(entries: TextPropertyEntry[]): void { 155 | if (!this.state.isOpen || this.state.bufferId === null) { 156 | return; 157 | } 158 | 159 | editor.setVirtualBufferContent(this.state.bufferId, entries); 160 | } 161 | 162 | /** 163 | * Reset panel state (called internally on close) 164 | */ 165 | reset(): void { 166 | this.state = { 167 | isOpen: false, 168 | bufferId: null, 169 | splitId: null, 170 | sourceSplitId: null, 171 | sourceBufferId: null, 172 | }; 173 | } 174 | 175 | /** 176 | * Focus the source split (useful for "goto" operations) 177 | */ 178 | focusSource(): void { 179 | if (this.state.sourceSplitId !== null) { 180 | editor.focusSplit(this.state.sourceSplitId); 181 | } 182 | } 183 | 184 | /** 185 | * Focus the panel split 186 | */ 187 | focusPanel(): void { 188 | if (this.state.splitId !== null) { 189 | editor.focusSplit(this.state.splitId); 190 | } 191 | } 192 | 193 | /** 194 | * Open a file in the source split (for navigation operations) 195 | * 196 | * @param filePath - Path to the file to open 197 | * @param line - Line number to jump to (1-indexed) 198 | * @param column - Column number to jump to (1-indexed) 199 | */ 200 | async openInSource(filePath: string, line: number, column: number): Promise { 201 | if (this.state.sourceSplitId === null) { 202 | return; 203 | } 204 | 205 | // Focus source split and open file 206 | editor.focusSplit(this.state.sourceSplitId); 207 | await editor.openFile(filePath); 208 | 209 | // Jump to location 210 | editor.gotoLine(line); 211 | if (column > 1) { 212 | editor.gotoColumn(column); 213 | } 214 | 215 | // Focus back on panel 216 | this.focusPanel(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /tests/e2e/unicode_cursor.rs: -------------------------------------------------------------------------------- 1 | use crate::common::harness::EditorTestHarness; 2 | use crossterm::event::{KeyCode, KeyModifiers}; 3 | 4 | /// Test that cursor position stays in sync when editing lines with non-ASCII characters 5 | /// This reproduces the bug where visual cursor position drifts from actual position 6 | /// when a line contains Unicode box-drawing characters or other multi-byte UTF-8 characters 7 | #[test] 8 | fn test_cursor_sync_with_non_ascii_box_drawing_chars() { 9 | let mut harness = EditorTestHarness::new(120, 30).unwrap(); 10 | 11 | // Type a line with box-drawing characters like in the bug report 12 | // Example: │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ 13 | let text_with_boxes = " 17 │ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │"; 14 | harness.type_text(text_with_boxes).unwrap(); 15 | harness.render().unwrap(); 16 | 17 | // Verify buffer content is correct 18 | harness.assert_buffer_content(text_with_boxes); 19 | 20 | // Get the buffer position (should be at end) 21 | let buffer_pos = harness.cursor_position(); 22 | let expected_buffer_pos = text_with_boxes.len(); 23 | assert_eq!( 24 | buffer_pos, expected_buffer_pos, 25 | "Cursor should be at end of text (byte position {}), but is at {}", 26 | expected_buffer_pos, buffer_pos 27 | ); 28 | 29 | // Move cursor to the beginning of the line 30 | harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap(); 31 | 32 | // Cursor should now be at position 0 33 | let buffer_pos_after_home = harness.cursor_position(); 34 | assert_eq!( 35 | buffer_pos_after_home, 0, 36 | "Cursor should be at position 0 after Home" 37 | ); 38 | 39 | // Now move cursor right character by character and verify screen position matches 40 | // The key insight: when moving through multi-byte UTF-8 characters, 41 | // the buffer position advances by the number of bytes in the character, 42 | // but the screen column should advance by 1 43 | 44 | // First, let's move right 10 times (through " 17 │ │ ") 45 | for i in 1..=10 { 46 | harness 47 | .send_key(KeyCode::Right, KeyModifiers::NONE) 48 | .unwrap(); 49 | 50 | let buffer_pos = harness.cursor_position(); 51 | let (screen_x, _screen_y) = harness.screen_cursor_position(); 52 | 53 | // The screen cursor position depends on gutter width 54 | // For this test, we're mainly checking that the screen cursor advances properly 55 | // The gutter width varies based on line numbers, so we'll focus on relative movement 56 | 57 | println!( 58 | "After {} right arrows: buffer_pos={}, screen_x={}", 59 | i, buffer_pos, screen_x 60 | ); 61 | } 62 | 63 | // Now test: type a character and verify it appears at the visual cursor position 64 | // Move to somewhere in the middle of the line 65 | harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap(); 66 | 67 | // Move right 20 characters 68 | for _ in 0..20 { 69 | harness 70 | .send_key(KeyCode::Right, KeyModifiers::NONE) 71 | .unwrap(); 72 | } 73 | 74 | let buffer_pos_before_insert = harness.cursor_position(); 75 | let (screen_x_before, screen_y_before) = harness.screen_cursor_position(); 76 | 77 | println!( 78 | "Before insert: buffer_pos={}, screen=({}, {})", 79 | buffer_pos_before_insert, screen_x_before, screen_y_before 80 | ); 81 | 82 | // Insert a marker character 'X' at this position 83 | harness.type_text("X").unwrap(); 84 | 85 | // Verify that 'X' appears at the expected position in the buffer 86 | let buffer_content_after = harness.get_buffer_content().unwrap(); 87 | println!("Buffer after insert: {:?}", buffer_content_after); 88 | 89 | // The 'X' should be inserted at buffer_pos_before_insert 90 | // and should appear visually at screen_x_before 91 | 92 | // Get the screen position where 'X' appears 93 | harness.render().unwrap(); 94 | 95 | // This is where the bug manifests: if cursor tracking is broken, 96 | // the 'X' will not appear at screen_x_before 97 | } 98 | 99 | /// Test cursor movement with simple multi-byte UTF-8 characters (emojis) 100 | #[test] 101 | fn test_cursor_sync_with_emoji() { 102 | let mut harness = EditorTestHarness::new(80, 24).unwrap(); 103 | 104 | // Type a line with emojis 105 | let text = "Hello 😀 World 🌍"; 106 | harness.type_text(text).unwrap(); 107 | 108 | // Move to beginning 109 | harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap(); 110 | 111 | // The text has these characters: 112 | // H e l l o 😀 W o r l d 🌍 113 | // 0 1 2 3 4 5 [6-9] 10 11 12 13 14 15 [16-19] 114 | // Note: 😀 is 4 bytes (U+1F600), 🌍 is 4 bytes (U+1F30D) 115 | 116 | // Move right 7 times should position us after the emoji 117 | for _ in 0..7 { 118 | harness 119 | .send_key(KeyCode::Right, KeyModifiers::NONE) 120 | .unwrap(); 121 | } 122 | 123 | let buffer_pos = harness.cursor_position(); 124 | // "Hello " = 6 bytes, "😀" = 4 bytes, so position should be 10 125 | assert_eq!( 126 | buffer_pos, 10, 127 | "After moving through 'Hello 😀', cursor should be at byte 10" 128 | ); 129 | 130 | // Type 'X' and verify it's inserted correctly 131 | harness.type_text("X").unwrap(); 132 | let expected = "Hello 😀X World 🌍"; 133 | harness.assert_buffer_content(expected); 134 | } 135 | 136 | /// Test that cursor position is correct when clicking on text with non-ASCII characters 137 | #[test] 138 | fn test_mouse_click_on_non_ascii_text() { 139 | let mut harness = EditorTestHarness::new(120, 30).unwrap(); 140 | 141 | // Type a line with box-drawing characters 142 | let text = "│ ┌──────────────┐ ┌──────────────┐ │"; 143 | harness.type_text(text).unwrap(); 144 | harness.render().unwrap(); 145 | 146 | // Now click on various positions in the line and verify cursor position 147 | 148 | // Get the gutter width first by checking where line 1 starts 149 | // The tab bar is at row 0, first line of text is at row 1 150 | let line_row = 1; 151 | 152 | // Click at the beginning of the text (after gutter) 153 | // We need to figure out where the gutter ends 154 | // Let's assume standard gutter of 8 chars for now: " " + " 1" + " │ " 155 | 156 | // This test may need adjustment based on actual gutter rendering 157 | } 158 | -------------------------------------------------------------------------------- /src/view/file_tree/node.rs: -------------------------------------------------------------------------------- 1 | use crate::services::fs::FsEntry; 2 | use std::fmt; 3 | 4 | /// Unique identifier for a tree node 5 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 6 | pub struct NodeId(pub usize); 7 | 8 | impl fmt::Display for NodeId { 9 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 10 | write!(f, "Node({})", self.0) 11 | } 12 | } 13 | 14 | /// Represents a node in the file tree 15 | #[derive(Debug, Clone)] 16 | pub struct TreeNode { 17 | /// Unique identifier 18 | pub id: NodeId, 19 | /// Filesystem entry information 20 | pub entry: FsEntry, 21 | /// Parent node ID (None for root) 22 | pub parent: Option, 23 | /// Child node IDs (for directories) 24 | pub children: Vec, 25 | /// Current state of the node 26 | pub state: NodeState, 27 | } 28 | 29 | impl TreeNode { 30 | /// Create a new tree node 31 | pub fn new(id: NodeId, entry: FsEntry, parent: Option) -> Self { 32 | let state = if entry.is_dir() { 33 | NodeState::Collapsed 34 | } else { 35 | NodeState::Leaf 36 | }; 37 | 38 | Self { 39 | id, 40 | entry, 41 | parent, 42 | children: Vec::new(), 43 | state, 44 | } 45 | } 46 | 47 | /// Check if this node is a directory 48 | pub fn is_dir(&self) -> bool { 49 | self.entry.is_dir() 50 | } 51 | 52 | /// Check if this node is a file 53 | pub fn is_file(&self) -> bool { 54 | self.entry.is_file() 55 | } 56 | 57 | /// Check if this node is expanded 58 | pub fn is_expanded(&self) -> bool { 59 | self.state == NodeState::Expanded 60 | } 61 | 62 | /// Check if this node is collapsed 63 | pub fn is_collapsed(&self) -> bool { 64 | self.state == NodeState::Collapsed 65 | } 66 | 67 | /// Check if this node is loading 68 | pub fn is_loading(&self) -> bool { 69 | self.state == NodeState::Loading 70 | } 71 | 72 | /// Check if this node has an error 73 | pub fn is_error(&self) -> bool { 74 | matches!(self.state, NodeState::Error(_)) 75 | } 76 | 77 | /// Check if this node is a leaf (file, not a directory) 78 | pub fn is_leaf(&self) -> bool { 79 | self.state == NodeState::Leaf 80 | } 81 | 82 | /// Get the depth of this node in the tree 83 | pub fn depth(&self, get_parent: impl Fn(NodeId) -> Option) -> usize { 84 | let mut depth = 0; 85 | let mut current = self.parent; 86 | 87 | while let Some(parent_id) = current { 88 | depth += 1; 89 | current = get_parent(parent_id); 90 | } 91 | 92 | depth 93 | } 94 | } 95 | 96 | /// State of a tree node 97 | #[derive(Debug, Clone, PartialEq)] 98 | pub enum NodeState { 99 | /// Directory not yet expanded 100 | Collapsed, 101 | /// Directory expanded, loading children 102 | Loading, 103 | /// Directory expanded, children loaded 104 | Expanded, 105 | /// Failed to load (with error message) 106 | Error(String), 107 | /// File (leaf node, cannot be expanded) 108 | Leaf, 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::*; 114 | use crate::services::fs::{FsEntry, FsEntryType}; 115 | use std::path::PathBuf; 116 | 117 | #[test] 118 | fn test_node_creation() { 119 | let entry = FsEntry::new( 120 | PathBuf::from("/test/file.txt"), 121 | "file.txt".to_string(), 122 | FsEntryType::File, 123 | ); 124 | 125 | let node = TreeNode::new(NodeId(0), entry, None); 126 | 127 | assert_eq!(node.id, NodeId(0)); 128 | assert_eq!(node.parent, None); 129 | assert!(node.is_file()); 130 | assert!(node.is_leaf()); 131 | assert_eq!(node.children.len(), 0); 132 | } 133 | 134 | #[test] 135 | fn test_directory_node() { 136 | let entry = FsEntry::new( 137 | PathBuf::from("/test/dir"), 138 | "dir".to_string(), 139 | FsEntryType::Directory, 140 | ); 141 | 142 | let node = TreeNode::new(NodeId(1), entry, Some(NodeId(0))); 143 | 144 | assert!(node.is_dir()); 145 | assert!(node.is_collapsed()); 146 | assert!(!node.is_expanded()); 147 | assert_eq!(node.parent, Some(NodeId(0))); 148 | } 149 | 150 | #[test] 151 | fn test_node_states() { 152 | let entry = FsEntry::new( 153 | PathBuf::from("/test/dir"), 154 | "dir".to_string(), 155 | FsEntryType::Directory, 156 | ); 157 | 158 | let mut node = TreeNode::new(NodeId(0), entry, None); 159 | 160 | assert!(node.is_collapsed()); 161 | assert!(!node.is_loading()); 162 | assert!(!node.is_error()); 163 | 164 | node.state = NodeState::Loading; 165 | assert!(node.is_loading()); 166 | assert!(!node.is_collapsed()); 167 | 168 | node.state = NodeState::Expanded; 169 | assert!(node.is_expanded()); 170 | assert!(!node.is_loading()); 171 | 172 | node.state = NodeState::Error("Failed to read".to_string()); 173 | assert!(node.is_error()); 174 | assert!(!node.is_expanded()); 175 | } 176 | 177 | #[test] 178 | fn test_node_depth() { 179 | // Create a simple tree structure 180 | let root = TreeNode::new( 181 | NodeId(0), 182 | FsEntry::new(PathBuf::from("/"), "/".to_string(), FsEntryType::Directory), 183 | None, 184 | ); 185 | 186 | let child1 = TreeNode::new( 187 | NodeId(1), 188 | FsEntry::new( 189 | PathBuf::from("/dir1"), 190 | "dir1".to_string(), 191 | FsEntryType::Directory, 192 | ), 193 | Some(NodeId(0)), 194 | ); 195 | 196 | let child2 = TreeNode::new( 197 | NodeId(2), 198 | FsEntry::new( 199 | PathBuf::from("/dir1/dir2"), 200 | "dir2".to_string(), 201 | FsEntryType::Directory, 202 | ), 203 | Some(NodeId(1)), 204 | ); 205 | 206 | // Helper function to get parent 207 | let get_parent = |id: NodeId| match id.0 { 208 | 0 => None, 209 | 1 => Some(NodeId(0)), 210 | 2 => Some(NodeId(1)), 211 | _ => None, 212 | }; 213 | 214 | assert_eq!(root.depth(get_parent), 0); 215 | assert_eq!(child1.depth(get_parent), 1); 216 | assert_eq!(child2.depth(get_parent), 2); 217 | } 218 | } 219 | --------------------------------------------------------------------------------