diff --git a/Cargo.lock b/Cargo.lock index 929978a..22bf7d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,9 +329,9 @@ dependencies = [ [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "capacity_builder" diff --git a/README.md b/README.md index e873a80..b0a685a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,13 @@ cursor_line = true [lsp] enabled = true +[lsp.servers.typescript] +command = "typescript-language-server" +args = ["--stdio"] +language_id = "typescript" +file_extensions = ["ts", "tsx"] +root_markers = ["package.json", ".git"] + # Plugin settings [plugins] enabled = true diff --git a/default_config.toml b/default_config.toml index 2fe06e0..eb96ad0 100644 --- a/default_config.toml +++ b/default_config.toml @@ -62,7 +62,7 @@ Esc = { EnterMode = "Normal" } "n" = [ "FindNext" ] "N" = [ "FindPrevious" ] "a" = [ { EnterMode = "Insert" }, "MoveRight" ] -"A" = [ { EnterMode = "Insert" }, "MoveToLineEnd" ] +"A" = [ "MoveToLineEnd", "MoveRight", { EnterMode = "Insert" } ] "i" = { EnterMode = "Insert" } "I" = [ { EnterMode = "Insert" }, "MoveToFirstLineChar" ] ";" = { EnterMode = "Command" } @@ -94,6 +94,7 @@ Esc = { EnterMode = "Normal" } # } "Ctrl-p" = "FilePicker" "Ctrl-z" = "Suspend" +"Ctrl-e" = { PluginCommand = "NeoTree" } "K" = "Hover" # "W" = "ToggleWrap" # "L" = "DecreaseLeft" @@ -138,6 +139,8 @@ Esc = { EnterMode = "Normal" } "j" = "MoveDown" "h" = "MoveLeft" "l" = "MoveRight" +"$" = "MoveToLineEnd" +"End" = "MoveToLineEnd" "y" = [ "Yank", { EnterMode = "Normal" } ] "x" = [ "Delete", { EnterMode = "Normal" } ] "p" = [ "Paste", { EnterMode = "Normal" } ] @@ -152,6 +155,8 @@ Esc = { EnterMode = "Normal" } [keys.visual_block] Esc = { EnterMode = "Normal" } +"$" = "MoveToLineEnd" +"End" = "MoveToLineEnd" [keys.visual_line] Esc = { EnterMode = "Normal" } @@ -162,3 +167,4 @@ Esc = { EnterMode = "Normal" } [plugins] buffer_picker = "buffer_picker.js" +neotree = "neotree.js" diff --git a/docs/PLUGIN_SYSTEM.md b/docs/PLUGIN_SYSTEM.md index 5ae1ec9..83e3e81 100644 --- a/docs/PLUGIN_SYSTEM.md +++ b/docs/PLUGIN_SYSTEM.md @@ -149,8 +149,44 @@ red.openBuffer(name: string) // Draw text at specific coordinates red.drawText(x: number, y: number, text: string, style?: object) + +// Create and update a persistent side panel +red.createPanel("tree", { side: "left", width: 32, title: "Files" }) +red.updatePanel("tree", [{ + id: "/repo/src", + label: "src", + path: "/repo/src", + depth: 0, + expanded: true, + kind: "directory" +}]) +red.focusPanel("tree") +red.focusEditor() +red.closePanel("tree") +red.onPanelEvent("tree", (event) => { + // event.action is "up", "down", "expand", "collapse", or "activate" +}) +``` + +Panel rows are rendered by the editor and receive focused keyboard input +while the panel is active. Plugins can call `focusEditor()` to return input +to the editor after handling a panel action. Pressing `Esc` also returns +focus to the editor. + +#### Filesystem +```javascript +const { entries, error } = await red.listDirectory(".") +const watchId = red.watchDirectory(".", async (snapshot) => { + // snapshot has the same shape as listDirectory() +}) +red.unwatchDirectory(watchId) +red.openFile("src/main.rs") ``` +`listDirectory` returns entries sorted with directories before files. Plugins +do not receive arbitrary filesystem access; they request directory listings +and directory watches through editor-owned APIs. + #### Buffer Manipulation ```javascript // Insert text at position @@ -489,4 +525,4 @@ Areas identified for potential improvement: The Red editor's plugin system provides a robust foundation for extending editor functionality while maintaining security and performance. By leveraging Deno's runtime and a well-designed API, developers can create powerful plugins that integrate seamlessly with the editor's core functionality. -For questions or contributions to the plugin system, please refer to the main Red editor repository and its contribution guidelines. \ No newline at end of file +For questions or contributions to the plugin system, please refer to the main Red editor repository and its contribution guidelines. diff --git a/plugins/neotree.js b/plugins/neotree.js new file mode 100644 index 0000000..7b5f1b5 --- /dev/null +++ b/plugins/neotree.js @@ -0,0 +1,149 @@ +const PANEL_ID = "neotree"; +const ROOT = "."; + +let redApi = null; +const watches = new Map(); + +export async function activate(red) { + redApi = red; + const expanded = new Set([ROOT]); + const children = new Map(); + let created = false; + + async function loadDirectory(path) { + const result = await red.listDirectory(path); + if (result.error) { + red.logWarn("NeoTree failed to list directory", path, result.error); + children.set(path, []); + return []; + } + children.set(path, result.entries); + return result.entries; + } + + function watchDirectory(path) { + if (watches.has(path)) return; + const watchId = red.watchDirectory(path, async () => { + await loadDirectory(path); + await refresh(); + }); + watches.set(path, watchId); + } + + async function ensureLoaded(path) { + if (!children.has(path)) { + await loadDirectory(path); + } + watchDirectory(path); + return children.get(path) || []; + } + + async function buildRows(path, depth = 0, rows = []) { + const entries = await ensureLoaded(path); + for (const entry of entries) { + if (entry.kind !== "directory" && entry.kind !== "file") { + continue; + } + + const isDirectory = entry.kind === "directory"; + rows.push({ + id: entry.path, + label: entry.name, + path: entry.path, + depth, + expanded: isDirectory ? expanded.has(entry.path) : false, + kind: isDirectory ? "directory" : "file", + }); + + if (isDirectory && expanded.has(entry.path)) { + await buildRows(entry.path, depth + 1, rows); + } + } + return rows; + } + + async function refresh() { + const rows = await buildRows(ROOT); + red.updatePanel(PANEL_ID, rows); + } + + function stopWatchingDirectories() { + for (const watchId of watches.values()) { + red.unwatchDirectory(watchId); + } + watches.clear(); + } + + function close() { + if (!created) return; + stopWatchingDirectories(); + red.closePanel(PANEL_ID); + red.focusEditor(); + created = false; + } + + async function show() { + if (!created) { + red.createPanel(PANEL_ID, { + side: "left", + width: 32, + title: "Files", + }); + created = true; + } + + await ensureLoaded(ROOT); + await refresh(); + red.focusPanel(PANEL_ID); + } + + async function toggleDirectory(path, forceExpand = null) { + const shouldExpand = forceExpand ?? !expanded.has(path); + if (shouldExpand) { + expanded.add(path); + await ensureLoaded(path); + } else { + expanded.delete(path); + } + await refresh(); + } + + red.addCommand("NeoTree", async () => { + if (created) { + close(); + } else { + await show(); + } + }); + + red.onPanelEvent(PANEL_ID, async (event) => { + const row = event.row; + if (!row) return; + + if (event.action === "activate") { + if (row.kind === "directory") { + await toggleDirectory(row.path); + } else if (row.path) { + red.openFile(row.path); + red.focusEditor(); + } + return; + } + + if (row.kind === "directory" && event.action === "expand") { + await toggleDirectory(row.path, true); + } + + if (row.kind === "directory" && event.action === "collapse") { + await toggleDirectory(row.path, false); + } + }); +} + +export async function deactivate() { + if (!redApi) return; + for (const watchId of watches.values()) redApi.unwatchDirectory(watchId); + watches.clear(); + redApi.closePanel(PANEL_ID); + redApi = null; +} diff --git a/src/buffer.rs b/src/buffer.rs index f226f16..4ecae4d 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -4,7 +4,7 @@ use std::path::Path; use path_absolutize::Absolutize; use crate::lsp::LspClient; -use crate::undo::{UndoChange, UndoGroup, UndoHistory}; +use crate::undo::{TextPosition, TextRange, UndoHistory}; use crate::unicode_utils::{char_to_column, column_to_char, display_width}; /// Buffer represents an editable text buffer, which may be associated with a file. @@ -26,8 +26,8 @@ pub struct Buffer { /// Top line number of the viewport (for scrolling) pub vtop: usize, - /// Undo/redo history for this buffer - undo_history: UndoHistory, + /// Buffer-local undo and redo history. + pub undo_history: UndoHistory, } impl Buffer { @@ -119,10 +119,10 @@ impl Buffer { /// Saves the buffer contents to its associated file pub fn save(&mut self) -> anyhow::Result { - if let Some(file) = &self.file { + if let Some(file) = self.file.clone() { let contents = self.contents(); - std::fs::write(file, &contents)?; - self.dirty = false; + std::fs::write(&file, &contents)?; + self.mark_saved(); let message = format!("{:?} {}L, {}B written", file, self.len(), contents.len()); Ok(message) } else { @@ -134,8 +134,8 @@ impl Buffer { pub fn save_as(&mut self, new_file_name: &str) -> anyhow::Result { let contents = self.contents(); std::fs::write(new_file_name, &contents)?; - self.dirty = false; self.file = Some(new_file_name.to_string()); + self.mark_saved(); let message = format!( "{:?} {}L, {}B written", new_file_name, @@ -169,11 +169,11 @@ impl Buffer { /// Sets the content of a line pub fn set(&mut self, line: usize, content: String) { - if line >= self.len() { + if line > self.len() { return; } let start_char = self.content.line_to_char(line); - let end_char = if line + 1 < self.len() { + let end_char = if line + 1 < self.content.len_lines() { self.content.line_to_char(line + 1) } else { self.content.len_chars() @@ -188,6 +188,19 @@ impl Buffer { self.content.len_lines() - 1 } + pub fn last_navigable_line(&self) -> usize { + let last_line = self.len(); + if last_line > 0 && self.get(last_line).is_some_and(|line| line.is_empty()) { + last_line - 1 + } else { + last_line + } + } + + pub fn navigable_line_count(&self) -> usize { + self.last_navigable_line() + 1 + } + /// Returns true if the buffer is empty pub fn is_empty(&self) -> bool { self.content.len_bytes() == 0 @@ -247,6 +260,50 @@ impl Buffer { self.dirty = true; } + pub fn text_in_range(&self, range: TextRange) -> String { + let start_char = self.position_to_char_idx(range.start); + let end_char = self.position_to_char_idx(range.end); + self.content + .get_slice(start_char..end_char) + .map(|slice| slice.to_string()) + .unwrap_or_default() + } + + pub fn replace_range_raw(&mut self, range: TextRange, text: &str) { + let start_char = self.position_to_char_idx(range.start); + let end_char = self.position_to_char_idx(range.end); + self.content.remove(start_char..end_char); + self.content.insert(start_char, text); + self.dirty = true; + } + + pub fn range_for_text(&self, start: TextPosition, text: &str) -> TextRange { + let mut line = start.line; + let mut character = start.character; + + for c in text.chars() { + if c == '\n' { + line += 1; + character = 0; + } else { + character += 1; + } + } + + TextRange::new(start, TextPosition::new(line, character)) + } + + pub fn position_to_char_idx(&self, position: TextPosition) -> usize { + if position.line >= self.content.len_lines() { + return self.content.len_chars(); + } + + let line_start = self.content.line_to_char(position.line); + let line = self.content.line(position.line).to_string(); + let line_len = line.trim_end_matches('\n').chars().count(); + line_start + position.character.min(line_len) + } + /// Inserts a new line at the given line number pub fn insert_line(&mut self, y: usize, content: String) { let char_idx = if y >= self.content.len_lines() { @@ -275,11 +332,11 @@ impl Buffer { /// Replaces a line with new content pub fn replace_line(&mut self, line: usize, new_line: String) { - if line >= self.len() { + if line > self.len() { return; } let start_char = self.content.line_to_char(line); - let end_char = if line + 1 < self.len() { + let end_char = if line + 1 < self.content.len_lines() { self.content.line_to_char(line + 1) } else { self.content.len_chars() @@ -291,7 +348,7 @@ impl Buffer { /// Gets a portion of the buffer for viewport rendering pub fn viewport(&self, vtop: usize, vheight: usize) -> String { - let height = std::cmp::min(vtop + vheight, self.content.len_lines()); + let height = std::cmp::min(vtop + vheight, self.navigable_line_count()); let mut result = String::new(); for i in vtop..height { result.push_str(&self.content.line(i).to_string()); @@ -320,11 +377,11 @@ impl Buffer { loop { let line = self.get(y)?; - if x >= line.len() { + if x >= line.chars().count() { // Move to next line if at end y += 1; x = 0; - if y >= self.len() { + if y > self.len() { return None; } continue; @@ -334,7 +391,8 @@ impl Buffer { let current_type = Self::get_char_type(current_char); // Skip current word/sequence - while x < line.len() { + let line_len = line.chars().count(); + while x < line_len { let c = line.chars().nth(x)?; if Self::get_char_type(c) != current_type { break; @@ -343,7 +401,7 @@ impl Buffer { } // Skip whitespace - while x < line.len() { + while x < line_len { let c = line.chars().nth(x)?; if !c.is_whitespace() { return Some((x, y)); @@ -352,10 +410,10 @@ impl Buffer { } // If we reach end of line, continue to next line - if x >= line.len() { + if x >= line_len { y += 1; x = 0; - if y >= self.len() { + if y > self.len() { return None; } } @@ -367,8 +425,9 @@ impl Buffer { let line = self.get(y)?; let mut x = x; let chars = line.chars().skip(x); + let line_len = line.chars().count(); for c in chars { - if x >= line.len() { + if x >= line_len { return Some((x, y)); } if !c.is_alphanumeric() && c != '_' { @@ -385,7 +444,8 @@ impl Buffer { let line = self.get(y)?; // Check if we're at the last character of the buffer - if y >= self.len().saturating_sub(1) && x >= line.len().saturating_sub(1) { + let line_len = line.chars().count(); + if y >= self.len() && x >= line_len.saturating_sub(1) { return None; } @@ -393,7 +453,7 @@ impl Buffer { // without doing anything else if line.is_empty() { y += 1; - if y >= self.len() { + if y > self.len() { return None; } return Some((0, y)); @@ -404,7 +464,7 @@ impl Buffer { // If we're at the end of current line, move to next line if x >= chars.len() { y += 1; - if y >= self.len() { + if y > self.len() { return None; } x = 0; @@ -455,7 +515,7 @@ impl Buffer { } y += 1; - if y >= self.len() { + if y > self.len() { return None; } @@ -564,13 +624,15 @@ impl Buffer { let (mut x, mut y) = self.find_word_end((x, y))?; loop { - if y >= self.len() { + if y > self.len() { return None; } let line = self.get(y)?; - if let Some(pos) = line[x..].find(query) { - return Some((pos + x, y)); + let suffix = crate::unicode_utils::char_suffix(&line, x); + if let Some(pos) = suffix.find(query) { + let prefix_chars = suffix[..pos].chars().count(); + return Some((prefix_chars + x, y)); } x = 0; @@ -583,13 +645,14 @@ impl Buffer { let (mut x, mut y) = self.find_word_start((x, y))?; loop { - if y >= self.len() { + if y > self.len() { return None; } let line = self.get(y)?; - if let Some(pos) = line[..x].rfind(query) { - return Some((pos, y)); + let prefix = crate::unicode_utils::char_prefix(&line, x); + if let Some(pos) = prefix.rfind(query) { + return Some((prefix[..pos].chars().count(), y)); } if y == 0 { @@ -597,7 +660,7 @@ impl Buffer { } y -= 1; - x = self.get(y)?.len(); + x = self.get(y)?.chars().count(); } } @@ -610,11 +673,9 @@ impl Buffer { let end_char = self.xy_to_char_idx(end.0, end.1); // Get the text before removing (need to use byte indices for slice) - let start_byte = self.content.char_to_byte(start_char); - let end_byte = self.content.char_to_byte(end_char); let result = self .content - .get_slice(start_byte..end_byte) + .get_slice(start_char..end_char) .map(|s| s.to_string()); self.content.remove(start_char..end_char); @@ -628,6 +689,15 @@ impl Buffer { self.dirty } + pub fn refresh_dirty_from_history(&mut self) { + self.dirty = self.undo_history.is_dirty(); + } + + pub fn mark_saved(&mut self) { + self.undo_history.mark_saved(); + self.refresh_dirty_from_history(); + } + // Helper method to convert (x,y) coordinates to character index in the rope fn xy_to_char_idx(&self, x: usize, y: usize) -> usize { if y >= self.content.len_lines() { @@ -694,260 +764,6 @@ impl Buffer { CharType::Symbol } } - - // ==================== Undo/Redo Methods ==================== - - /// Record a change for undo. Call this after making changes to the buffer. - pub fn record_undo(&mut self, change: UndoChange, cursor_pos: (usize, usize)) { - self.undo_history.record(change, cursor_pos); - } - - /// Start a grouped undo operation (e.g., entering insert mode) - pub fn start_undo_group(&mut self, cursor_pos: (usize, usize)) { - self.undo_history.start_group(cursor_pos); - } - - /// End a grouped undo operation - pub fn end_undo_group(&mut self) { - self.undo_history.end_group(); - } - - /// Check if we're in a grouped undo operation - pub fn in_undo_group(&self) -> bool { - self.undo_history.in_group() - } - - /// Perform undo and return the group of changes to apply - pub fn undo(&mut self) -> Option { - self.undo_history.undo() - } - - /// Perform redo and return the group of changes to apply - pub fn redo(&mut self) -> Option { - self.undo_history.redo() - } - - /// Check if undo is available - pub fn can_undo(&self) -> bool { - self.undo_history.can_undo() - } - - /// Check if redo is available - pub fn can_redo(&self) -> bool { - self.undo_history.can_redo() - } - - // ==================== Undo-Tracked Editing Methods ==================== - // These methods perform edits AND record undo information - - /// Insert a character with undo tracking - pub fn insert_with_undo(&mut self, x: usize, y: usize, c: char, cursor_pos: (usize, usize)) { - self.insert(x, y, c); - self.record_undo(UndoChange::InsertChar { x, y, c }, cursor_pos); - } - - /// Insert a string with undo tracking - pub fn insert_str_with_undo(&mut self, x: usize, y: usize, s: &str, cursor_pos: (usize, usize)) { - self.insert_str(x, y, s); - self.record_undo( - UndoChange::InsertString { - x, - y, - s: s.to_string(), - }, - cursor_pos, - ); - } - - /// Remove a character with undo tracking - pub fn remove_with_undo(&mut self, x: usize, y: usize, cursor_pos: (usize, usize)) { - // Capture the character before removing it - if let Some(line) = self.get(y) { - let chars: Vec = line.chars().collect(); - if x < chars.len() { - let c = chars[x]; - self.remove(x, y); - self.record_undo(UndoChange::DeleteChar { x, y, c }, cursor_pos); - } - } - } - - /// Remove previous character (backspace) with undo tracking - /// Returns the new cursor x position if successful - pub fn remove_prev_char_with_undo(&mut self, x: usize, y: usize, cursor_pos: (usize, usize)) -> Option { - if x > 0 { - if let Some(line) = self.get(y) { - let chars: Vec = line.chars().collect(); - let prev_x = x - 1; - if prev_x < chars.len() { - let c = chars[prev_x]; - self.remove(prev_x, y); - self.record_undo(UndoChange::DeleteChar { x: prev_x, y, c }, cursor_pos); - return Some(prev_x); - } - } - } - None - } - - /// Delete a line with undo tracking - pub fn remove_line_with_undo(&mut self, y: usize, cursor_pos: (usize, usize)) { - if let Some(content) = self.get(y) { - self.remove_line(y); - self.record_undo( - UndoChange::DeleteLine { - y, - content: content.to_string(), - }, - cursor_pos, - ); - } - } - - /// Insert a line with undo tracking - pub fn insert_line_with_undo(&mut self, y: usize, content: String, cursor_pos: (usize, usize)) { - self.insert_line(y, content.clone()); - self.record_undo(UndoChange::InsertLine { y, content }, cursor_pos); - } - - /// Replace a line with undo tracking - pub fn replace_line_with_undo(&mut self, y: usize, new_content: String, cursor_pos: (usize, usize)) { - if let Some(old_content) = self.get(y) { - let old = old_content.to_string(); - self.replace_line(y, new_content.clone()); - self.record_undo( - UndoChange::ReplaceLine { - y, - old, - new: new_content, - }, - cursor_pos, - ); - } - } - - /// Delete word with undo tracking - pub fn delete_word_with_undo(&mut self, pos: (usize, usize), cursor_pos: (usize, usize)) -> Option { - let (x, y) = pos; - if let Some(deleted_text) = self.delete_word(pos) { - self.record_undo( - UndoChange::DeleteString { - x, - y, - s: deleted_text.clone(), - }, - cursor_pos, - ); - Some(deleted_text) - } else { - None - } - } - - /// Remove a range with undo tracking - pub fn remove_range_with_undo( - &mut self, - x0: usize, - y0: usize, - x1: usize, - y1: usize, - cursor_pos: (usize, usize), - ) { - // Capture the content before removing - let content = self.get_range_content(x0, y0, x1, y1); - self.remove_range(x0, y0, x1, y1); - self.record_undo( - UndoChange::DeleteRange { - x0, - y0, - x1, - y1, - content, - }, - cursor_pos, - ); - } - - /// Get the content of a range (helper for undo) - fn get_range_content(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> String { - let mut content = String::new(); - - if y0 == y1 { - // Single line range - if let Some(line) = self.get(y0) { - let chars: Vec = line.chars().collect(); - for i in x0..x1.min(chars.len()) { - content.push(chars[i]); - } - } - } else { - // Multi-line range - for y in y0..=y1 { - if let Some(line) = self.get(y) { - if y == y0 { - // First line: from x0 to end - let chars: Vec = line.chars().collect(); - for i in x0..chars.len() { - content.push(chars[i]); - } - } else if y == y1 { - // Last line: from start to x1 - let chars: Vec = line.chars().collect(); - for i in 0..x1.min(chars.len()) { - content.push(chars[i]); - } - } else { - // Middle lines: entire line - content.push_str(&line); - } - } - } - } - - content - } - - /// Apply an undo change (used when executing undo/redo) - pub fn apply_undo_change(&mut self, change: &UndoChange) { - match change { - UndoChange::InsertChar { x, y, c } => { - self.insert(*x, *y, *c); - } - UndoChange::DeleteChar { x, y, .. } => { - self.remove(*x, *y); - } - UndoChange::InsertString { x, y, s } => { - self.insert_str(*x, *y, s); - } - UndoChange::DeleteString { x, y, s } => { - // Delete the string by removing range - self.remove_range(*x, *y, x + s.chars().count(), *y); - } - UndoChange::InsertLine { y, content } => { - self.insert_line(*y, content.clone()); - } - UndoChange::DeleteLine { y, .. } => { - self.remove_line(*y); - } - UndoChange::ReplaceLine { y, new, .. } => { - self.replace_line(*y, new.clone()); - } - UndoChange::DeleteRange { x0, y0, x1, y1, .. } => { - self.remove_range(*x0, *y0, *x1, *y1); - } - } - self.dirty = true; - } - - /// Apply an undo group (multiple changes in reverse order) - pub fn apply_undo_group(&mut self, group: &UndoGroup) -> (usize, usize) { - // Apply changes in reverse order for undo - for change in group.changes.iter().rev() { - self.apply_undo_change(change); - } - // Return the cursor position to restore - group.cursor_before - } } #[derive(Debug, PartialEq)] diff --git a/src/config.rs b/src/config.rs index 9e37f6e..b850995 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, path::PathBuf}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{json, Value}; use crate::editor::Action; @@ -12,12 +13,149 @@ pub struct Config { pub plugins: HashMap, pub log_file: Option, pub mouse_scroll_lines: Option, + #[serde(default)] + pub lsp: LspConfig, #[serde(default = "default_true")] pub show_diagnostics: bool, #[serde(default = "default_false")] pub window_borders_ascii: bool, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LspConfig { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde( + default = "default_language_servers", + deserialize_with = "deserialize_language_servers" + )] + pub servers: HashMap, +} + +impl Default for LspConfig { + fn default() -> Self { + Self { + enabled: true, + servers: default_language_servers(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct LanguageServerConfig { + pub command: String, + #[serde(default)] + pub args: Vec, + pub language_id: String, + #[serde(default)] + pub file_extensions: Vec, + #[serde(default)] + pub root_markers: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default, skip_serializing)] + pub initialization_options: Option, + pub workspace_name: Option, +} + +pub fn default_language_servers() -> HashMap { + HashMap::from([( + "rust".to_string(), + LanguageServerConfig { + command: "rust-analyzer".to_string(), + args: vec!["-v".to_string()], + language_id: "rust".to_string(), + file_extensions: vec!["rs".to_string()], + root_markers: vec!["Cargo.toml".to_string(), ".git".to_string()], + env: HashMap::new(), + initialization_options: Some(rust_analyzer_initialization_options()), + workspace_name: Some("red".to_string()), + }, + )]) +} + +fn deserialize_language_servers<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let user_servers = HashMap::::deserialize(deserializer)?; + let mut servers = default_language_servers(); + servers.extend(user_servers); + Ok(servers) +} + +pub fn rust_analyzer_initialization_options() -> Value { + json!({ + "restartServerOnConfigChange": false, + "showUnlinkedFileNotification": true, + "showRequestFailedErrorNotification": true, + "showDependenciesExplorer": true, + "testExplorer": false, + "initializeStopped": false, + "runnables": { + "extraEnv": null, + "problemMatcher": [ + "$rustc" + ], + "askBeforeUpdateTest": true, + "command": null, + "extraArgs": [], + "extraTestBinaryArgs": [ + "--show-output" + ] + }, + "statusBar": { + "clickAction": "openLogs", + "showStatusBar": { + "documentSelector": [ + { + "language": "rust" + }, + { + "pattern": "**/Cargo.toml" + }, + { + "pattern": "**/Cargo.lock" + } + ] + } + }, + "server": { + "path": null, + "extraEnv": null + }, + "trace": { + "server": "verbose", + "extension": false + }, + "debug": { + "engine": "auto", + "sourceFileMap": { + "/rustc/": "${env:USERPROFILE}/.rustup/toolchains//lib/rustlib/src/rust" + }, + "openDebugPane": false, + "buildBeforeRestart": false, + "engineSettings": {} + }, + "typing": { + "continueCommentsOnNewline": true, + "excludeChars": "|<" + }, + "diagnostics": { + "previewRustcOutput": false, + "useRustcErrorCode": false, + "disabled": [], + "enable": true, + "experimental": { + "enable": false + }, + "remapPrefix": {}, + } + }) +} + impl Config { pub fn path(p: &str) -> PathBuf { #[allow(deprecated)] @@ -96,4 +234,55 @@ mod test { let toml = toml::to_string(&config).unwrap(); println!("{toml}"); } + + #[test] + fn test_lsp_config_defaults_to_rust() { + let config: Config = toml::from_str( + r#" +theme = "theme/nightfox.json" + +[keys] +"#, + ) + .unwrap(); + + let rust = config.lsp.servers.get("rust").unwrap(); + assert!(config.lsp.enabled); + assert_eq!(rust.command, "rust-analyzer"); + assert_eq!(rust.args, vec!["-v"]); + assert_eq!(rust.language_id, "rust"); + assert_eq!(rust.file_extensions, vec!["rs"]); + } + + #[test] + fn test_lsp_config_accepts_additional_servers() { + let config: Config = toml::from_str( + r#" +theme = "theme/nightfox.json" + +[keys] + +[lsp] +enabled = true + +[lsp.servers.typescript] +command = "typescript-language-server" +args = ["--stdio"] +language_id = "typescript" +file_extensions = ["ts", "tsx"] +root_markers = ["package.json", ".git"] +workspace_name = "frontend" +"#, + ) + .unwrap(); + + let server = config.lsp.servers.get("typescript").unwrap(); + assert!(config.lsp.servers.contains_key("rust")); + assert_eq!(server.command, "typescript-language-server"); + assert_eq!(server.args, vec!["--stdio"]); + assert_eq!(server.language_id, "typescript"); + assert_eq!(server.file_extensions, vec!["ts", "tsx"]); + assert_eq!(server.root_markers, vec!["package.json", ".git"]); + assert_eq!(server.workspace_name.as_deref(), Some("frontend")); + } } diff --git a/src/editor.rs b/src/editor.rs index 23fdf6d..d917047 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -5,12 +5,14 @@ use std::{ cmp::Ordering, collections::{HashMap, VecDeque}, io::stdout, - mem, path::PathBuf, time::{Duration, Instant}, }; -use crate::unicode_utils::{display_width, next_grapheme_boundary, prev_grapheme_boundary}; +use crate::unicode_utils::{ + char_prefix, char_slice, char_suffix, char_to_grapheme, display_width, grapheme_len, + grapheme_to_byte, grapheme_to_char, next_grapheme_boundary, prev_grapheme_boundary, +}; /// Editor is the main component that handles: /// - Text editing operations @@ -37,6 +39,7 @@ use nix::unistd::Pid; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use unicode_segmentation::UnicodeSegmentation; pub use render_buffer::RenderBuffer; @@ -55,7 +58,7 @@ use crate::{ plugin::{self, PluginRegistry, Runtime}, theme::{Style, Theme}, ui::{CompletionUI, Component, FilePicker, Info, Picker}, - undo::UndoChange, + undo::{CursorSnapshot, TextPosition, TextRange}, utils::get_workspace_uri, window::WindowManager, }; @@ -131,6 +134,32 @@ pub enum PluginRequest { RemoveOverlay { id: String, }, + CreatePanel { + id: String, + config: plugin::PanelConfig, + }, + UpdatePanel { + id: String, + rows: Vec, + }, + FocusPanel { + id: String, + }, + FocusEditor, + ClosePanel { + id: String, + }, + ListDirectory { + path: String, + request_id: i32, + }, + WatchDirectory { + path: String, + watch_id: i32, + }, + UnwatchDirectory { + watch_id: i32, + }, } #[derive(Debug)] @@ -146,6 +175,12 @@ pub enum RenderCommand { #[allow(unused)] pub struct PluginResponse(serde_json::Value); +struct DirectoryWatcher { + path: String, + snapshot: Value, + last_checked: Instant, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Action { Quit(bool), @@ -155,8 +190,6 @@ pub enum Action { Undo, Redo, - #[deprecated(note = "Use buffer's undo system instead")] - UndoMultiple(Vec), InsertString(String), FindNext, @@ -400,6 +433,9 @@ pub struct Editor { /// Terminal output handle stdout: std::io::Stdout, + /// Whether render operations should write terminal escape sequences + terminal_output_enabled: bool, + /// Terminal size (width, height) size: (u16, u16), @@ -436,12 +472,6 @@ pub struct Editor { /// Executed actions actions: Vec, - /// Stack of actions that can be undone - undo_actions: Vec, - - /// Actions to be combined into a single undo for insert mode - insert_undo_actions: Vec, - /// Current command line content command: String, @@ -486,6 +516,10 @@ pub struct Editor { /// Plugin overlay manager overlay_manager: plugin::OverlayManager, + + panel_manager: plugin::PanelManager, + + directory_watchers: HashMap, } #[derive(Debug, Clone, PartialEq)] @@ -570,6 +604,15 @@ pub struct Content { text: String, } +impl Content { + pub fn charwise(text: String) -> Self { + Self { + kind: ContentKind::Charwise, + text, + } + } +} + impl Editor { #[allow(unused)] pub fn with_size( @@ -605,6 +648,7 @@ impl Editor { current_buffer_index: 0, window_manager, stdout, + terminal_output_enabled: true, size, vtop: 0, vleft: 0, @@ -617,8 +661,6 @@ impl Editor { waiting_key_action: None, pending_select_action: None, actions: vec![], - undo_actions: vec![], - insert_undo_actions: vec![], command: String::new(), search_term: String::new(), last_error: None, @@ -634,6 +676,8 @@ impl Editor { fwd_history: Vec::new(), render_commands: VecDeque::new(), overlay_manager: plugin::OverlayManager::new(), + panel_manager: plugin::PanelManager::default(), + directory_watchers: HashMap::new(), }) } @@ -688,6 +732,70 @@ impl Editor { } } + fn set_active_window(&mut self, window_id: usize) -> bool { + if window_id == self.window_manager.active_window_id() { + return false; + } + + self.sync_to_window(); + self.window_manager.set_active(window_id); + self.sync_with_window(); + true + } + + fn update_window_layout( + &mut self, + update: impl FnOnce(&mut WindowManager) -> Option<()>, + ) -> bool { + self.sync_to_window(); + if update(&mut self.window_manager).is_some() { + self.sync_with_window(); + true + } else { + false + } + } + + fn resize_window_layout(&mut self, terminal_size: (usize, usize)) { + self.sync_to_window(); + let (reserved_left, reserved_right) = self.reserved_panel_widths(terminal_size.0); + self.window_manager.resize_with_origin( + Point::new(reserved_left, 0), + ( + terminal_size + .0 + .saturating_sub(reserved_left) + .saturating_sub(reserved_right), + terminal_size.1, + ), + ); + self.sync_with_window(); + } + + fn apply_panel_layout(&mut self) { + self.sync_to_window(); + let (reserved_left, reserved_right) = self.reserved_panel_widths(self.size.0 as usize); + self.window_manager.resize_with_origin( + Point::new(reserved_left, 0), + ( + (self.size.0 as usize) + .saturating_sub(reserved_left) + .saturating_sub(reserved_right), + self.size.1 as usize, + ), + ); + } + + fn reserved_panel_widths(&self, terminal_width: usize) -> (usize, usize) { + let max_reserved = terminal_width.saturating_sub(10); + let reserved_left = self.panel_manager.reserved_left_width().min(max_reserved); + let reserved_right = self + .panel_manager + .reserved_right_width() + .min(max_reserved.saturating_sub(reserved_left)); + (reserved_left, reserved_right) + } + fn indentation(&self) -> Indentation { let file_type = self.current_buffer().file_type(); @@ -706,7 +814,7 @@ impl Editor { } pub fn vheight(&self) -> usize { - self.size.1 as usize - 2 + (self.size.1 as usize).saturating_sub(2) } /// Window-aware coordinate transformation methods @@ -761,11 +869,42 @@ impl Editor { fn line_length(&self) -> usize { if let Some(line) = self.viewport_line(self.cy) { let line = line.trim_end_matches('\n'); - return line.chars().count(); + return grapheme_len(line); } 0 } + fn grapheme_to_char_on_line(&self, x: usize, y: usize) -> usize { + self.current_buffer() + .get(y) + .map(|line| grapheme_to_char(line.trim_end_matches('\n'), x)) + .unwrap_or(x) + } + + fn char_to_grapheme_on_line(&self, x: usize, y: usize) -> usize { + self.current_buffer() + .get(y) + .map(|line| char_to_grapheme(line.trim_end_matches('\n'), x)) + .unwrap_or(x) + } + + fn next_word_search_char_on_line(&self, x: usize, y: usize) -> usize { + let Some(line) = self.current_buffer().get(y) else { + return x; + }; + let line = line.trim_end_matches('\n'); + if x > 0 + && line + .graphemes(true) + .nth(x) + .is_some_and(|grapheme| grapheme.chars().all(char::is_whitespace)) + { + grapheme_to_char(line, x - 1) + } else { + grapheme_to_char(line, x) + } + } + /// Returns the display width of the current line in columns #[allow(dead_code)] fn line_display_width(&self) -> usize { @@ -777,69 +916,22 @@ impl Editor { } fn length_for_line(&self, n: usize) -> usize { - if let Some(line) = self.viewport_line(n) { + if let Some(line) = self.current_buffer().get(n) { let line = line.trim_end_matches('\n'); - return line.chars().count(); + return grapheme_len(line); } 0 } + fn last_cell_for_line(&self, n: usize) -> usize { + self.length_for_line(n).saturating_sub(1) + } + /// Returns the current buffer y position fn buffer_line(&self) -> usize { self.vtop + self.cy } - /// Sets the cursor to a specific buffer line, adjusting viewport if necessary - fn set_buffer_line(&mut self, new_line: usize) { - let viewport_height = self.vheight(); - - if new_line < self.vtop { - // Scroll up - self.vtop = new_line; - self.cy = 0; - } else if new_line >= self.vtop + viewport_height { - // Scroll down - self.vtop = new_line.saturating_sub(viewport_height - 1); - self.cy = viewport_height - 1; - } else { - // Just move cursor within viewport - self.cy = new_line - self.vtop; - } - } - - /// Apply an undo change to the current buffer - fn apply_undo_change(&mut self, change: UndoChange) { - match change { - UndoChange::InsertChar { x, y, c } => { - self.current_buffer_mut().insert(x, y, c); - } - UndoChange::DeleteChar { x, y, .. } => { - self.current_buffer_mut().remove(x, y); - } - UndoChange::InsertString { x, y, ref s } => { - self.current_buffer_mut().insert_str(x, y, s); - } - UndoChange::DeleteString { x, y, ref s } => { - // Delete the string by removing character by character - for _ in 0..s.chars().count() { - self.current_buffer_mut().remove(x, y); - } - } - UndoChange::InsertLine { y, ref content } => { - self.current_buffer_mut().insert_line(y, content.clone()); - } - UndoChange::DeleteLine { y, .. } => { - self.current_buffer_mut().remove_line(y); - } - UndoChange::ReplaceLine { y, ref new, .. } => { - self.current_buffer_mut().replace_line(y, new.clone()); - } - UndoChange::DeleteRange { x0, y0, x1, y1, .. } => { - self.current_buffer_mut().remove_range(x0, y0, x1, y1); - } - } - } - /// Returns the buffer URI fn buffer_uri(&self) -> anyhow::Result> { self.current_buffer().uri() @@ -862,7 +954,23 @@ impl Editor { } fn gutter_width(&self) -> usize { - self.current_buffer().len().to_string().len() + 1 + self.current_buffer() + .len() + .saturating_add(1) + .to_string() + .len() + + 1 + } + + fn gutter_width_for_buffer_index(&self, buffer_index: usize) -> usize { + self.buffers + .get(buffer_index) + .map(|buffer| buffer.len().saturating_add(1).to_string().len() + 1) + .unwrap_or_else(|| self.gutter_width()) + } + + fn gutter_width_for_window(&self, window: &crate::window::Window) -> usize { + self.gutter_width_for_buffer_index(window.buffer_index) } pub fn highlight(&mut self, code: &str) -> anyhow::Result> { @@ -931,7 +1039,7 @@ impl Editor { return; }; - let x = self.gutter_width() + line.len() + 5; + let x = self.gutter_width() + display_width(line.trim_end_matches('\n')) + 5; // otherwise, clear the line let text = " ".repeat(self.vwidth().saturating_sub(x)); @@ -1061,26 +1169,42 @@ impl Editor { // TODO: in neovim, when you are at an x position and you move to a shorter line, the cursor // goes back to the max x but returns to the previous x position if the line is longer - fn check_bounds(&mut self) { + fn last_navigable_line(&self) -> usize { + self.current_buffer().last_navigable_line() + } + + fn check_bounds(&mut self) -> bool { + let old_position = (self.cx, self.cy, self.vtop); + let last_line = if self.is_insert() { + self.current_buffer().len() + } else { + self.last_navigable_line() + }; + let viewport_height = self.vheight().max(1); + let max_vtop = last_line.saturating_sub(viewport_height.saturating_sub(1)); + + self.vtop = self.vtop.min(max_vtop); + + let buffer_line = (self.vtop + self.cy).min(last_line); + self.cy = buffer_line + .saturating_sub(self.vtop) + .min(viewport_height.saturating_sub(1)); + let line_length = self.line_length(); - if self.cx >= line_length && self.is_normal() { + if self.cx > line_length && self.is_normal() { if line_length > 0 { - self.cx = self.line_length() - 1; + self.cx = self.line_length(); } else if self.is_normal() { self.cx = 0; } } - if self.cx >= self.vwidth() { - self.cx = self.vwidth() - 1; + let viewport_width = self.vwidth(); + if viewport_width > 0 && self.cx >= viewport_width { + self.cx = viewport_width - 1; } - // check if cy is after the end of the buffer - // the end of the buffer is less than vtop + cy - let line_on_buffer = self.cy + self.vtop; - if line_on_buffer > self.current_buffer().len().saturating_sub(1) { - self.cy = self.current_buffer().len() - self.vtop - 1; - } + old_position != (self.cx, self.cy, self.vtop) } /// Starts the main editor loop @@ -1134,6 +1258,16 @@ impl Editor { } } + for (watch_id, payload) in self.poll_directory_watchers() { + self.plugin_registry + .notify( + &mut runtime, + &format!("filesystem:changed:{watch_id}"), + payload, + ) + .await?; + } + // if self.sync_state.should_notify() { // for file in self.sync_state.get_changes().unwrap_or_default() { // // FIXME: not current buffer! @@ -1195,69 +1329,38 @@ impl Editor { // self.render(buffer)?; } PluginRequest::BufferInsert { x, y, text } => { - // Track undo action - self.undo_actions.push(Action::DeleteRange(x, y, x + text.len(), y)); - - self.current_buffer_mut().insert_str(x, y, &text); + self.begin_transaction("plugin insert"); + self.replace_range( + TextRange::insertion(TextPosition::new(y, x)), + &text, + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(&mut runtime).await?; self.render(&mut buffer)?; } PluginRequest::BufferDelete { x, y, length } => { - // Save deleted text for undo - let current_buf = self.current_buffer(); - let mut deleted_text = String::new(); - for i in 0..length { - if let Some(line) = current_buf.get(y) { - if x + i < line.len() { - deleted_text.push(line.chars().nth(x + i).unwrap_or(' ')); - } - } - } - self.undo_actions.push(Action::InsertText { - x, - y, - content: Content { - kind: ContentKind::Charwise, - text: deleted_text - } - }); - - for _ in 0..length { - self.current_buffer_mut().remove(x, y); - } + self.begin_transaction("plugin delete"); + self.replace_range( + TextRange::new( + TextPosition::new(y, x), + TextPosition::new(y, x + length), + ), + "", + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(&mut runtime).await?; self.render(&mut buffer)?; } PluginRequest::BufferReplace { x, y, length, text } => { - // Save replaced text for undo - let current_buf = self.current_buffer(); - let mut replaced_text = String::new(); - for i in 0..length { - if let Some(line) = current_buf.get(y) { - if x + i < line.len() { - replaced_text.push(line.chars().nth(x + i).unwrap_or(' ')); - } - } - } - // For undo, we need to delete the new text and insert the old - self.undo_actions.push(Action::UndoMultiple(vec![ - Action::DeleteRange(x, y, x + text.len(), y), - Action::InsertText { - x, - y, - content: Content { - kind: ContentKind::Charwise, - text: replaced_text - } - } - ])); - - // Delete old text - for _ in 0..length { - self.current_buffer_mut().remove(x, y); - } - // Insert new text - self.current_buffer_mut().insert_str(x, y, &text); + self.begin_transaction("plugin replace"); + self.replace_range( + TextRange::new( + TextPosition::new(y, x), + TextPosition::new(y, x + length), + ), + &text, + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(&mut runtime).await?; self.render(&mut buffer)?; } @@ -1273,7 +1376,7 @@ impl Editor { PluginRequest::GetCursorDisplayColumn => { let display_col = if let Some(line) = self.current_line_contents() { let line = line.trim_end_matches('\n'); - crate::unicode_utils::char_to_column(line, self.cx) + crate::unicode_utils::grapheme_to_column(line, self.cx) } else { self.cx }; @@ -1303,7 +1406,7 @@ impl Editor { // Convert display column to character index if let Some(line) = self.viewport_line(y - self.vtop) { let line = line.trim_end_matches('\n'); - self.cx = crate::unicode_utils::column_to_char(line, column); + self.cx = crate::unicode_utils::column_to_grapheme(line, column); } // Adjust viewport if needed if y < self.vtop { @@ -1412,6 +1515,48 @@ impl Editor { log!("Removing overlay: {}", id); self.overlay_manager.remove_overlay(&id); } + PluginRequest::CreatePanel { id, config } => { + self.panel_manager.create_panel(id, config); + self.apply_panel_layout(); + self.render(&mut buffer)?; + } + PluginRequest::UpdatePanel { id, rows } => { + self.panel_manager.update_panel(&id, rows); + self.render(&mut buffer)?; + } + PluginRequest::FocusPanel { id } => { + self.panel_manager.focus_panel(&id); + self.render(&mut buffer)?; + } + PluginRequest::FocusEditor => { + self.panel_manager.focus_editor(); + self.render(&mut buffer)?; + } + PluginRequest::ClosePanel { id } => { + self.panel_manager.close_panel(&id); + self.apply_panel_layout(); + self.render(&mut buffer)?; + } + PluginRequest::ListDirectory { path, request_id } => { + let payload = directory_listing(&path); + self.plugin_registry + .notify( + &mut runtime, + &format!("filesystem:directory:{request_id}"), + payload, + ) + .await?; + } + PluginRequest::WatchDirectory { path, watch_id } => { + self.directory_watchers.insert(watch_id, DirectoryWatcher { + snapshot: directory_listing(&path), + path, + last_checked: Instant::now(), + }); + } + PluginRequest::UnwatchDirectory { watch_id } => { + self.directory_watchers.remove(&watch_id); + } } } } @@ -1427,9 +1572,7 @@ impl Editor { if self.cy > max_y - 1 { self.cy = max_y - 1; } - // Resize window manager - self.window_manager.resize((width as usize, height as usize)); - self.sync_to_window(); + self.resize_window_layout((width as usize, height as usize)); buffer = RenderBuffer::new( self.size.0 as usize, self.size.1 as usize, @@ -1567,18 +1710,6 @@ impl Editor { // log!("server capabilities: {:#?}", self.server_capabilities); } - if method == "rust-analyzer/analyzerStatus" { - let r = msg.result.as_str().unwrap(); - log!("analyzer status: {r}"); - } - - if method == "rust-analyzer/viewFileText" { - let r = msg.result.as_str().unwrap(); - log!("----"); - log!("{r}"); - log!("----"); - } - if method == "textDocument/diagnostic" { if let Some((uri, diagnostics)) = parse_diagnostics(msg) { return self.add_diagnostics(Some(&uri), &diagnostics); @@ -1593,10 +1724,14 @@ impl Editor { match serde_json::from_value::(msg.result.clone()) { Ok(completion_response) => { - self.completion_ui.show( + let (completion_x, completion_y) = + self.render_cursor_position().unwrap_or((self.cx, self.cy)); + self.completion_ui.show_with_bounds( completion_response.items, - self.cx, - self.cy, + completion_x, + completion_y, + self.size.0 as usize, + self.size.1 as usize, ); self.current_dialog = Some(Box::new(self.completion_ui.clone())); return Some(Action::ShowDialog); @@ -1696,6 +1831,15 @@ impl Editor { return Ok(current_dialog.handle_event(ev)); } + if self.panel_manager.focused_panel_id().is_some() { + if let Some(action) = self.handle_panel_event(ev) { + return Ok(Some(action)); + } + + let normal = self.config.keys.normal.clone(); + return Ok(self.event_to_key_action(&normal, ev)); + } + Ok(match self.mode { Mode::Normal => self.handle_normal_event(ev), Mode::Insert => self.handle_insert_event(ev)?, @@ -1705,6 +1849,57 @@ impl Editor { }) } + fn handle_panel_event(&mut self, ev: &event::Event) -> Option { + let Event::Key(ref event) = ev else { + return None; + }; + + let action = match event.code { + KeyCode::Esc => { + self.panel_manager.focus_editor(); + return Some(KeyAction::Single(Action::Refresh)); + } + KeyCode::Up | KeyCode::Char('k') => "up", + KeyCode::Down | KeyCode::Char('j') => "down", + KeyCode::Left | KeyCode::Char('h') => "collapse", + KeyCode::Right | KeyCode::Char('l') => "expand", + KeyCode::Enter => "activate", + _ => return None, + }; + + let height = self.size.1 as usize; + self.panel_manager + .handle_focused_key(action, height) + .and_then(|event| { + serde_json::to_value(&event).ok().map(|payload| { + KeyAction::Multiple(vec![ + Action::NotifyPlugins(format!("panel:event:{}", event.panel_id), payload), + Action::Refresh, + ]) + }) + }) + } + + fn poll_directory_watchers(&mut self) -> Vec<(i32, Value)> { + let now = Instant::now(); + let mut changes = Vec::new(); + + for (watch_id, watcher) in self.directory_watchers.iter_mut() { + if now.duration_since(watcher.last_checked) < Duration::from_millis(500) { + continue; + } + watcher.last_checked = now; + + let next_snapshot = directory_listing(&watcher.path); + if next_snapshot != watcher.snapshot { + watcher.snapshot = next_snapshot.clone(); + changes.push((*watch_id, next_snapshot)); + } + } + + changes + } + fn handle_repeater(&mut self, ev: &event::Event) -> bool { if let Event::Key(KeyEvent { code: KeyCode::Char(c), @@ -1844,6 +2039,10 @@ impl Editor { actions } + fn delete_last_char(text: &mut String) { + text.pop(); + } + fn handle_command_event(&mut self, ev: &event::Event) -> Option { if let Event::Key(ref event) = ev { let code = event.code; @@ -1855,11 +2054,7 @@ impl Editor { return Some(KeyAction::Single(Action::EnterMode(Mode::Normal))); } KeyCode::Backspace => { - if self.command.len() < 2 { - self.command = String::new(); - } else { - self.command = self.command[..self.command.len() - 1].to_string(); - } + Self::delete_last_char(&mut self.command); } KeyCode::Enter => { if self.command.trim().is_empty() { @@ -1893,12 +2088,7 @@ impl Editor { return Some(KeyAction::Single(Action::EnterMode(Mode::Normal))); } KeyCode::Backspace => { - if self.search_term.len() < 2 { - self.search_term = String::new(); - } else { - self.search_term = - self.search_term[..self.search_term.len() - 1].to_string(); - } + Self::delete_last_char(&mut self.search_term); } KeyCode::Enter => { return Some(KeyAction::Multiple(vec![ @@ -2064,7 +2254,7 @@ impl Editor { } } Action::MoveDown => { - if self.vtop + self.cy < self.current_buffer().len() - 1 { + if self.vtop + self.cy < self.last_navigable_line() { self.cy += 1; if self.cy >= self.vheight() { // scroll if possible @@ -2082,17 +2272,11 @@ impl Editor { if let Some(line) = self.current_line_contents() { let line = line.trim_end_matches('\n'); - // Convert current position to byte offset - let current_byte = self - .current_buffer() - .column_to_char_index(self.cx, self.buffer_line()); - let byte_offset = crate::unicode_utils::char_to_byte(line, current_byte); + let byte_offset = grapheme_to_byte(line, self.cx); // Find previous grapheme boundary if let Some(prev_byte) = prev_grapheme_boundary(line, byte_offset) { - // Convert back to character index - let char_idx = crate::unicode_utils::byte_to_char(line, prev_byte); - self.cx = char_idx; + self.cx = crate::unicode_utils::byte_to_grapheme(line, prev_byte); } else if self.cx > 0 { self.cx = 0; } @@ -2107,19 +2291,17 @@ impl Editor { // Move by grapheme clusters if let Some(line) = self.current_line_contents() { let line = line.trim_end_matches('\n'); - let max_chars = line.chars().count(); + let max_graphemes = grapheme_len(line); - if self.cx < max_chars { - // Convert current position to byte offset - let current_byte = crate::unicode_utils::char_to_byte(line, self.cx); + if self.cx < max_graphemes { + let current_byte = grapheme_to_byte(line, self.cx); // Find next grapheme boundary if let Some(next_byte) = next_grapheme_boundary(line, current_byte) { - // Convert back to character index - let char_idx = crate::unicode_utils::byte_to_char(line, next_byte); - self.cx = char_idx.min(max_chars); + self.cx = crate::unicode_utils::byte_to_grapheme(line, next_byte) + .min(max_graphemes); } else { - self.cx = max_chars; + self.cx = max_graphemes; } } } @@ -2133,30 +2315,39 @@ impl Editor { } Action::MoveToFirstLineChar => { if let Some(line) = self.current_line_contents() { - self.cx = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0); + self.cx = line + .trim_end_matches('\n') + .graphemes(true) + .position(|g| !g.chars().all(char::is_whitespace)) + .unwrap_or(0); } } Action::MoveToLastLineChar => { if let Some(line) = self.current_line_contents() { - self.cx = line.len().saturating_sub( - line.chars() - .rev() - .position(|c| !c.is_whitespace()) - .unwrap_or(0), - ); + let line = line.trim_end_matches('\n'); + let trailing = line + .graphemes(true) + .rev() + .position(|g| !g.chars().all(char::is_whitespace)) + .unwrap_or(0); + self.cx = grapheme_len(line).saturating_sub(trailing + 1); } } Action::PageUp => { - if self.vtop > 0 { - self.vtop = self.vtop.saturating_sub(self.vheight()); - self.render(buffer)?; - } + let target_line = self + .buffer_line() + .saturating_sub(self.vheight()) + .min(self.last_navigable_line()); + self.vtop = target_line.saturating_sub(self.vheight().saturating_sub(1)); + self.cy = target_line.saturating_sub(self.vtop); + self.render(buffer)?; } Action::PageDown => { - if self.current_buffer().len() > self.vtop + self.vheight() { - self.vtop += self.vheight(); - self.render(buffer)?; - } + let target_line = + (self.buffer_line() + self.vheight()).min(self.last_navigable_line()); + self.vtop = target_line.saturating_sub(self.vheight().saturating_sub(1)); + self.cy = target_line.saturating_sub(self.vtop); + self.render(buffer)?; } Action::EnterMode(new_mode) => { add_to_history = false; @@ -2168,25 +2359,15 @@ impl Editor { self.execute(&select_action.action, buffer, runtime).await?; } - // TODO: with the introduction of new modes, maybe this transtion - // needs to be widened to anything -> insert and anything -> normal if self.is_normal() && matches!(new_mode, Mode::Insert) { - // Start a new undo group for insert mode - all changes will be grouped together - let cursor_pos = (self.cx, self.buffer_line()); - self.current_buffer_mut().start_undo_group(cursor_pos); - // Keep old system for backwards compatibility during transition - self.insert_undo_actions = Vec::new(); + self.begin_transaction("insert"); } if self.is_insert() && matches!(new_mode, Mode::Normal) { - // End the undo group when leaving insert mode - self.current_buffer_mut().end_undo_group(); - // Keep old system for backwards compatibility during transition - if !self.insert_undo_actions.is_empty() { - let actions = mem::take(&mut self.insert_undo_actions); - #[allow(deprecated)] - self.undo_actions.push(Action::UndoMultiple(actions)); - } + self.cx = self.cx.min(self.line_length().saturating_sub(1)); + let after_cursor = self.cursor_snapshot(); + self.commit_transaction(after_cursor); + self.cancel_transaction_if_empty(); } if matches!(new_mode, Mode::Search) { @@ -2227,9 +2408,13 @@ impl Editor { Action::InsertCharAtCursorPos(c) => { use crate::log; + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert char"); + } let line = self.buffer_line(); let cx = self.cx; - let cursor_pos = (cx, line); + let char_cx = self.grapheme_to_char_on_line(cx, line); log!( "InsertCharAtCursorPos - char: '{}' (U+{:04X}), cx: {}, line: {}", @@ -2245,68 +2430,112 @@ impl Editor { log!("Line char count: {}", line_content.chars().count()); } - // Use the new undo-tracking insert method - self.current_buffer_mut().insert_with_undo(cx, line, *c, cursor_pos); + self.replace_range( + TextRange::insertion(TextPosition::new(line, char_cx)), + &c.to_string(), + ); self.notify_change(runtime).await?; // Move cursor by one character position (not display width) - self.cx += 1; + self.cx += grapheme_len(&c.to_string()); + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } log!("Cursor after insert: cx = {}", self.cx); - // Keep old system for backwards compatibility during transition - self.insert_undo_actions - .push(Action::DeleteCharAt(cx, line)); - self.draw_line(buffer); } Action::DeleteCharAt(x, y) => { - self.current_buffer_mut().remove(*x, *y); + self.begin_transaction("delete char"); + self.replace_range( + TextRange::new(TextPosition::new(*y, *x), TextPosition::new(*y, *x + 1)), + "", + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(runtime).await?; self.draw_line(buffer); } Action::DeleteRange(x0, y0, x1, y1) => { - self.current_buffer_mut().remove_range(*x0, *y0, *x1, *y1); + self.begin_transaction("delete range"); + self.replace_range( + TextRange::new(TextPosition::new(*y0, *x0), TextPosition::new(*y1, *x1)), + "", + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(runtime).await?; self.render(buffer)?; } Action::DeleteCharAtCursorPos => { let cx = self.cx; let line = self.buffer_line(); - let cursor_pos = (cx, line); - // Use the new undo-tracking remove method - self.current_buffer_mut().remove_with_undo(cx, line, cursor_pos); - self.notify_change(runtime).await?; - self.draw_line(buffer); + let deleted = self.current_buffer().get(line).and_then(|line_content| { + let line_content = line_content.trim_end_matches('\n'); + line_content + .graphemes(true) + .nth(cx) + .map(|grapheme| grapheme.to_string()) + }); + + if let Some(deleted) = deleted { + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("delete char"); + } + let start = self.grapheme_to_char_on_line(cx, line); + let end = self.grapheme_to_char_on_line(cx + 1, line); + self.replace_range( + TextRange::new( + TextPosition::new(line, start), + TextPosition::new(line, end), + ), + "", + ); + self.notify_change(runtime).await?; + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } + let _ = deleted; + self.draw_line(buffer); + } } Action::ReplaceLineAt(y, contents) => { - self.current_buffer_mut() - .replace_line(*y, contents.to_string()); + let line_len = self.length_for_line(*y); + self.begin_transaction("replace line"); + self.replace_range( + TextRange::new(TextPosition::new(*y, 0), TextPosition::new(*y, line_len)), + contents, + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(runtime).await?; self.draw_line(buffer); } Action::InsertNewLine => { - self.insert_undo_actions.extend(vec![ - Action::MoveTo(self.cx, self.buffer_line() + 1), - Action::DeleteLineAt(self.buffer_line() + 1), - Action::ReplaceLineAt( - self.buffer_line(), - self.current_line_contents().unwrap_or_default(), - ), - ]); + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert newline"); + } let spaces = self.current_line_indentation(); let current_line = self.current_line_contents().unwrap_or_default(); let current_line = current_line.trim_end(); - if self.cx > current_line.len() { - self.cx = current_line.len(); + let current_line_len = grapheme_len(current_line); + if self.cx > current_line_len { + self.cx = current_line_len; } - let before_cursor = current_line[..self.cx].to_string(); - let after_cursor = current_line[self.cx..].to_string(); + let cursor_char = grapheme_to_char(current_line, self.cx); + let before_cursor = char_prefix(current_line, cursor_char).to_string(); + let after_cursor = char_suffix(current_line, cursor_char).to_string(); let line = self.buffer_line(); - self.current_buffer_mut().replace_line(line, before_cursor); + self.replace_range( + TextRange::new( + TextPosition::new(line, 0), + TextPosition::new(line, current_line.chars().count()), + ), + &format!("{}\n{}{}", before_cursor, " ".repeat(spaces), after_cursor), + ); self.notify_change(runtime).await?; self.cx = spaces; @@ -2317,10 +2546,9 @@ impl Editor { self.cy -= 1; } - let new_line = format!("{}{}", " ".repeat(spaces), &after_cursor); - let line = self.buffer_line(); - - self.current_buffer_mut().insert_line(line, new_line); + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } self.render(buffer)?; } Action::SetWaitingKey(key_action) => { @@ -2328,58 +2556,35 @@ impl Editor { } Action::DeleteCurrentLine => { let line = self.buffer_line(); - let contents = self.current_line_contents(); - - self.current_buffer_mut().remove_line(line); + let end = if line < self.current_buffer().len() { + TextPosition::new(line + 1, 0) + } else { + TextPosition::new(line, self.length_for_line(line)) + }; + self.begin_transaction("delete line"); + self.replace_range(TextRange::new(TextPosition::new(line, 0), end), ""); self.notify_change(runtime).await?; - self.undo_actions.push(Action::InsertLineAt(line, contents)); + let target_line = line.min(self.current_buffer().len()); + self.vtop = self.vtop.min(target_line); + self.cy = target_line.saturating_sub(self.vtop); + self.cx = 0; + self.commit_transaction(self.cursor_snapshot()); self.render(buffer)?; } Action::Undo => { - // Use the new buffer-based undo system - if let Some(undo_group) = self.current_buffer_mut().undo() { - // Apply the inverse of each change to undo - for change in undo_group.changes.iter().rev() { - self.apply_undo_change(change.inverse()); - } - // Restore cursor position - let (x, y) = undo_group.cursor_before; - self.cx = x; - self.set_buffer_line(y); - self.notify_change(runtime).await?; - self.render(buffer)?; - } else { - // Fallback to old system for backwards compatibility - if let Some(undo_action) = self.undo_actions.pop() { - #[allow(deprecated)] - self.execute(&undo_action, buffer, runtime).await?; - } - } + self.undo_transaction(buffer, runtime).await?; } Action::Redo => { - if let Some(redo_group) = self.current_buffer_mut().redo() { - // Apply each change to redo - for change in &redo_group.changes { - self.apply_undo_change(change.inverse()); - } - // Restore cursor position - let (x, y) = redo_group.cursor_before; - self.cx = x; - self.set_buffer_line(y); - self.notify_change(runtime).await?; - self.render(buffer)?; - } - } - #[allow(deprecated)] - Action::UndoMultiple(actions) => { - for action in actions.iter().rev() { - self.execute(action, buffer, runtime).await?; - } + self.redo_transaction(buffer, runtime).await?; } Action::InsertLineAt(y, contents) => { if let Some(contents) = contents { - self.current_buffer_mut() - .insert_line(*y, contents.to_string()); + self.begin_transaction("insert line"); + self.replace_range( + TextRange::insertion(TextPosition::new(*y, 0)), + &format!("{}\n", contents), + ); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(runtime).await?; self.render(buffer)?; } @@ -2416,9 +2621,6 @@ impl Editor { Action::InsertLineBelowCursor => { use crate::log; - self.undo_actions - .push(Action::DeleteLineAt(self.buffer_line() + 1)); - let leading_spaces = self.current_line_indentation(); let line = self.buffer_line(); @@ -2436,11 +2638,18 @@ impl Editor { log!("Line char count: {}", line_content.chars().count()); } - self.current_buffer_mut() - .insert_line(line + 1, " ".repeat(leading_spaces)); + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert line below"); + } + self.replace_range( + TextRange::insertion(TextPosition::new(line + 1, 0)), + &format!("{}\n", " ".repeat(leading_spaces)), + ); self.notify_change(runtime).await?; self.cy += 1; self.cx = leading_spaces; + self.mode = Mode::Insert; if self.cy >= self.vheight() { self.vtop += 1; @@ -2450,9 +2659,6 @@ impl Editor { self.render(buffer)?; } Action::InsertLineAtCursor => { - self.undo_actions - .push(Action::DeleteLineAt(self.buffer_line())); - // if the current line is empty, let's use the indentation from the line above let leading_spaces = if let Some(line) = self.current_line_contents() { if line.is_empty() { @@ -2465,10 +2671,17 @@ impl Editor { }; let line = self.buffer_line(); - self.current_buffer_mut() - .insert_line(line, " ".repeat(leading_spaces)); + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert line above"); + } + self.replace_range( + TextRange::insertion(TextPosition::new(line, 0)), + &format!("{}\n", " ".repeat(leading_spaces)), + ); self.notify_change(runtime).await?; self.cx = leading_spaces; + self.mode = Mode::Insert; self.render(buffer)?; } Action::MoveToTop => { @@ -2477,65 +2690,92 @@ impl Editor { self.render(buffer)?; } Action::MoveToBottom => { - if self.current_buffer().len() > self.vheight() { + let last_line = self.last_navigable_line(); + let line_count = last_line + 1; + if line_count > self.vheight() { self.cy = self.vheight() - 1; - self.vtop = self.current_buffer().len() - self.vheight(); + self.vtop = line_count - self.vheight(); self.render(buffer)?; } else { - self.cy = self.current_buffer().len() - 1; + self.cy = last_line; } } Action::DeleteLineAt(y) => { - self.current_buffer_mut().remove_line(*y); + let end = if *y < self.current_buffer().len() { + TextPosition::new(*y + 1, 0) + } else { + TextPosition::new(*y, self.length_for_line(*y)) + }; + self.begin_transaction("delete line"); + self.replace_range(TextRange::new(TextPosition::new(*y, 0), end), ""); + self.commit_transaction(self.cursor_snapshot()); self.notify_change(runtime).await?; self.render(buffer)?; } Action::DeletePreviousChar => { + if self.cx == 0 && self.buffer_line() == 0 { + return Ok(false); + } + + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("delete previous char"); + } + if self.cx > 0 { // Get the current line to find the previous grapheme boundary if let Some(line) = self.current_line_contents() { let line = line.trim_end_matches('\n'); - let current_byte = crate::unicode_utils::char_to_byte(line, self.cx); + let current_byte = grapheme_to_byte(line, self.cx); if let Some(prev_byte) = crate::unicode_utils::prev_grapheme_boundary(line, current_byte) { - let prev_char_idx = crate::unicode_utils::byte_to_char(line, prev_byte); - - // Calculate how many characters to remove - let chars_to_remove = self.cx - prev_char_idx; - - // Capture the grapheme being deleted for undo - let deleted_chars: String = line.chars() - .skip(prev_char_idx) - .take(chars_to_remove) - .collect(); - - // Move cursor to the previous grapheme boundary - self.cx = prev_char_idx; - - // Capture values before mutable borrow + let prev_grapheme_idx = + crate::unicode_utils::byte_to_grapheme(line, prev_byte); + let start_char = crate::unicode_utils::byte_to_char(line, prev_byte); + let end_char = crate::unicode_utils::byte_to_char(line, current_byte); let line_num = self.buffer_line(); - let cx = self.cx; - let cursor_pos = (cx, line_num); - let undo_change = UndoChange::DeleteString { - x: cx, - y: line_num, - s: deleted_chars, - }; - - // Record undo before making changes - self.current_buffer_mut().record_undo(undo_change, cursor_pos); - - // Remove all characters in the grapheme cluster - for _ in 0..chars_to_remove { - self.current_buffer_mut().remove(cx, line_num); - } + self.replace_range( + TextRange::new( + TextPosition::new(line_num, start_char), + TextPosition::new(line_num, end_char), + ), + "", + ); + self.cx = prev_grapheme_idx; self.notify_change(runtime).await?; self.draw_line(buffer); } } + } else if self.buffer_line() > 0 { + let line_num = self.buffer_line(); + let previous_line = self.current_buffer().get(line_num - 1).unwrap_or_default(); + let previous_line = previous_line.trim_end_matches('\n'); + let previous_char_len = previous_line.chars().count(); + let previous_grapheme_len = grapheme_len(previous_line); + + self.replace_range( + TextRange::new( + TextPosition::new(line_num - 1, previous_char_len), + TextPosition::new(line_num, 0), + ), + "", + ); + self.cx = previous_grapheme_len; + if self.cy > 0 { + self.cy -= 1; + } else { + self.vtop = self.vtop.saturating_sub(1); + } + + self.notify_change(runtime).await?; + self.render(buffer)?; + } + + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); } } Action::DumpHistory => { @@ -2585,26 +2825,7 @@ impl Editor { } Action::DoPing => { add_to_history = false; - // self.lsp - // .send_request( - // "rust-analyzer/analyzerStatus", - // json!({ - // "textDocument": { - // "uri": self.current_buffer().uri().unwrap_or_default() - // } - // }), - // true, - // ) - // .await?; - self.lsp - .send_request( - "rust-analyzer/viewFileText", - json!({ - "uri": self.current_buffer().uri().unwrap_or_default() - }), - true, - ) - .await?; + self.lsp.workspace_symbol("").await?; } Action::ViewLogs => { add_to_history = false; @@ -2761,7 +2982,9 @@ impl Editor { } Action::SetCursor(x, y) => { self.cx = *x; - self.cy = *y; + let target_y = (*y).min(self.last_navigable_line()); + self.vtop = target_y.saturating_sub(self.vheight().saturating_sub(1)); + self.cy = target_y.saturating_sub(self.vtop); } Action::ScrollUp => { let scroll_lines = self.config.mouse_scroll_lines.unwrap_or(3); @@ -2785,33 +3008,41 @@ impl Editor { } } Action::MoveToNextWord => { - let next_word = self - .current_buffer() - .find_next_word((self.cx, self.buffer_line())); + let line = self.buffer_line(); + let char_cx = self.next_word_search_char_on_line(self.cx, line); + let next_word = self.current_buffer().find_next_word((char_cx, line)); if let Some((x, y)) = next_word { - self.cx = x; - self.go_to_line(y + 1, buffer, runtime, GoToLinePosition::Top) - .await?; + self.cx = self.char_to_grapheme_on_line(x, y); + if self.is_within_viewport(y) { + self.cy = y - self.vtop; + } else { + self.go_to_line(y + 1, buffer, runtime, GoToLinePosition::Top) + .await?; + } self.draw_cursor()?; } } Action::MoveToPreviousWord => { - let previous_word = self - .current_buffer() - .find_prev_word((self.cx, self.buffer_line())); + let line = self.buffer_line(); + let char_cx = self.grapheme_to_char_on_line(self.cx, line); + let previous_word = self.current_buffer().find_prev_word((char_cx, line)); if let Some((x, y)) = previous_word { - self.cx = x; - self.go_to_line(y + 1, buffer, runtime, GoToLinePosition::Top) - .await?; + self.cx = self.char_to_grapheme_on_line(x, y); + if self.is_within_viewport(y) { + self.cy = y - self.vtop; + } else { + self.go_to_line(y + 1, buffer, runtime, GoToLinePosition::Top) + .await?; + } self.draw_cursor()?; } } Action::MoveLineToViewportBottom => { let line = self.buffer_line(); if line > self.vtop + self.vheight() { - self.vtop = line - self.vheight(); + self.vtop = line.saturating_sub(self.vheight().saturating_sub(1)); self.cy = self.vheight() - 1; self.render(buffer)?; } @@ -2821,38 +3052,54 @@ impl Editor { let tabsize = 4; let cx = self.cx; let line = self.buffer_line(); - let cursor_pos = (cx, line); - let spaces = " ".repeat(tabsize); - - // Use the new undo-tracking insert method - self.current_buffer_mut() - .insert_str_with_undo(cx, line, &spaces, cursor_pos); + let char_cx = self.grapheme_to_char_on_line(cx, line); + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert tab"); + } + self.replace_range( + TextRange::insertion(TextPosition::new(line, char_cx)), + &" ".repeat(tabsize), + ); self.notify_change(runtime).await?; self.cx += tabsize; + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } self.draw_line(buffer); } - Action::Save => match self.current_buffer_mut().save() { - Ok(msg) => { - // TODO: use last_message instead of last_error - self.last_error = Some(msg); + Action::Save => { + let resume_insert_transaction = self.commit_active_transaction_before_save(); + let save_result = self.current_buffer_mut().save(); + self.resume_insert_transaction_after_save(resume_insert_transaction); - // Notify plugins about file save - if let Some(file) = &self.current_buffer().file { - let save_info = serde_json::json!({ - "file": file, - "buffer_index": self.current_buffer_index - }); - self.plugin_registry - .notify(runtime, "file:saved", save_info) - .await?; + match save_result { + Ok(msg) => { + // TODO: use last_message instead of last_error + self.last_error = Some(msg); + + // Notify plugins about file save + if let Some(file) = &self.current_buffer().file { + let save_info = serde_json::json!({ + "file": file, + "buffer_index": self.current_buffer_index + }); + self.plugin_registry + .notify(runtime, "file:saved", save_info) + .await?; + } + } + Err(e) => { + self.last_error = Some(e.to_string()); } } - Err(e) => { - self.last_error = Some(e.to_string()); - } - }, + } Action::SaveAs(new_file_name) => { - match self.current_buffer_mut().save_as(new_file_name) { + let resume_insert_transaction = self.commit_active_transaction_before_save(); + let save_result = self.current_buffer_mut().save_as(new_file_name); + self.resume_insert_transaction_after_save(resume_insert_transaction); + + match save_result { Ok(msg) => { // TODO: use last_message instead of last_error self.last_error = Some(msg); @@ -2894,18 +3141,19 @@ impl Editor { Action::DeleteWord => { let cx = self.cx; let line = self.buffer_line(); + let char_cx = self.grapheme_to_char_on_line(cx, line); - if let Some(text) = self.current_buffer_mut().delete_word((cx, line)) { - let content = Content { - kind: ContentKind::Charwise, - text, - }; - - self.undo_actions.push(Action::InsertText { - x: cx, - y: line, - content, - }); + if let Some((end_x, end_y)) = self.current_buffer().find_next_word((char_cx, line)) + { + self.begin_transaction("delete word"); + self.replace_range( + TextRange::new( + TextPosition::new(line, char_cx), + TextPosition::new(end_y, end_x), + ), + "", + ); + self.commit_transaction(self.cursor_snapshot()); } self.notify_change(runtime).await?; @@ -3028,10 +3276,12 @@ impl Editor { } Action::Delete => { if self.selection.is_some() { + self.begin_transaction("delete selection"); if let Some((x0, y0)) = self.delete_selection() { self.cx = x0; self.cy = y0 - self.vtop; } + self.commit_transaction(self.cursor_snapshot()); self.selection = None; self.notify_change(runtime).await?; self.render(buffer)?; @@ -3044,7 +3294,7 @@ impl Editor { } } Action::InsertText { x, y, content } => { - self.insert_content(*x, *y, content, true); + self.insert_content_as_transaction(*x, *y, content); self.notify_change(runtime).await?; self.render(buffer)?; } @@ -3070,9 +3320,17 @@ impl Editor { Action::InsertString(text) => { let line = self.buffer_line(); let cx = self.cx; - self.current_buffer_mut().insert_str(cx, line, text); + let char_cx = self.grapheme_to_char_on_line(cx, line); + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert string"); + } + self.replace_range(TextRange::insertion(TextPosition::new(line, char_cx)), text); self.notify_change(runtime).await?; - self.cx += text.len(); + self.cx += grapheme_len(text); + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } self.draw_line(buffer); } Action::RequestCompletion => { @@ -3094,28 +3352,31 @@ impl Editor { let indent = self.indentation(); let line = self.buffer_line(); - self.undo_actions - .push(Action::DeleteRange(0, line, indent.shift_width, line)); - - self.current_buffer_mut() - .insert_str(0, line, &" ".repeat(indent.shift_width)); + self.begin_transaction("indent line"); + self.replace_range( + TextRange::insertion(TextPosition::new(line, 0)), + &" ".repeat(indent.shift_width), + ); + self.commit_transaction(self.cursor_snapshot()); + self.notify_change(runtime).await?; + self.render(buffer)?; } Action::UnindentLine => { let spaces = self.current_line_indentation(); let chars_to_remove = std::cmp::min(spaces, self.indentation().shift_width); let line = self.buffer_line(); - self.undo_actions.push(Action::InsertText { - x: 0, - y: line, - content: Content { - kind: ContentKind::Charwise, - text: " ".repeat(chars_to_remove), - }, - }); - - self.current_buffer_mut() - .remove_range(0, line, chars_to_remove, line); + self.begin_transaction("unindent line"); + self.replace_range( + TextRange::new( + TextPosition::new(line, 0), + TextPosition::new(line, chars_to_remove), + ), + "", + ); + self.commit_transaction(self.cursor_snapshot()); + self.notify_change(runtime).await?; + self.render(buffer)?; } Action::JumpBack => { add_to_history = false; @@ -3166,13 +3427,8 @@ impl Editor { Action::SplitHorizontal => { log!("SplitHorizontal action triggered"); let current_buffer = self.current_buffer_index; - if self - .window_manager - .split_horizontal(current_buffer) - .is_some() - { + if self.update_window_layout(|windows| windows.split_horizontal(current_buffer)) { log!("Window split successful"); - self.sync_with_window(); self.render(buffer)?; } else { log!("Window split failed"); @@ -3181,9 +3437,8 @@ impl Editor { Action::SplitVertical => { log!("SplitVertical action triggered"); let current_buffer = self.current_buffer_index; - if self.window_manager.split_vertical(current_buffer).is_some() { + if self.update_window_layout(|windows| windows.split_vertical(current_buffer)) { log!("Vertical split successful"); - self.sync_with_window(); self.render(buffer)?; } else { log!("Vertical split failed"); @@ -3199,13 +3454,10 @@ impl Editor { Ok(new_buffer) => { self.buffers.push(new_buffer); let new_buffer_index = self.buffers.len() - 1; - if self - .window_manager - .split_horizontal(new_buffer_index) - .is_some() - { + if self.update_window_layout(|windows| { + windows.split_horizontal(new_buffer_index) + }) { log!("Window split with new file successful"); - self.sync_with_window(); self.render(buffer)?; } else { log!("Window split failed"); @@ -3225,13 +3477,10 @@ impl Editor { Ok(new_buffer) => { self.buffers.push(new_buffer); let new_buffer_index = self.buffers.len() - 1; - if self - .window_manager - .split_vertical(new_buffer_index) - .is_some() - { + if self.update_window_layout(|windows| { + windows.split_vertical(new_buffer_index) + }) { log!("Vertical split with new file successful"); - self.sync_with_window(); self.render(buffer)?; } else { log!("Vertical split failed"); @@ -3245,121 +3494,92 @@ impl Editor { } } Action::CloseWindow => { - if self.window_manager.close_window().is_some() { - self.sync_with_window(); + if self.update_window_layout(WindowManager::close_window) { self.render(buffer)?; } } Action::NextWindow => { let window_count = self.window_manager.windows().len(); if window_count > 1 { - self.sync_to_window(); // Save current window state let next_id = (self.window_manager.active_window_id() + 1) % window_count; - self.window_manager.set_active(next_id); - self.sync_with_window(); // Load new window state + self.set_active_window(next_id); self.render(buffer)?; } } Action::PreviousWindow => { let window_count = self.window_manager.windows().len(); if window_count > 1 { - self.sync_to_window(); // Save current window state let current_id = self.window_manager.active_window_id(); let prev_id = if current_id == 0 { window_count - 1 } else { current_id - 1 }; - self.window_manager.set_active(prev_id); - self.sync_with_window(); // Load new window state + self.set_active_window(prev_id); self.render(buffer)?; } } Action::MoveWindowUp => { - self.sync_to_window(); // Save current window state if let Some(target_id) = self .window_manager .find_window_in_direction(crate::window::Direction::Up) { - self.window_manager.set_active(target_id); - self.sync_with_window(); // Load new window state + self.set_active_window(target_id); self.render(buffer)?; } } Action::MoveWindowDown => { - self.sync_to_window(); // Save current window state if let Some(target_id) = self .window_manager .find_window_in_direction(crate::window::Direction::Down) { - self.window_manager.set_active(target_id); - self.sync_with_window(); // Load new window state + self.set_active_window(target_id); self.render(buffer)?; } } Action::MoveWindowLeft => { - self.sync_to_window(); // Save current window state if let Some(target_id) = self .window_manager .find_window_in_direction(crate::window::Direction::Left) { - self.window_manager.set_active(target_id); - self.sync_with_window(); // Load new window state + self.set_active_window(target_id); self.render(buffer)?; } } Action::MoveWindowRight => { - self.sync_to_window(); // Save current window state if let Some(target_id) = self .window_manager .find_window_in_direction(crate::window::Direction::Right) { - self.window_manager.set_active(target_id); - self.sync_with_window(); // Load new window state + self.set_active_window(target_id); self.render(buffer)?; } } Action::ResizeWindowUp(amount) => { - self.sync_to_window(); // Save current window state - if self - .window_manager - .resize_window(crate::window::Direction::Up, *amount) - .is_some() - { - self.sync_with_window(); // Load new window state + if self.update_window_layout(|windows| { + windows.resize_window(crate::window::Direction::Up, *amount) + }) { self.render(buffer)?; } } Action::ResizeWindowDown(amount) => { - self.sync_to_window(); // Save current window state - if self - .window_manager - .resize_window(crate::window::Direction::Down, *amount) - .is_some() - { - self.sync_with_window(); // Load new window state + if self.update_window_layout(|windows| { + windows.resize_window(crate::window::Direction::Down, *amount) + }) { self.render(buffer)?; } } Action::ResizeWindowLeft(amount) => { - self.sync_to_window(); // Save current window state - if self - .window_manager - .resize_window(crate::window::Direction::Left, *amount) - .is_some() - { - self.sync_with_window(); // Load new window state + if self.update_window_layout(|windows| { + windows.resize_window(crate::window::Direction::Left, *amount) + }) { self.render(buffer)?; } } Action::ResizeWindowRight(amount) => { - self.sync_to_window(); // Save current window state - if self - .window_manager - .resize_window(crate::window::Direction::Right, *amount) - .is_some() - { - self.sync_with_window(); // Load new window state + if self.update_window_layout(|windows| { + windows.resize_window(crate::window::Direction::Right, *amount) + }) { self.render(buffer)?; } } @@ -3371,6 +3591,31 @@ impl Editor { } } + if self.check_bounds() { + self.render(buffer)?; + } + + if self.is_visual() + && matches!( + action, + Action::MoveUp + | Action::MoveDown + | Action::MoveLeft + | Action::MoveRight + | Action::MoveToLineEnd + | Action::MoveToLineStart + | Action::MoveToFirstLineChar + | Action::MoveToLastLineChar + | Action::MoveToTop + | Action::MoveToBottom + | Action::GoToLine(_) + | Action::MoveTo(_, _) + ) + { + self.update_selection(); + self.render(buffer)?; + } + if add_to_history { self.save_to_history(action); } @@ -3418,10 +3663,11 @@ impl Editor { // truncate the message if it's too long let overflow = x.unsigned_abs() as usize; - let (x, text) = if overflow + 3 >= text.len() { + let text_len = text.chars().count(); + let (x, text) = if overflow + 3 >= text_len { (x, text.to_string()) } else { - (0, format!("...{}", &text[overflow..])) + (0, format!("...{}", char_suffix(text, overflow))) }; self.render_commands.push_back(RenderCommand::BufferText { @@ -3563,17 +3809,14 @@ impl Editor { self.registers.insert(DEFAULT_REGISTER, content.clone()); - self.undo_actions.push(Action::InsertText { - x: x0, - y: y0, - content, - }); - match self.mode { Mode::VisualLine => { - for y in (y0..=y1).rev() { - self.current_buffer_mut().remove_line(y); - } + let end = if y1 < self.current_buffer().len() { + TextPosition::new(y1 + 1, 0) + } else { + TextPosition::new(y1, self.length_for_line(y1)) + }; + self.replace_range(TextRange::new(TextPosition::new(y0, 0), end), ""); } Mode::VisualBlock => { let min_x = std::cmp::min(x0, x1); @@ -3581,44 +3824,45 @@ impl Editor { for y in y0..=y1 { if let Some(line) = self.current_buffer().get(y) { - if min_x >= line.len() { + let line = line.trim_end_matches('\n'); + let line_len = grapheme_len(line); + if min_x >= line_len { continue; } - let before = line[..min_x].to_string(); - let after = if max_x + 1 >= line.len() { - String::new() - } else { - line[max_x + 1..].to_string() - }; - self.current_buffer_mut() - .replace_line(y, format!("{}{}", before, after)); + let start = self.grapheme_to_char_on_line(min_x, y); + let end = + self.grapheme_to_char_on_line((max_x + 1).min(line_len), y); + self.replace_range( + TextRange::new( + TextPosition::new(y, start), + TextPosition::new(y, end), + ), + "", + ); } } } Mode::Visual => { if y0 == y1 { - let line = self.current_buffer().get(y0).unwrap(); - let before = line[..x0].to_string(); - let after = line[x1 + 1..].to_string(); - self.current_buffer_mut() - .replace_line(y0, format!("{}{}", before, after)); + let start = self.grapheme_to_char_on_line(x0, y0); + let end = self.grapheme_to_char_on_line(x1 + 1, y0); + self.replace_range( + TextRange::new( + TextPosition::new(y0, start), + TextPosition::new(y0, end), + ), + "", + ); } else { - // Multi-line deletion - let first_line = self.current_buffer().get(y0).unwrap(); - let last_line = self.current_buffer().get(y1).unwrap(); - - // Combine the parts before and after the selection - let before = first_line[..x0].to_string(); - let after = last_line[x1 + 1..].to_string(); - let new_line = format!("{}{}", before, after); - - // Replace the first line with the combined text - self.current_buffer_mut().replace_line(y0, new_line); - - // Remove the lines in between - for y in (y0 + 1..=y1).rev() { - self.current_buffer_mut().remove_line(y); - } + let start = self.grapheme_to_char_on_line(x0, y0); + let end = self.grapheme_to_char_on_line(x1 + 1, y1); + self.replace_range( + TextRange::new( + TextPosition::new(y0, start), + TextPosition::new(y1, end), + ), + "", + ); } } _ => {} @@ -3643,7 +3887,14 @@ impl Editor { } fn paste(&mut self, content: &Content, before: bool) { + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("paste"); + } self.insert_content(self.cx, self.buffer_line(), content, before); + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); + } } fn insert_content(&mut self, x: usize, y: usize, content: &Content, before: bool) { @@ -3655,10 +3906,13 @@ impl Editor { } fn insert_linewise(&mut self, y: usize, contents: &Content, before: bool) { - for (dy, line) in contents.text.lines().enumerate() { - self.current_buffer_mut() - .insert_line(y + dy + if before { 0 } else { 1 }, line.to_string()); + let target_y = y + if before { 0 } else { 1 }; + let mut text = String::new(); + for line in contents.text.lines() { + text.push_str(line); + text.push('\n'); } + self.replace_range(TextRange::insertion(TextPosition::new(target_y, 0)), &text); } fn insert_blockwise(&mut self, x: usize, y: usize, contents: &Content, before: bool) { @@ -3669,53 +3923,54 @@ impl Editor { let y = y + dy; // Extend the buffer with empty lines if needed while self.current_buffer().len() <= y { - self.current_buffer_mut().insert_line(y, String::new()); + self.replace_range(TextRange::insertion(TextPosition::new(y, 0)), "\n"); } let current_line = self.current_buffer().get(y).unwrap_or_default(); - let mut new_line = current_line.clone(); + let current_line = current_line.trim_end_matches('\n'); + let mut new_line = current_line.to_string(); // Extend the line with spaces if needed - while new_line.len() < paste_x { + while grapheme_len(&new_line) < paste_x { new_line.push(' '); } // Insert the block text - new_line.insert_str(paste_x, line); - self.current_buffer_mut().replace_line(y, new_line); + let paste_byte = grapheme_to_byte(&new_line, paste_x); + new_line.insert_str(paste_byte, line); + self.replace_range( + TextRange::new( + TextPosition::new(y, 0), + TextPosition::new(y, current_line.chars().count()), + ), + &new_line, + ); } } fn insert_charwise(&mut self, x: usize, y: usize, contents: &Content, before: bool) { - let lines = contents.text.lines().collect::>(); - let count = lines.len(); + let insert_x = self.grapheme_to_char_on_line(x, y); + let insertion = if before { + insert_x + } else { + let after_x = self.grapheme_to_char_on_line(x + 1, y); + self.cx += 1; + after_x + }; + self.replace_range( + TextRange::insertion(TextPosition::new(y, insertion)), + &contents.text, + ); + } - if count == 1 { - let line = lines[0]; - if before { - self.current_buffer_mut().insert_str(x, y, line); - } else { - self.current_buffer_mut().insert_str(x + 1, y, line); - self.cx += 1; - } - return; + fn insert_content_as_transaction(&mut self, x: usize, y: usize, content: &Content) { + let started_transaction = !self.transaction_active(); + if started_transaction { + self.begin_transaction("insert text"); } - - let line_contents = self.current_line_contents().unwrap_or_default(); - let (text_before, text_after) = line_contents.split_at(x); - - for (n, line) in lines.iter().enumerate() { - if n == 0 { - self.current_buffer_mut().set(y, text_before.to_string()); - self.current_buffer_mut().insert_str(x, y, line); - } else if n == count - 1 { - let new_text = format!("{}{}", line, text_after); - self.current_buffer_mut() - .insert_line(y + count - 1, new_text); - } else { - self.current_buffer_mut() - .insert_line(y + n, line.to_string()); - } + self.insert_content(x, y, content, true); + if started_transaction { + self.commit_transaction(self.cursor_snapshot()); } } @@ -3809,45 +4064,27 @@ impl Editor { &mut self, line: usize, buffer: &mut RenderBuffer, - runtime: &mut Runtime, + _runtime: &mut Runtime, pos: GoToLinePosition, ) -> anyhow::Result<()> { if line == 0 { - self.execute(&Action::MoveToTop, buffer, runtime).await?; + self.vtop = 0; + self.cy = 0; + self.render(buffer)?; return Ok(()); } - if line <= self.current_buffer().len() { - let y = line - 1; - - if self.is_within_viewport(y) { - self.cy = y - self.vtop; - } else if self.is_within_first_page(y) { - self.vtop = 0; - self.cy = y; - self.render(buffer)?; - } else if self.is_within_last_page(y) { - self.vtop = self.current_buffer().len() - self.vheight(); - self.cy = y - self.vtop; - self.render(buffer)?; - } else { - if matches!(pos, GoToLinePosition::Bottom) { - self.vtop = y - self.vheight(); - self.cy = self.buffer_line() - self.vtop; - } else { - self.vtop = y; - self.cy = 0; - if matches!(pos, GoToLinePosition::Center) { - self.execute(&Action::MoveLineToViewportCenter, buffer, runtime) - .await?; - } - } + let y = line.saturating_sub(1).min(self.last_navigable_line()); + let viewport_height = self.vheight().max(1); - // FIXME: this is wasteful when move to viewport center worked - // but we have to account for the case where it didn't and also - self.render(buffer)?; - } - } + self.vtop = match pos { + GoToLinePosition::Top => y, + GoToLinePosition::Center => y.saturating_sub(viewport_height / 2), + GoToLinePosition::Bottom => y.saturating_sub(viewport_height.saturating_sub(1)), + }; + self.cy = y.saturating_sub(self.vtop); + self.check_bounds(); + self.render(buffer)?; Ok(()) } @@ -3881,14 +4118,6 @@ impl Editor { (self.vtop..self.vtop + self.vheight()).contains(&y) } - fn is_within_last_page(&self, y: usize) -> bool { - y > self.current_buffer().len() - self.vheight() - } - - fn is_within_first_page(&self, y: usize) -> bool { - y < self.vheight() - } - fn event_to_key_action( &mut self, mappings: &HashMap, @@ -3934,18 +4163,16 @@ impl Editor { let window_vtop = window.vtop; // Switch to the clicked window if it's not already active - if window_id != self.window_manager.active_window_id() { - self.sync_to_window(); // Save current window state - self.window_manager.set_active(window_id); - self.sync_with_window(); // Load new window state - } + self.set_active_window(window_id); // Convert terminal coordinates to window-local coordinates if let Some((local_x, local_y)) = window.terminal_to_local(click_x, click_y) { - // Adjust for gutter - let buffer_x = local_x.saturating_sub(self.gutter_width() + 1); + // Adjust for the clicked window's gutter, not the active buffer's. + let gutter_width = + self.gutter_width_for_buffer_index(window_buffer_index); + let buffer_x = local_x.saturating_sub(gutter_width + 1); let buffer_y = window_vtop + local_y; // Ensure y is within buffer bounds @@ -3978,11 +4205,7 @@ impl Editor { if let Some((window_id, _window)) = self.window_manager.window_at_position(click_x, click_y) { - if window_id != self.window_manager.active_window_id() { - self.sync_to_window(); // Save current window state - self.window_manager.set_active(window_id); - self.sync_with_window(); // Load new window state - } + self.set_active_window(window_id); } Some(KeyAction::Single(Action::ScrollUp)) @@ -3995,11 +4218,7 @@ impl Editor { if let Some((window_id, _window)) = self.window_manager.window_at_position(click_x, click_y) { - if window_id != self.window_manager.active_window_id() { - self.sync_to_window(); // Save current window state - self.window_manager.set_active(window_id); - self.sync_with_window(); // Load new window state - } + self.set_active_window(window_id); } Some(KeyAction::Single(Action::ScrollDown)) @@ -4027,6 +4246,110 @@ impl Editor { &mut self.buffers[self.current_buffer_index] } + fn cursor_snapshot(&self) -> CursorSnapshot { + CursorSnapshot::new(self.cx, self.buffer_line(), self.vtop) + } + + fn restore_cursor_snapshot(&mut self, snapshot: CursorSnapshot) { + self.vtop = snapshot.vtop; + self.cy = snapshot.y.saturating_sub(self.vtop); + self.cx = snapshot.x; + self.check_bounds(); + } + + fn begin_transaction(&mut self, label: impl Into) { + let before_cursor = self.cursor_snapshot(); + self.current_buffer_mut() + .undo_history + .begin_transaction(label, before_cursor); + } + + fn transaction_active(&self) -> bool { + self.current_buffer().undo_history.is_transaction_active() + } + + fn commit_active_transaction_before_save(&mut self) -> bool { + let was_active = self.transaction_active(); + if was_active { + self.commit_transaction(self.cursor_snapshot()); + } + was_active + } + + fn resume_insert_transaction_after_save(&mut self, was_active: bool) { + if was_active && self.is_insert() && !self.transaction_active() { + self.begin_transaction("insert"); + } + } + + fn replace_range(&mut self, range: TextRange, new_text: &str) { + let old_text = self.current_buffer().text_in_range(range); + if old_text == new_text { + return; + } + self.current_buffer_mut().replace_range_raw(range, new_text); + self.current_buffer_mut().undo_history.record_replace( + range, + old_text, + new_text.to_string(), + ); + } + + fn commit_transaction(&mut self, after_cursor: CursorSnapshot) -> bool { + let committed = self + .current_buffer_mut() + .undo_history + .commit_transaction(after_cursor); + self.current_buffer_mut().refresh_dirty_from_history(); + committed + } + + fn cancel_transaction_if_empty(&mut self) { + self.current_buffer_mut() + .undo_history + .cancel_transaction_if_empty(); + } + + async fn undo_transaction( + &mut self, + render_buffer: &mut RenderBuffer, + runtime: &mut Runtime, + ) -> anyhow::Result<()> { + let buffer = self.current_buffer_mut(); + let mut history = std::mem::take(&mut buffer.undo_history); + let cursor = history.undo(buffer); + buffer.undo_history = history; + buffer.refresh_dirty_from_history(); + + if let Some(cursor) = cursor { + self.restore_cursor_snapshot(cursor); + self.notify_change(runtime).await?; + self.render(render_buffer)?; + } + + Ok(()) + } + + async fn redo_transaction( + &mut self, + render_buffer: &mut RenderBuffer, + runtime: &mut Runtime, + ) -> anyhow::Result<()> { + let buffer = self.current_buffer_mut(); + let mut history = std::mem::take(&mut buffer.undo_history); + let cursor = history.redo(buffer); + buffer.undo_history = history; + buffer.refresh_dirty_from_history(); + + if let Some(cursor) = cursor { + self.restore_cursor_snapshot(cursor); + self.notify_change(runtime).await?; + self.render(render_buffer)?; + } + + Ok(()) + } + pub fn current_file_name(&self) -> Option { self.current_buffer().file.clone() } @@ -4080,9 +4403,10 @@ impl Editor { for y in y0..=y1 { if let Some(line) = self.current_buffer().get(y) { - let end = std::cmp::min(max_x + 1, line.len()); - if min_x <= line.len() { - text.push_str(&line[min_x..end]); + let line_len = line.chars().count(); + let end = std::cmp::min(max_x + 1, line_len); + if min_x <= line_len { + text.push_str(char_slice(&line, min_x, end)); } text.push('\n'); } @@ -4094,8 +4418,15 @@ impl Editor { for y in y0..=y1 { let line = self.current_buffer().get(y).unwrap(); let start = if y == y0 { x0 } else { 0 }; - let end = if y == y1 { x1 } else { line.len() - 1 }; - text.push_str(&line[start..=end]); + let end = if y == y1 { + x1 + } else { + line.trim_end_matches('\n') + .chars() + .count() + .saturating_sub(1) + }; + text.push_str(char_slice(&line, start, end + 1)); if y != y1 { text.push('\n'); } @@ -4109,13 +4440,9 @@ impl Editor { fn fix_cursor_pos(&mut self) { let line_len = self.line_length(); - if self.is_normal() && line_len > 0 { - // In normal mode, cursor can't be on the newline character - if self.cx >= line_len { - self.cx = line_len.saturating_sub(1); - } - } else if self.cx > line_len { - // In other modes, cursor can be at the end of line + if self.cx > line_len { + // Cursor positions are character indices and may sit one past the + // final character for append-style editing. self.cx = line_len; } } @@ -4213,6 +4540,58 @@ impl From<&Buffer> for BufferInfo { } } +fn directory_listing(path: &str) -> Value { + let read_dir = match std::fs::read_dir(path) { + Ok(read_dir) => read_dir, + Err(err) => { + return json!({ + "path": path, + "entries": [], + "error": err.to_string(), + }); + } + }; + + let mut entries = read_dir + .filter_map(|entry| { + let entry = entry.ok()?; + let metadata = entry.metadata().ok()?; + let kind = if metadata.is_dir() { + "directory" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + Some(json!({ + "name": entry.file_name().to_string_lossy(), + "path": entry.path().to_string_lossy(), + "kind": kind, + })) + }) + .collect::>(); + + entries.sort_by(|a, b| { + let kind_rank = |value: &Value| match value.get("kind").and_then(Value::as_str) { + Some("directory") => 0, + Some("file") => 1, + _ => 2, + }; + let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default(); + let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default(); + + kind_rank(a) + .cmp(&kind_rank(b)) + .then_with(|| a_name.to_lowercase().cmp(&b_name.to_lowercase())) + }); + + json!({ + "path": path, + "entries": entries, + "error": null, + }) +} + fn determine_style_for_position(style_info: &[StyleInfo], pos: usize) -> Option