From b60c1ba2251bd06d28d59f84bfe73d1d6c11a129 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 12 Jul 2026 15:48:17 +0100 Subject: [PATCH 1/5] feat(editor): add multi-buffer find-file (#30) --- README.md | 15 +- docs/issues/30-plan.md | 38 ++++ src/app.rs | 505 ++++++++++++++++++++++++++++------------- src/buffer.rs | 1 + src/commands.rs | 41 +--- src/editor.rs | 335 +++++++++++++++++++++++++++ src/keymap.rs | 7 + src/main.rs | 1 + 8 files changed, 750 insertions(+), 193 deletions(-) create mode 100644 docs/issues/30-plan.md create mode 100644 src/editor.rs diff --git a/README.md b/README.md index 9179e7c..b67f708 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A small macOS-only terminal code editor written in Rust. Cortex is currently in v0.3 development. -The current goal is one fast editing loop: open a file or directory, edit one buffer, save it, and quit cleanly. +The current goal is one fast editing loop: open files or a directory, edit and switch buffers, save, and quit cleanly. ## Platform @@ -94,13 +94,16 @@ Existing files open with their current contents. Missing files open as empty clean buffers attached to the requested path. Saving can create the target file when its parent directory already exists. Saving does not create missing parent directories. +Inside Cortex, `C-x C-f` accepts an existing or missing file path. +Enter a missing path, edit the empty buffer, and save to create the file. +Enter a directory path to browse it in the directory picker. Directories open a picker that lists non-hidden entries. The picker can open regular files. The picker can expand and collapse directories. ## Current Scope -The current editor supports one buffer in the terminal alternate screen. +The current editor supports multiple file buffers in the terminal alternate screen. It uses raw terminal mode while running and should restore the shell after exit. It shows file text, cursor position, dirty state, save errors, and short status messages in a modeline. It includes a directory picker, a slash command line, visual theme and modeline polish, and syntax highlighting for supported file types. @@ -127,7 +130,8 @@ It includes a directory picker, a slash command line, visual theme and modeline | `C-/` or `C-_` | Undo the last edit | | `C-x u` | Undo the last edit | | `Command-z` | Undo the last edit | -| `C-x C-f` | Open the file picker when the current buffer is clean | +| `C-x C-f` | Enter a path to find or create a file buffer | +| `C-x b` | Switch to an open buffer by path or unique file name | | `C-x C-s` | Save the file | | `C-x C-c` | Quit | | `/` | Open the slash command line | @@ -153,7 +157,7 @@ Press `n` or Escape to cancel. Escape cancels the command line. Unknown slash commands leave the editor open and show an error message. -`/open ` rejects directories and keeps the current buffer in place when it has unsaved changes. +`/open ` rejects directories and opens another buffer without discarding unsaved changes. ## Directory Picker Keybindings @@ -185,7 +189,8 @@ See [docs/release.md](docs/release.md) for the release checklist. ## Known Limitations Redo is available from the slash command line, but does not have a dedicated keybinding yet. -Cortex has one active buffer at a time. +Cortex shows one active buffer at a time. +The switch-buffer prompt requires an exact path or a unique file name and does not offer completion yet. The directory picker can expand directories, but it is still a minimal picker. The slash command `/open ` opens files only, not directories. There are no splits, tabs, minibuffer, config, plugins, LSP, AI integration, or embedded terminal pane yet. diff --git a/docs/issues/30-plan.md b/docs/issues/30-plan.md new file mode 100644 index 0000000..7b3d12d --- /dev/null +++ b/docs/issues/30-plan.md @@ -0,0 +1,38 @@ +# Issue 30 Plan + +## Goal + +Add multiple in-memory file buffers with Emacs-style find-file and switch-buffer commands. + +`C-x C-f` must accept an editable path. +An existing path opens its file. +A missing path opens an empty buffer and saving creates the file when its parent directory exists. + +## Implementation + +1. Add an editor-level buffer list that owns each buffer and its view state. +2. Keep one active buffer and switch to an already-open path instead of duplicating it. +3. Generalize the existing bottom command input enough to support find-file and switch-buffer prompts. +4. Bind `C-x C-f` to find-file and `C-x b` to switch-buffer. +5. Keep dirty state per buffer, save only the active buffer, and protect dirty inactive buffers on quit. +6. Update the README for the new keys, file creation behavior, and remaining limitations. + +## Acceptance Checks + +- Opening another file retains the first buffer and its unsaved edits. +- Switching buffers restores the selected buffer and its view state. +- Saving affects only the active buffer. +- Quitting warns when any buffer is dirty. +- `C-x C-f` opens existing files and missing paths. +- Saving a missing path creates the file when its parent exists. +- Saving fails clearly when the parent directory is missing. +- Canceling a prompt leaves the active buffer unchanged. +- Directory startup and picker browsing still work. + +## Verification + +- Run focused buffer-list, app, keymap, and renderer tests. +- Run `cargo fmt --check`. +- Run `cargo clippy --all-targets --all-features -- -D warnings`. +- Run `cargo test`. +- Manually smoke test find-file, buffer switching, save creation, dirty quit, and terminal cleanup. diff --git a/src/app.rs b/src/app.rs index 84f94fa..02babc2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crate::{ buffer::Buffer, commands, + editor::{Editor, OpenResult, SwitchError}, input::key_from_event, keymap::{Keymap, KeymapResult}, picker::{DirectoryPicker, DirectoryPickerAction}, @@ -18,7 +19,7 @@ use std::{ path::{Path, PathBuf}, }; -const DIRTY_QUIT_PROMPT: &str = "Buffer modified; quit without saving? (y or n)"; +const DIRTY_QUIT_PROMPT: &str = "Buffers modified; quit without saving? (y or n)"; const COMMAND_HELP: &str = "Commands: /help, /commands, /open , /search , /next, /save, /undo, /redo, /quit, /quit!"; @@ -28,6 +29,7 @@ struct AppState { status_kind: Option, dirty_quit_prompt: bool, command_line: Option, + prompt_kind: Option, keycast: Option, last_search: Option, mark: Option, @@ -35,9 +37,26 @@ struct AppState { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PromptKind { + Command, + FindFile, + SwitchBuffer, +} + +#[derive(Debug, Clone, PartialEq, Eq)] enum AppAction { Continue, - OpenFilePicker, + FindFile(PathBuf), + OpenFile(PathBuf), + SwitchBuffer(String), + Quit, + ForceQuit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AppControl { + Continue, + BrowseDirectory(PathBuf), Quit, } @@ -71,51 +90,39 @@ fn is_directory_path(path: &Path) -> io::Result { } } -fn run_editor( - terminal: &mut TerminalSession, - mut buffer: Buffer, -) -> io::Result<()> { - let mut view = View::new(); +fn run_editor(terminal: &mut TerminalSession, buffer: Buffer) -> io::Result<()> { + let mut editor = Editor::new(buffer)?; let mut keymap = Keymap::new(); let renderer = Renderer::new(); let mut app_state = AppState::default(); - render( - &renderer, - terminal.writer_mut(), - &buffer, - &mut view, - &app_state, - )?; + render_editor(&renderer, terminal.writer_mut(), &mut editor, &app_state)?; loop { match event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press { let key = key_from_event(key); - match app_state.handle_key(key, &mut keymap, &mut buffer, &mut view) { - AppAction::Continue => {} - AppAction::OpenFilePicker => { - open_file_from_picker(terminal, &mut buffer, &mut view, &mut app_state)? - } - AppAction::Quit => break, + let action = { + let (buffer, view) = editor.active_mut(); + app_state.handle_key(key, &mut keymap, buffer, view) + }; + match apply_app_action(&mut editor, &mut app_state, action) { + AppControl::Continue => {} + AppControl::BrowseDirectory(path) => browse_directory_in_editor( + terminal, + &mut editor, + &path, + &mut app_state, + )?, + AppControl::Quit => break, } - render( - &renderer, - terminal.writer_mut(), - &buffer, - &mut view, - &app_state, - )?; + render_editor(&renderer, terminal.writer_mut(), &mut editor, &app_state)?; } } - Event::Resize(_, _) => render( - &renderer, - terminal.writer_mut(), - &buffer, - &mut view, - &app_state, - )?, + Event::Resize(_, _) => { + render_editor(&renderer, terminal.writer_mut(), &mut editor, &app_state)? + } _ => {} } } @@ -123,6 +130,104 @@ fn run_editor( Ok(()) } +fn apply_app_action( + editor: &mut Editor, + app_state: &mut AppState, + action: AppAction, +) -> AppControl { + match action { + AppAction::Continue => AppControl::Continue, + AppAction::FindFile(path) => match is_directory_path(&path) { + Ok(true) => AppControl::BrowseDirectory(path), + Ok(false) => { + open_file_in_editor(editor, &path, app_state); + AppControl::Continue + } + Err(error) => { + app_state.set_status(format!("Open failed: {error}"), StatusKind::Error); + AppControl::Continue + } + }, + AppAction::OpenFile(path) => { + open_file_in_editor(editor, &path, app_state); + AppControl::Continue + } + AppAction::SwitchBuffer(name) => { + switch_buffer(editor, &name, app_state); + AppControl::Continue + } + AppAction::Quit if editor.any_dirty() => { + app_state.request_dirty_quit(); + AppControl::Continue + } + AppAction::Quit | AppAction::ForceQuit => AppControl::Quit, + } +} + +fn browse_directory_in_editor( + terminal: &mut TerminalSession, + editor: &mut Editor, + path: &Path, + app_state: &mut AppState, +) -> io::Result<()> { + let picker = match DirectoryPicker::read(path) { + Ok(picker) => picker, + Err(error) => { + app_state.set_status(format!("Open failed: {error}"), StatusKind::Error); + return Ok(()); + } + }; + + let Some(path) = run_directory_picker(terminal, picker)? else { + app_state.set_status("Open canceled", StatusKind::Info); + return Ok(()); + }; + + open_file_in_editor(editor, &path, app_state); + Ok(()) +} + +fn open_file_in_editor(editor: &mut Editor, path: &Path, app_state: &mut AppState) { + match is_directory_path(path) { + Ok(true) => app_state.set_status( + format!("Open failed: {} is a directory", path.display()), + StatusKind::Error, + ), + Ok(false) => match editor.open(path) { + Ok(OpenResult::Opened) => { + app_state.mark = None; + app_state.set_status(format!("Opened {}", path.display()), StatusKind::Success); + } + Ok(OpenResult::AlreadyOpen) => { + app_state.mark = None; + app_state.set_status( + format!("Switched to {}", path.display()), + StatusKind::Success, + ); + } + Err(error) => app_state.set_status(format!("Open failed: {error}"), StatusKind::Error), + }, + Err(error) => app_state.set_status(format!("Open failed: {error}"), StatusKind::Error), + } +} + +fn switch_buffer(editor: &mut Editor, name: &str, app_state: &mut AppState) { + match editor.switch_to(name) { + Ok(()) => { + app_state.mark = None; + let path = editor.active().0.path().display().to_string(); + app_state.set_status(format!("Switched to {path}"), StatusKind::Success); + } + Err(SwitchError::Ambiguous) => { + app_state.set_status(format!("Ambiguous buffer name: {name}"), StatusKind::Error) + } + Err(SwitchError::NotFound) => app_state.set_status( + format!("No open buffer named {name}. Open: {}", editor.names()), + StatusKind::Error, + ), + } +} + fn run_directory_picker( terminal: &mut TerminalSession, mut picker: DirectoryPicker, @@ -163,49 +268,6 @@ fn run_directory_picker( } } -fn open_file_from_picker( - terminal: &mut TerminalSession, - buffer: &mut Buffer, - view: &mut View, - app_state: &mut AppState, -) -> io::Result<()> { - let directory = picker_directory(buffer.path()); - let picker = match DirectoryPicker::read(&directory) { - Ok(picker) => picker, - Err(error) => { - app_state.set_status(format!("Open failed: {error}"), StatusKind::Error); - return Ok(()); - } - }; - - let Some(path) = run_directory_picker(terminal, picker)? else { - app_state.set_status("Open canceled", StatusKind::Info); - return Ok(()); - }; - - match Buffer::open(&path) { - Ok(opened) => { - *buffer = opened; - *view = View::new(); - app_state.mark = None; - app_state.set_status(format!("Opened {}", path.display()), StatusKind::Success); - } - Err(error) => { - app_state.set_status(format!("Open failed: {error}"), StatusKind::Error); - } - } - - Ok(()) -} - -fn picker_directory(file_path: &Path) -> PathBuf { - file_path - .parent() - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")) - .to_path_buf() -} - impl AppState { fn handle_key( &mut self, @@ -226,6 +288,7 @@ impl AppState { if key == crate::input::Key::Char('/') && !keymap.has_pending_prefix() { self.command_line = Some("/".to_string()); + self.prompt_kind = Some(PromptKind::Command); self.clear_status(); return AppAction::Continue; } @@ -238,6 +301,8 @@ impl AppState { KeymapResult::Command(commands::Command::RepeatSearch) => { self.repeat_search(buffer, view) } + KeymapResult::Command(commands::Command::OpenFile) => self.start_find_file(), + KeymapResult::Command(commands::Command::SwitchBuffer) => self.start_switch_buffer(), KeymapResult::Command(command) => self.dispatch_command(command, buffer, view), KeymapResult::PendingPrefix => { self.set_status("C-x", StatusKind::Prefix); @@ -250,6 +315,20 @@ impl AppState { } } + fn start_find_file(&mut self) -> AppAction { + self.command_line = Some(String::new()); + self.prompt_kind = Some(PromptKind::FindFile); + self.clear_status(); + AppAction::Continue + } + + fn start_switch_buffer(&mut self) -> AppAction { + self.command_line = Some(String::new()); + self.prompt_kind = Some(PromptKind::SwitchBuffer); + self.clear_status(); + AppAction::Continue + } + fn active_region(&self, buffer: &Buffer, view: &View) -> Option> { let len_chars = buffer.len_chars(); let mark = self.mark?.min(len_chars); @@ -334,17 +413,44 @@ impl AppState { } crate::input::Key::Enter => { let input = self.command_line.take().unwrap_or_default(); - self.run_command_line(&input, buffer, view) + match self.prompt_kind.take().unwrap_or(PromptKind::Command) { + PromptKind::Command => self.run_command_line(&input, buffer, view), + PromptKind::FindFile => self.submit_find_file(&input), + PromptKind::SwitchBuffer => self.submit_switch_buffer(&input), + } } crate::input::Key::Escape => { self.command_line = None; - self.set_status("Command canceled", StatusKind::Info); + let message = match self.prompt_kind.take().unwrap_or(PromptKind::Command) { + PromptKind::Command => "Command canceled", + PromptKind::FindFile => "Find file canceled", + PromptKind::SwitchBuffer => "Switch buffer canceled", + }; + self.set_status(message, StatusKind::Info); AppAction::Continue } _ => AppAction::Continue, } } + fn submit_find_file(&mut self, input: &str) -> AppAction { + if input.trim().is_empty() { + self.set_status("Find file requires a path", StatusKind::Error); + return AppAction::Continue; + } + + AppAction::FindFile(PathBuf::from(input)) + } + + fn submit_switch_buffer(&mut self, input: &str) -> AppAction { + if input.trim().is_empty() { + self.set_status("Switch buffer requires a name", StatusKind::Error); + return AppAction::Continue; + } + + AppAction::SwitchBuffer(input.to_string()) + } + fn run_command_line(&mut self, input: &str, buffer: &mut Buffer, view: &mut View) -> AppAction { let trimmed = input.trim(); let Some(command_text) = trimmed.strip_prefix('/') else { @@ -362,13 +468,13 @@ impl AppState { "undo" => self.dispatch_command(commands::Command::Undo, buffer, view), "redo" => self.dispatch_command(commands::Command::Redo, buffer, view), "quit" => self.dispatch_command(commands::Command::Quit, buffer, view), - "quit!" => AppAction::Quit, + "quit!" => AppAction::ForceQuit, command if command == "search" || command.starts_with("search ") => { self.run_search_command(command, buffer, view) } "next" => self.repeat_search(buffer, view), command if command == "open" || command.starts_with("open ") => { - self.run_open_command(command, buffer, view) + self.run_open_command(command) } command => { self.set_status(format!("Unknown command: /{command}"), StatusKind::Error); @@ -422,12 +528,7 @@ impl AppState { AppAction::Continue } - fn run_open_command( - &mut self, - command: &str, - buffer: &mut Buffer, - view: &mut View, - ) -> AppAction { + fn run_open_command(&mut self, command: &str) -> AppAction { let path_text = command .strip_prefix("open") .map(str::trim) @@ -438,14 +539,6 @@ impl AppState { return AppAction::Continue; } - if buffer.is_dirty() { - self.set_status( - "Open canceled: current buffer has unsaved changes", - StatusKind::Prompt, - ); - return AppAction::Continue; - } - let path = PathBuf::from(path_text); match is_directory_path(&path) { Ok(true) => { @@ -455,19 +548,7 @@ impl AppState { ); AppAction::Continue } - Ok(false) => match Buffer::open(&path) { - Ok(opened) => { - *buffer = opened; - *view = View::new(); - self.mark = None; - self.set_status(format!("Opened {}", path.display()), StatusKind::Success); - AppAction::Continue - } - Err(error) => { - self.set_status(format!("Open failed: {error}"), StatusKind::Error); - AppAction::Continue - } - }, + Ok(false) => AppAction::OpenFile(path), Err(error) => { self.set_status(format!("Open failed: {error}"), StatusKind::Error); AppAction::Continue @@ -477,7 +558,7 @@ impl AppState { fn handle_dirty_quit_key(&mut self, key: crate::input::Key) -> AppAction { match key { - crate::input::Key::Char('y') => AppAction::Quit, + crate::input::Key::Char('y') => AppAction::ForceQuit, crate::input::Key::Char('n') | crate::input::Key::Escape => { self.dirty_quit_prompt = false; self.set_status("Quit canceled", StatusKind::Info); @@ -490,6 +571,11 @@ impl AppState { } } + fn request_dirty_quit(&mut self) { + self.dirty_quit_prompt = true; + self.set_status(DIRTY_QUIT_PROMPT, StatusKind::Prompt); + } + fn dispatch_command( &mut self, command: commands::Command, @@ -517,17 +603,6 @@ impl AppState { return AppAction::Continue; } - if outcome.open_file_picker { - self.clear_status(); - return AppAction::OpenFilePicker; - } - - if outcome.open_file_blocked { - self.status_message = outcome.status_message; - self.status_kind = Some(StatusKind::Prompt); - return AppAction::Continue; - } - self.status_kind = outcome.status_message.as_ref().map(|_| { if outcome.save_failed { StatusKind::Error @@ -548,6 +623,25 @@ impl AppState { self.status_message = None; self.status_kind = None; } + + fn prompt_text(&self) -> Option { + let input = self.command_line.as_ref()?; + Some(match self.prompt_kind.unwrap_or(PromptKind::Command) { + PromptKind::Command => input.clone(), + PromptKind::FindFile => format!("Find file: {input}"), + PromptKind::SwitchBuffer => format!("Switch buffer: {input}"), + }) + } +} + +fn render_editor( + renderer: &Renderer, + writer: &mut W, + editor: &mut Editor, + app_state: &AppState, +) -> io::Result<()> { + let (buffer, view) = editor.active_mut(); + render(renderer, writer, buffer, view, app_state) } fn render( @@ -559,6 +653,7 @@ fn render( ) -> io::Result<()> { let (cols, rows) = terminal::size().unwrap_or((80, 24)); let size = TerminalSize { cols, rows }; + let prompt_text = app_state.prompt_text(); view.ensure_point_visible(buffer, renderer.viewport_height(size)); renderer.render( writer, @@ -568,7 +663,7 @@ fn render( app_state.status_message.as_deref(), app_state.status_kind, app_state.active_region(buffer, view), - app_state.command_line.as_deref(), + prompt_text.as_deref(), app_state.keycast.as_deref(), ) } @@ -630,8 +725,13 @@ fn command_clears_mark(command: commands::Command) -> bool { #[cfg(test)] mod tests { - use super::{picker_directory, AppAction, AppState, COMMAND_HELP, DIRTY_QUIT_PROMPT}; - use crate::{buffer::Buffer, input::Key, keymap::Keymap, renderer::StatusKind, view::View}; + use super::{ + apply_app_action, AppAction, AppControl, AppState, COMMAND_HELP, DIRTY_QUIT_PROMPT, + }; + use crate::{ + buffer::Buffer, editor::Editor, input::Key, keymap::Keymap, renderer::StatusKind, + view::View, + }; use std::{ fs, path::PathBuf, @@ -732,7 +832,7 @@ mod tests { start_dirty_quit_prompt(&mut app, &mut keymap, &mut buffer, &mut view); let action = app.handle_key(Key::Char('y'), &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::Quit); + assert_eq!(action, AppAction::ForceQuit); assert!(buffer.is_dirty()); } @@ -949,7 +1049,7 @@ mod tests { } #[test] - fn ctrl_x_ctrl_f_requests_file_picker_when_buffer_is_clean() { + fn ctrl_x_ctrl_f_opens_an_editable_find_file_prompt() { let mut app = AppState::default(); let mut keymap = Keymap::new(); let mut buffer = buffer_with_text("notes.txt", "old"); @@ -958,14 +1058,18 @@ mod tests { app.handle_key(Key::Ctrl('x'), &mut keymap, &mut buffer, &mut view); let action = app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::OpenFilePicker); + assert_eq!(action, AppAction::Continue); + assert_eq!(app.command_line.as_deref(), Some("")); + assert_eq!(app.prompt_text().as_deref(), Some("Find file: ")); assert_eq!(app.status_message, None); assert_eq!(buffer.text(), "old"); assert!(!buffer.is_dirty()); } #[test] - fn ctrl_x_ctrl_f_keeps_dirty_buffer_in_place() { + fn find_file_accepts_a_missing_path_while_current_buffer_is_dirty() { + let dir = test_dir("find-file-missing"); + let target = dir.join("new.txt"); let mut app = AppState::default(); let mut keymap = Keymap::new(); let mut buffer = buffer_with_text("notes.txt", "old"); @@ -973,28 +1077,130 @@ mod tests { app.handle_key(Key::Char('x'), &mut keymap, &mut buffer, &mut view); app.handle_key(Key::Ctrl('x'), &mut keymap, &mut buffer, &mut view); - let action = app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view); + for ch in target.to_string_lossy().chars() { + app.handle_key(Key::Char(ch), &mut keymap, &mut buffer, &mut view); + } + let action = app.handle_key(Key::Enter, &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::Continue); + assert_eq!(action, AppAction::FindFile(target)); assert_eq!(buffer.text(), "xold"); assert!(buffer.is_dirty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn escape_cancels_find_file_without_changing_the_buffer() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let mut buffer = buffer_with_text("notes.txt", "old"); + let mut view = View::new(); + + app.handle_key(Key::Ctrl('x'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Ctrl('f'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Char('x'), &mut keymap, &mut buffer, &mut view); + let action = app.handle_key(Key::Escape, &mut keymap, &mut buffer, &mut view); + + assert_eq!(action, AppAction::Continue); + assert_eq!(app.command_line, None); + assert_eq!(app.status_message.as_deref(), Some("Find file canceled")); + assert_eq!(buffer.text(), "old"); + assert!(!buffer.is_dirty()); + } + + #[test] + fn find_file_preserves_whitespace_in_the_requested_path() { + let mut app = AppState::default(); + assert_eq!( - app.status_message.as_deref(), - Some("Open canceled: current buffer has unsaved changes") + app.submit_find_file(" leading and trailing.txt "), + AppAction::FindFile(PathBuf::from(" leading and trailing.txt ")) ); - assert_eq!(app.status_kind, Some(StatusKind::Prompt)); } #[test] - fn file_picker_starts_from_current_file_parent_or_current_directory() { + fn find_file_routes_existing_directories_to_the_picker() { + let dir = test_dir("find-file-directory"); + let first = dir.join("first.txt"); + fs::write(&first, "first").unwrap(); + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + let mut app = AppState::default(); + + assert_eq!( + apply_app_action(&mut editor, &mut app, AppAction::FindFile(dir.clone())), + AppControl::BrowseDirectory(dir.clone()) + ); + assert_eq!(editor.active().0.path(), first); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn ctrl_x_b_opens_an_editable_switch_buffer_prompt() { + let mut app = AppState::default(); + let mut keymap = Keymap::new(); + let mut buffer = buffer_with_text("notes.txt", "old"); + let mut view = View::new(); + + app.handle_key(Key::Ctrl('x'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Char('b'), &mut keymap, &mut buffer, &mut view); + for ch in "other.txt".chars() { + app.handle_key(Key::Char(ch), &mut keymap, &mut buffer, &mut view); + } + let action = app.handle_key(Key::Enter, &mut keymap, &mut buffer, &mut view); + + assert_eq!(action, AppAction::SwitchBuffer("other.txt".to_string())); + assert_eq!(buffer.text(), "old"); + assert!(!buffer.is_dirty()); + } + + #[test] + fn opening_and_switching_buffers_keeps_unsaved_edits() { + let dir = test_dir("app-buffer-list"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.active_mut().0.insert(0, "dirty "); + let mut app = AppState::default(); + assert_eq!( - picker_directory(PathBuf::from("/tmp/current.txt").as_path()), - PathBuf::from("/tmp") + apply_app_action(&mut editor, &mut app, AppAction::OpenFile(second.clone())), + AppControl::Continue ); + assert_eq!(editor.active().0.path(), second); assert_eq!( - picker_directory(PathBuf::from("current.txt").as_path()), - PathBuf::from(".") + apply_app_action( + &mut editor, + &mut app, + AppAction::SwitchBuffer("first.txt".to_string()) + ), + AppControl::Continue ); + assert_eq!(editor.active().0.text(), "dirty first"); + assert!(editor.active().0.is_dirty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn quit_warns_when_only_an_inactive_buffer_is_dirty() { + let dir = test_dir("inactive-dirty-quit"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.active_mut().0.insert(0, "dirty "); + editor.open(&second).unwrap(); + let mut app = AppState::default(); + + assert_eq!( + apply_app_action(&mut editor, &mut app, AppAction::Quit), + AppControl::Continue + ); + assert!(app.dirty_quit_prompt); + assert_eq!(app.status_message.as_deref(), Some(DIRTY_QUIT_PROMPT)); + fs::remove_dir_all(dir).unwrap(); } #[test] @@ -1125,12 +1331,12 @@ mod tests { app.handle_key(Key::Char('x'), &mut keymap, &mut buffer, &mut view); let action = run_slash_command("/quit!", &mut app, &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::Quit); + assert_eq!(action, AppAction::ForceQuit); assert!(buffer.is_dirty()); } #[test] - fn slash_open_replaces_buffer_and_resets_view() { + fn slash_open_requests_another_buffer_without_replacing_the_current_one() { let dir = test_dir("slash-open"); let current_path = dir.join("current.txt"); let target_path = dir.join("target.txt"); @@ -1145,15 +1351,10 @@ mod tests { let command = format!("/open {}", target_path.display()); let action = run_slash_command(&command, &mut app, &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::Continue); - assert_eq!(buffer.path(), target_path.as_path()); - assert_eq!(buffer.text(), "target"); - assert_eq!(view.point(), 0); - let expected_status = format!("Opened {}", target_path.display()); - assert_eq!( - app.status_message.as_deref(), - Some(expected_status.as_str()) - ); + assert_eq!(action, AppAction::OpenFile(target_path)); + assert_eq!(buffer.path(), current_path.as_path()); + assert_eq!(buffer.text(), "current"); + assert_eq!(view.point(), 1); fs::remove_dir_all(dir).unwrap(); } @@ -1182,7 +1383,7 @@ mod tests { } #[test] - fn slash_open_keeps_dirty_buffer_in_place() { + fn slash_open_allows_another_buffer_when_current_buffer_is_dirty() { let dir = test_dir("slash-open-dirty"); let current_path = dir.join("current.txt"); let target_path = dir.join("target.txt"); @@ -1197,13 +1398,9 @@ mod tests { let command = format!("/open {}", target_path.display()); let action = run_slash_command(&command, &mut app, &mut keymap, &mut buffer, &mut view); - assert_eq!(action, AppAction::Continue); + assert_eq!(action, AppAction::OpenFile(target_path)); assert_eq!(buffer.path(), current_path.as_path()); assert_eq!(buffer.text(), "xcurrent"); - assert_eq!( - app.status_message.as_deref(), - Some("Open canceled: current buffer has unsaved changes") - ); fs::remove_dir_all(dir).unwrap(); } diff --git a/src/buffer.rs b/src/buffer.rs index 4c68209..03d95e2 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -637,6 +637,7 @@ mod tests { } #[test] + #[allow(clippy::reversed_empty_ranges)] fn text_range_returns_the_requested_chars() { let dir = test_dir("text-range"); let path = dir.join("notes.txt"); diff --git a/src/commands.rs b/src/commands.rs index f392d46..4dbaa59 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -19,6 +19,7 @@ pub enum Command { RepeatSearch, SaveBuffer, SetMark, + SwitchBuffer, Undo, Yank, Quit, @@ -28,8 +29,6 @@ pub enum Command { pub struct CommandOutcome { pub quit: bool, pub dirty_quit_blocked: bool, - pub open_file_blocked: bool, - pub open_file_picker: bool, pub save_failed: bool, pub status_message: Option, } @@ -64,9 +63,12 @@ pub fn dispatch(command: Command, buffer: &mut Buffer, view: &mut View) -> Comma } CommandOutcome::default() } - Command::KillLine | Command::KillRegion | Command::SetMark | Command::Yank => { - CommandOutcome::default() - } + Command::KillLine + | Command::KillRegion + | Command::OpenFile + | Command::SetMark + | Command::SwitchBuffer + | Command::Yank => CommandOutcome::default(), Command::Undo => { if let Some(point) = buffer.undo() { view.set_point(point, buffer); @@ -104,15 +106,6 @@ pub fn dispatch(command: Command, buffer: &mut Buffer, view: &mut View) -> Comma view.move_to_line_end(buffer); CommandOutcome::default() } - Command::OpenFile if buffer.is_dirty() => CommandOutcome { - open_file_blocked: true, - status_message: Some("Open canceled: current buffer has unsaved changes".to_string()), - ..CommandOutcome::default() - }, - Command::OpenFile => CommandOutcome { - open_file_picker: true, - ..CommandOutcome::default() - }, Command::SaveBuffer => match buffer.save() { Ok(()) => CommandOutcome { status_message: Some(format!("Wrote {}", buffer.path().display())), @@ -313,26 +306,6 @@ mod tests { assert!(outcome.dirty_quit_blocked); } - #[test] - fn open_file_command_requests_picker_only_when_buffer_is_clean() { - let mut buffer = buffer_with_text("notes.txt", ""); - let mut view = View::new(); - - let outcome = dispatch(Command::OpenFile, &mut buffer, &mut view); - assert!(outcome.open_file_picker); - assert!(!outcome.quit); - - dispatch(Command::Insert('x'), &mut buffer, &mut view); - let outcome = dispatch(Command::OpenFile, &mut buffer, &mut view); - - assert!(outcome.open_file_blocked); - assert!(!outcome.open_file_picker); - assert_eq!( - outcome.status_message.as_deref(), - Some("Open canceled: current buffer has unsaved changes") - ); - } - fn buffer_with_text(file_name: &str, text: &str) -> Buffer { let dir = test_dir("commands"); let path = dir.join(file_name); diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..f9c0adf --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,335 @@ +use crate::{buffer::Buffer, view::View}; +use std::{ + ffi::OsString, + fs, io, + path::{Path, PathBuf}, +}; + +#[derive(Debug)] +struct BufferEntry { + buffer: Buffer, + view: View, + identity: PathBuf, +} + +#[derive(Debug)] +pub struct Editor { + buffers: Vec, + active: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OpenResult { + Opened, + AlreadyOpen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SwitchError { + Ambiguous, + NotFound, +} + +impl Editor { + pub fn new(buffer: Buffer) -> io::Result { + let identity = path_identity(buffer.path())?; + Ok(Self { + buffers: vec![BufferEntry { + buffer, + view: View::new(), + identity, + }], + active: 0, + }) + } + + pub fn active(&self) -> (&Buffer, &View) { + let entry = &self.buffers[self.active]; + (&entry.buffer, &entry.view) + } + + pub fn active_mut(&mut self) -> (&mut Buffer, &mut View) { + let entry = &mut self.buffers[self.active]; + (&mut entry.buffer, &mut entry.view) + } + + pub fn open(&mut self, path: &Path) -> io::Result { + let identity = path_identity(path)?; + if let Some(index) = self.index_for_identity(&identity) { + self.active = index; + return Ok(OpenResult::AlreadyOpen); + } + + self.buffers.push(BufferEntry { + buffer: Buffer::open(path)?, + view: View::new(), + identity, + }); + self.active = self.buffers.len() - 1; + Ok(OpenResult::Opened) + } + + pub fn switch_to(&mut self, name: &str) -> Result<(), SwitchError> { + let path_matches: Vec = self + .buffers + .iter() + .enumerate() + .filter_map(|(index, entry)| { + (entry.buffer.path().to_string_lossy() == name).then_some(index) + }) + .collect(); + + if let Some(index) = unique_match(&path_matches)? { + self.active = index; + return Ok(()); + } + + let file_name_matches: Vec = self + .buffers + .iter() + .enumerate() + .filter_map(|(index, entry)| { + entry + .buffer + .path() + .file_name() + .is_some_and(|file_name| file_name.to_string_lossy() == name) + .then_some(index) + }) + .collect(); + + let index = unique_match(&file_name_matches)?.ok_or(SwitchError::NotFound)?; + self.active = index; + Ok(()) + } + + pub fn any_dirty(&self) -> bool { + self.buffers.iter().any(|entry| entry.buffer.is_dirty()) + } + + pub fn names(&self) -> String { + self.buffers + .iter() + .map(|entry| entry.buffer.path().display().to_string()) + .collect::>() + .join(", ") + } + + fn index_for_identity(&self, identity: &Path) -> Option { + self.buffers + .iter() + .position(|entry| entry.identity == identity) + } +} + +fn unique_match(matches: &[usize]) -> Result, SwitchError> { + match matches { + [] => Ok(None), + [index] => Ok(Some(*index)), + _ => Err(SwitchError::Ambiguous), + } +} + +fn path_identity(path: &Path) -> io::Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut ancestor = absolute.as_path(); + let mut missing_components: Vec = Vec::new(); + + loop { + match fs::canonicalize(ancestor) { + Ok(mut canonical) => { + for component in missing_components.iter().rev() { + canonical.push(component); + } + return Ok(canonical); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let Some(file_name) = ancestor.file_name() else { + return Err(error); + }; + missing_components.push(file_name.to_os_string()); + let Some(parent) = ancestor.parent() else { + return Err(error); + }; + ancestor = parent; + } + Err(error) => return Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::{Editor, OpenResult, SwitchError}; + use crate::buffer::Buffer; + use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + time::{SystemTime, UNIX_EPOCH}, + }; + + static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0); + + #[test] + fn opening_and_switching_buffers_preserves_unsaved_text_and_view() { + let dir = test_dir("preserve"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + { + let (buffer, view) = editor.active_mut(); + buffer.insert(0, "dirty "); + view.set_point(3, buffer); + } + + assert_eq!(editor.open(&second).unwrap(), OpenResult::Opened); + assert_eq!(editor.active().0.text(), "second"); + + editor.switch_to("first.txt").unwrap(); + assert_eq!(editor.active().0.text(), "dirty first"); + assert!(editor.active().0.is_dirty()); + assert_eq!(editor.active().1.point(), 3); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn opening_an_existing_buffer_switches_without_reloading_it() { + let dir = test_dir("already-open"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.open(&second).unwrap(); + fs::write(&first, "changed on disk").unwrap(); + + assert_eq!(editor.open(&first).unwrap(), OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "first"); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn missing_relative_and_absolute_paths_share_one_buffer() { + let dir = test_dir("missing-alias"); + let first = dir.join("first.txt"); + fs::write(&first, "first").unwrap(); + let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let relative = PathBuf::from(format!( + "cortex-missing-alias-{}-{nanos}-{unique}.txt", + std::process::id() + )); + let absolute = std::env::current_dir().unwrap().join(&relative); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + assert_eq!(editor.open(&relative).unwrap(), OpenResult::Opened); + editor.active_mut().0.insert(0, "unsaved"); + assert_eq!(editor.open(&absolute).unwrap(), OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "unsaved"); + assert_eq!(editor.active().0.path(), relative); + assert!(!absolute.exists()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn missing_paths_open_as_empty_buffers_and_save_creates_them() { + let dir = test_dir("create"); + let first = dir.join("first.txt"); + let created = dir.join("created.txt"); + fs::write(&first, "first").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.open(&created).unwrap(); + assert_eq!(editor.active().0.text(), ""); + assert_eq!(editor.active().0.path(), created); + + editor.active_mut().0.insert(0, "new file"); + editor.active_mut().0.save().unwrap(); + assert_eq!(fs::read_to_string(&created).unwrap(), "new file"); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn dirty_state_is_reported_across_inactive_buffers() { + let dir = test_dir("dirty"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.active_mut().0.insert(0, "dirty "); + editor.open(&second).unwrap(); + + assert!(editor.any_dirty()); + assert!(!editor.active().0.is_dirty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn saving_writes_only_the_active_buffer() { + let dir = test_dir("save-active"); + let first = dir.join("first.txt"); + let second = dir.join("second.txt"); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.active_mut().0.insert(0, "dirty "); + editor.open(&second).unwrap(); + editor.active_mut().0.insert(0, "saved "); + editor.active_mut().0.save().unwrap(); + + assert_eq!(fs::read_to_string(&first).unwrap(), "first"); + assert_eq!(fs::read_to_string(&second).unwrap(), "saved second"); + assert!(editor.any_dirty()); + assert!(!editor.active().0.is_dirty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn switch_buffer_requires_a_unique_path_or_file_name() { + let dir = test_dir("switch"); + let first = dir.join("a").join("notes.txt"); + let second = dir.join("b").join("notes.txt"); + fs::create_dir_all(first.parent().unwrap()).unwrap(); + fs::create_dir_all(second.parent().unwrap()).unwrap(); + fs::write(&first, "first").unwrap(); + fs::write(&second, "second").unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + editor.open(&second).unwrap(); + + assert_eq!(editor.switch_to("notes.txt"), Err(SwitchError::Ambiguous)); + assert_eq!(editor.switch_to(&first.to_string_lossy()), Ok(())); + assert_eq!(editor.active().0.text(), "first"); + assert_eq!(editor.switch_to("missing.txt"), Err(SwitchError::NotFound)); + fs::remove_dir_all(dir).unwrap(); + } + + fn test_dir(label: &str) -> PathBuf { + let unique = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "cortex-editor-{label}-{}-{nanos}-{unique}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + path + } +} diff --git a/src/keymap.rs b/src/keymap.rs index 5a77c05..355a4c6 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -63,6 +63,7 @@ fn resolve_prefixed(prefix: Prefix, key: Key) -> KeymapResult { (Prefix::CtrlX, Key::Ctrl('s')) => KeymapResult::Command(Command::SaveBuffer), (Prefix::CtrlX, Key::Ctrl('c')) => KeymapResult::Command(Command::Quit), (Prefix::CtrlX, Key::Ctrl('f')) => KeymapResult::Command(Command::OpenFile), + (Prefix::CtrlX, Key::Char('b')) => KeymapResult::Command(Command::SwitchBuffer), (Prefix::CtrlX, Key::Char('u')) => KeymapResult::Command(Command::Undo), _ => KeymapResult::Unbound, } @@ -199,6 +200,12 @@ mod tests { KeymapResult::Command(Command::OpenFile) ); + assert_eq!(keymap.resolve(Key::Ctrl('x')), KeymapResult::PendingPrefix); + assert_eq!( + keymap.resolve(Key::Char('b')), + KeymapResult::Command(Command::SwitchBuffer) + ); + assert_eq!(keymap.resolve(Key::Ctrl('x')), KeymapResult::PendingPrefix); assert_eq!( keymap.resolve(Key::Char('u')), diff --git a/src/main.rs b/src/main.rs index 3939d4f..e72d797 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; pub mod buffer; mod cli; mod commands; +mod editor; pub mod highlighter; mod input; mod keymap; From a186f6fe64aec83ebc4a39e4482c98b1308f6dde Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 12 Jul 2026 16:13:04 +0100 Subject: [PATCH 2/5] fix(editor): resolve buffer path aliases (#30) --- src/editor.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/editor.rs b/src/editor.rs index f9c0adf..02e8b19 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -84,6 +84,13 @@ impl Editor { return Ok(()); } + if let Ok(identity) = path_identity(Path::new(name)) { + if let Some(index) = self.index_for_identity(&identity) { + self.active = index; + return Ok(()); + } + } + let file_name_matches: Vec = self .buffers .iter() @@ -308,11 +315,16 @@ mod tests { fs::create_dir_all(second.parent().unwrap()).unwrap(); fs::write(&first, "first").unwrap(); fs::write(&second, "second").unwrap(); + let first_alias_dir = dir.join("first-alias"); + std::os::unix::fs::symlink(first.parent().unwrap(), &first_alias_dir).unwrap(); + let first_alias = first_alias_dir.join("notes.txt"); let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); editor.open(&second).unwrap(); assert_eq!(editor.switch_to("notes.txt"), Err(SwitchError::Ambiguous)); + assert_eq!(editor.switch_to(&first_alias.to_string_lossy()), Ok(())); + assert_eq!(editor.active().0.text(), "first"); assert_eq!(editor.switch_to(&first.to_string_lossy()), Ok(())); assert_eq!(editor.active().0.text(), "first"); assert_eq!(editor.switch_to("missing.txt"), Err(SwitchError::NotFound)); From 540dd69ff2fe9c723283faf75502bfac8624ff74 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 12 Jul 2026 16:26:27 +0100 Subject: [PATCH 3/5] fix(editor): respect macOS path case rules (#30) --- Cargo.lock | 33 ++++++++++++++++ Cargo.toml | 3 ++ src/editor.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d04ec8..efeb6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,7 @@ name = "cortex" version = "0.1.0" dependencies = [ "crossterm", + "libc", "ropey", "tree-sitter", "tree-sitter-highlight", @@ -50,6 +51,8 @@ dependencies = [ "tree-sitter-rust", "tree-sitter-toml-ng", "tree-sitter-typescript", + "unicode-casefold", + "unicode-normalization", ] [[package]] @@ -384,6 +387,21 @@ dependencies = [ "syn", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tree-sitter" version = "0.26.9" @@ -506,12 +524,27 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "unicode-casefold" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index a13f7e2..a2e429a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ description = "A fast, lightweight, macOS-only terminal code editor." [dependencies] crossterm = "0.27" +libc = "0.2" ropey = "1.6" tree-sitter = "0.26.9" tree-sitter-highlight = "0.26.9" @@ -20,3 +21,5 @@ tree-sitter-ruby = "0.23.1" tree-sitter-rust = "0.24.2" tree-sitter-toml-ng = "0.7.0" tree-sitter-typescript = "0.23.2" +unicode-casefold = "0.2.0" +unicode-normalization = "0.1.25" diff --git a/src/editor.rs b/src/editor.rs index 02e8b19..085bb53 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,9 +1,12 @@ use crate::{buffer::Buffer, view::View}; use std::{ - ffi::OsString, + ffi::{CString, OsStr, OsString}, fs, io, + os::unix::ffi::OsStrExt, path::{Path, PathBuf}, }; +use unicode_casefold::UnicodeCaseFold; +use unicode_normalization::UnicodeNormalization; #[derive(Debug)] struct BufferEntry { @@ -149,8 +152,9 @@ fn path_identity(path: &Path) -> io::Result { loop { match fs::canonicalize(ancestor) { Ok(mut canonical) => { + let case_sensitive = volume_is_case_sensitive(&canonical)?; for component in missing_components.iter().rev() { - canonical.push(component); + canonical.push(normalize_missing_component(component, case_sensitive)); } return Ok(canonical); } @@ -169,6 +173,36 @@ fn path_identity(path: &Path) -> io::Result { } } +fn volume_is_case_sensitive(path: &Path) -> io::Result { + let path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "file path contains an interior NUL byte", + ) + })?; + + // SAFETY: `path` is a valid NUL-terminated C string, and `_PC_CASE_SENSITIVE` + // is a read-only query supported by macOS for existing paths. + let result = unsafe { libc::pathconf(path.as_ptr(), libc::_PC_CASE_SENSITIVE) }; + match result { + 0 => Ok(false), + 1 => Ok(true), + _ => Err(io::Error::last_os_error()), + } +} + +fn normalize_missing_component(component: &OsStr, case_sensitive: bool) -> OsString { + if case_sensitive { + component.to_os_string() + } else { + component + .to_str() + .map(|text| text.nfd().case_fold().nfd().collect::()) + .map(OsString::from) + .unwrap_or_else(|| component.to_os_string()) + } +} + #[cfg(test)] mod tests { use super::{Editor, OpenResult, SwitchError}; @@ -250,6 +284,71 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn missing_path_case_identity_follows_the_volume() { + let dir = test_dir("missing-case"); + let first = dir.join("first.txt"); + let upper = dir.join("Future.swift"); + let lower = dir.join("future.swift"); + fs::write(&first, "first").unwrap(); + let case_sensitive = super::volume_is_case_sensitive(&dir).unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + assert_eq!(editor.open(&upper).unwrap(), OpenResult::Opened); + editor.active_mut().0.insert(0, "unsaved"); + let result = editor.open(&lower).unwrap(); + + if case_sensitive { + assert_eq!(result, OpenResult::Opened); + assert_eq!(editor.active().0.text(), ""); + } else { + assert_eq!(result, OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "unsaved"); + } + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn missing_component_case_folding_is_volume_aware() { + let component = std::ffi::OsStr::new("Future-é-Σ.swift"); + assert_eq!( + super::normalize_missing_component(component, false), + "future-e\u{301}-σ.swift" + ); + assert_eq!( + super::normalize_missing_component(component, true), + "Future-é-Σ.swift" + ); + } + + #[test] + fn missing_unicode_aliases_share_a_buffer_on_case_insensitive_volumes() { + let dir = test_dir("missing-unicode-case"); + let first = dir.join("first.txt"); + fs::write(&first, "first").unwrap(); + let case_sensitive = super::volume_is_case_sensitive(&dir).unwrap(); + let aliases = [ + (dir.join("é.swift"), dir.join("e\u{301}.swift")), + (dir.join("Σ.swift"), dir.join("ς.swift")), + ]; + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + for (left, right) in aliases { + assert_eq!(editor.open(&left).unwrap(), OpenResult::Opened); + editor.active_mut().0.insert(0, "unsaved"); + let result = editor.open(&right).unwrap(); + + if case_sensitive { + assert_eq!(result, OpenResult::Opened); + assert_eq!(editor.active().0.text(), ""); + } else { + assert_eq!(result, OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "unsaved"); + } + } + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn missing_paths_open_as_empty_buffers_and_save_creates_them() { let dir = test_dir("create"); From c0788df9337dad9720965361477fb02e55b35865 Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 12 Jul 2026 16:35:03 +0100 Subject: [PATCH 4/5] fix(editor): keep saved file identity stable (#30) --- src/editor.rs | 69 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 085bb53..b519e19 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3,7 +3,7 @@ use std::{ ffi::{CString, OsStr, OsString}, fs, io, os::unix::ffi::OsStrExt, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, }; use unicode_casefold::UnicodeCaseFold; use unicode_normalization::UnicodeNormalization; @@ -151,12 +151,13 @@ fn path_identity(path: &Path) -> io::Result { loop { match fs::canonicalize(ancestor) { - Ok(mut canonical) => { + Ok(canonical) => { + let mut identity = normalize_existing_path(&canonical)?; let case_sensitive = volume_is_case_sensitive(&canonical)?; for component in missing_components.iter().rev() { - canonical.push(normalize_missing_component(component, case_sensitive)); + identity.push(normalize_path_component(component, case_sensitive)); } - return Ok(canonical); + return Ok(identity); } Err(error) if error.kind() == io::ErrorKind::NotFound => { let Some(file_name) = ancestor.file_name() else { @@ -173,6 +174,36 @@ fn path_identity(path: &Path) -> io::Result { } } +fn normalize_existing_path(path: &Path) -> io::Result { + let mut actual_parent = PathBuf::new(); + let mut identity = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(prefix) => { + actual_parent.push(prefix.as_os_str()); + identity.push(prefix.as_os_str()); + } + Component::RootDir => { + actual_parent.push(Path::new("/")); + identity.push(Path::new("/")); + } + Component::CurDir => {} + Component::ParentDir => { + actual_parent.pop(); + identity.pop(); + } + Component::Normal(part) => { + let case_sensitive = volume_is_case_sensitive(&actual_parent)?; + identity.push(normalize_path_component(part, case_sensitive)); + actual_parent.push(part); + } + } + } + + Ok(identity) +} + fn volume_is_case_sensitive(path: &Path) -> io::Result { let path = CString::new(path.as_os_str().as_bytes()).map_err(|_| { io::Error::new( @@ -191,7 +222,7 @@ fn volume_is_case_sensitive(path: &Path) -> io::Result { } } -fn normalize_missing_component(component: &OsStr, case_sensitive: bool) -> OsString { +fn normalize_path_component(component: &OsStr, case_sensitive: bool) -> OsString { if case_sensitive { component.to_os_string() } else { @@ -312,11 +343,11 @@ mod tests { fn missing_component_case_folding_is_volume_aware() { let component = std::ffi::OsStr::new("Future-é-Σ.swift"); assert_eq!( - super::normalize_missing_component(component, false), + super::normalize_path_component(component, false), "future-e\u{301}-σ.swift" ); assert_eq!( - super::normalize_missing_component(component, true), + super::normalize_path_component(component, true), "Future-é-Σ.swift" ); } @@ -349,6 +380,30 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn saved_missing_file_keeps_the_same_buffer_identity() { + let dir = test_dir("saved-missing-identity"); + let first = dir.join("first.txt"); + let created = dir.join("Future.swift"); + let case_alias = dir.join("future.swift"); + fs::write(&first, "first").unwrap(); + let case_sensitive = super::volume_is_case_sensitive(&dir).unwrap(); + + let mut editor = Editor::new(Buffer::open(&first).unwrap()).unwrap(); + assert_eq!(editor.open(&created).unwrap(), OpenResult::Opened); + editor.active_mut().0.insert(0, "saved"); + editor.active_mut().0.save().unwrap(); + editor.active_mut().0.insert(0, "unsaved "); + + assert_eq!(editor.open(&created).unwrap(), OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "unsaved saved"); + if !case_sensitive { + assert_eq!(editor.open(&case_alias).unwrap(), OpenResult::AlreadyOpen); + assert_eq!(editor.active().0.text(), "unsaved saved"); + } + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn missing_paths_open_as_empty_buffers_and_save_creates_them() { let dir = test_dir("create"); From 564f70c84a362be383bf29e30b1480a2af7dc59f Mon Sep 17 00:00:00 2001 From: Owain Lewis Date: Sun, 12 Jul 2026 16:42:46 +0100 Subject: [PATCH 5/5] fix(editor): resolve find-file from buffer directory (#30) --- src/app.rs | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 02babc2..6b06668 100644 --- a/src/app.rs +++ b/src/app.rs @@ -415,7 +415,7 @@ impl AppState { let input = self.command_line.take().unwrap_or_default(); match self.prompt_kind.take().unwrap_or(PromptKind::Command) { PromptKind::Command => self.run_command_line(&input, buffer, view), - PromptKind::FindFile => self.submit_find_file(&input), + PromptKind::FindFile => self.submit_find_file(&input, buffer.path()), PromptKind::SwitchBuffer => self.submit_switch_buffer(&input), } } @@ -433,13 +433,22 @@ impl AppState { } } - fn submit_find_file(&mut self, input: &str) -> AppAction { + fn submit_find_file(&mut self, input: &str, active_path: &Path) -> AppAction { if input.trim().is_empty() { self.set_status("Find file requires a path", StatusKind::Error); return AppAction::Continue; } - AppAction::FindFile(PathBuf::from(input)) + let path = PathBuf::from(input); + if path.is_absolute() { + AppAction::FindFile(path) + } else { + let directory = active_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + AppAction::FindFile(directory.join(path)) + } } fn submit_switch_buffer(&mut self, input: &str) -> AppAction { @@ -734,7 +743,7 @@ mod tests { }; use std::{ fs, - path::PathBuf, + path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, time::{SystemTime, UNIX_EPOCH}, }; @@ -1113,8 +1122,25 @@ mod tests { let mut app = AppState::default(); assert_eq!( - app.submit_find_file(" leading and trailing.txt "), - AppAction::FindFile(PathBuf::from(" leading and trailing.txt ")) + app.submit_find_file( + " leading and trailing.txt ", + Path::new("/tmp/project/current.txt") + ), + AppAction::FindFile(PathBuf::from("/tmp/project/ leading and trailing.txt ")) + ); + } + + #[test] + fn find_file_resolves_relative_paths_from_the_active_buffer_directory() { + let mut app = AppState::default(); + + assert_eq!( + app.submit_find_file("sibling.rs", Path::new("/tmp/project/src/main.rs")), + AppAction::FindFile(PathBuf::from("/tmp/project/src/sibling.rs")) + ); + assert_eq!( + app.submit_find_file("/tmp/absolute.rs", Path::new("/tmp/project/src/main.rs")), + AppAction::FindFile(PathBuf::from("/tmp/absolute.rs")) ); }