diff --git a/README.md b/README.md index 420cf06..c6b4918 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ dimmed. The keys also work without opening the menu. | Where | Keys | |---|---| -| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `q` quit | +| anywhere | `Tab` cycle tree · document · notes; `E` send; `t` tree; `r` reload; `o` open the file in `$EDITOR`; `q` quit | | reply review | `S` send and close | | document | `j`/`k` block; `c` comment on the block; `x` clear its annotations; `X` clear every annotation on the document, after a `y`; `v` select with `hjkl` `w` `b` `0` `$`, `Enter` to finish; `i` move the cursor with those keys first, then `v` to select from there | | toolbar | `a` looks good · `c` comment · `d` delete · `Esc` | @@ -99,6 +99,13 @@ dimmed. The keys also work without opening the menu. These are the defaults; every key can be rebound under `[keys]` (see [Keys](#keys)). +`o` hands the terminal to `$VISUAL` (or `$EDITOR`, which may carry flags such as `code -w`) +on the open file, at the current line for vim, nvim, nano, helix, emacs, micro and kak. When +the editor exits the viewer comes back and, if the file changed, reloads it as `r` does so +notes re-anchor. A GUI editor without its wait flag returns at once; save later and the +footer says the file changed. Reply and terminal reviews have no file, so `o` does nothing +there. + Hidden folders are out of the tree until `.` asks for them, and `.git`, `.hg`, `.svn`, `.jj`, `.cache`, `.direnv`, `.venv`, `venv`, `__pycache__`, `node_modules`, `vendor`, `target`, `build`, `dist` and `out` stay out either way — none of them is read at all. @@ -224,6 +231,7 @@ screen show your keys. `plannotator-tui config` prints every action with its key | `cycle-focus` | `Tab` | tree · document · notes | | `send` `send-and-close` | `E` `S` | send; send and close a reply review | | `toggle-tree` `reload` `pick-message` | `t` `r` `p` | show/hide the tree; reload; choose another reply | +| `open-in-editor` | `o` | edit the open file in `$VISUAL` or `$EDITOR`, then reload it | | `quick-open` | `ctrl+p` | find a file by name in a folder review | | `review-menu` | `m` | the Review menu | | `resend-all` `finish-review` `undo-finish` `archive` | `R` `F` `U` `H` | file and folder review actions | diff --git a/crates/plannotator-tui/src/app/editor.rs b/crates/plannotator-tui/src/app/editor.rs new file mode 100644 index 0000000..4dc7045 --- /dev/null +++ b/crates/plannotator-tui/src/app/editor.rs @@ -0,0 +1,211 @@ +//! `open-in-editor`: hand the reviewed file to the user's editor and reload it on return. +//! +//! The app only asks; the event loop owns the terminal, so it suspends the screen, runs the +//! editor and reports back through `editor_returned`. A file that did not change is left +//! alone, so a GUI editor that returns at once does not cost a reload: the disk check says +//! "File changed on disk" when it saves later, as it does for any other writer. + +use std::process::ExitStatus; + +use plannotator_tui_schema::Provenance; + +use super::App; +use super::disk::DiskStamp; +use crate::editor::{self, EditorRequest}; + +impl App { + /// Ask the event loop to open the document in the editor, or say why it cannot. + pub(super) fn request_editor(&mut self) { + let Provenance::File { path } = &self.open.source.provenance else { + self.status = Some("not a file on disk; nothing to open in an editor".into()); + return; + }; + let Some(editor) = self.editor.clone() else { + self.status = Some("set $EDITOR (or $VISUAL) to open the file in your editor".into()); + return; + }; + if editor::split(&editor).is_none() { + self.status = Some(format!("cannot read the editor command {editor:?}: a quote is left open")); + return; + } + self.editor_request = Some(EditorRequest { editor, path: path.clone(), line: self.current_line() }); + } + + /// Whether a keypress asked for the editor; the event loop stops reading input then. + pub(crate) fn wants_editor(&self) -> bool { + self.editor_request.is_some() + } + + pub(crate) fn take_editor_request(&mut self) -> Option { + self.editor_request.take() + } + + /// The editor is done (or never started). Reload when the file changed, so notes + /// re-anchor the way `reload` does; otherwise only say what happened. + pub(crate) fn editor_returned(&mut self, request: &EditorRequest, outcome: std::io::Result) { + let name = editor::split(&request.editor) + .and_then(|words| words.first().map(|program| editor::program_name(program))) + .unwrap_or_else(|| request.editor.clone()); + let exit = match outcome { + Err(error) => { + self.status = Some(format!("could not start {name}: {error}")); + return; + } + Ok(status) if status.success() => None, + Ok(status) => Some(match status.code() { + Some(code) => format!("{name} exited with {code}"), + None => format!("{name} was stopped"), + }), + }; + let still_open = + matches!(&self.open.source.provenance, Provenance::File { path } if *path == request.path); + let changed = still_open && DiskStamp::of(&request.path) != self.open.disk; + let mut status = if !changed { + format!("no changes from {name}") + } else if DiskStamp::of(&request.path).is_none() { + format!("{} is gone after {name}; nothing reloaded", request.path.display()) + } else { + match self.reload() { + Ok(()) => self.status.take().unwrap_or_else(|| "reloaded".into()), + Err(error) => format!("reloading after {name}: {error:#}"), + } + }; + if let Some(exit) = exit { + status = format!("{status} · {exit}"); + } + self.status = Some(status); + } + + /// The 1-based source line the reviewer is on: the start of a pending selection, the + /// roaming cursor's line, or the selected block's first line. + fn current_line(&self) -> usize { + let roaming = + || self.open.layout.row(self.cursor.0).and_then(|row| row.cells.iter().find_map(|cell| *cell)); + let block = || self.open.doc.blocks.get(self.selected).map(|b| b.range.start); + let offset = match &self.pending { + Some(pending) => Some(pending.range.start), + None if self.roam => roaming().or_else(block), + None => block(), + }; + let before = offset.and_then(|offset| self.open.doc.source.get(..offset)).unwrap_or(""); + before.matches('\n').count() + 1 + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "tests assert by panicking")] + + use std::path::Path; + use std::time::Duration; + + use crate::app::review_test_support::{file_app, press, reply_app}; + + #[cfg(unix)] + fn exited(code: i32) -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt as _; + std::process::ExitStatus::from_raw(code << 8) + } + + #[cfg(windows)] + fn exited(code: i32) -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt as _; + std::process::ExitStatus::from_raw(code.cast_unsigned()) + } + + /// What an editor would do: new content, and a later mtime so the change is visible + /// even on a filesystem with coarse timestamps. + fn save(root: &Path, content: &str) { + let path = root.join("docs/a.md"); + std::fs::write(&path, content).expect("rewrite"); + let loaded = std::fs::metadata(&path).and_then(|meta| meta.modified()).expect("mtime"); + let later = loaded + Duration::from_secs(5); + std::fs::File::options().write(true).open(&path).expect("open").set_modified(later).expect("mtime"); + } + + #[test] + fn without_an_editor_the_key_says_to_set_one() { + let (root, mut app, _) = file_app("editor-unset"); + press(&mut app, 'o'); + assert!(!app.wants_editor()); + assert_eq!(app.status.as_deref(), Some("set $EDITOR (or $VISUAL) to open the file in your editor")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_reply_review_has_no_file_to_open() { + let (root, mut app, _) = reply_app("editor-reply"); + app.editor = Some("vim".into()); + press(&mut app, 'o'); + assert!(!app.wants_editor()); + assert_eq!(app.status.as_deref(), Some("not a file on disk; nothing to open in an editor")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn the_request_names_the_file_and_the_selected_line() { + let (root, mut app, _) = file_app("editor-line"); + app.editor = Some("nvim -u x".into()); + press(&mut app, 'j'); + press(&mut app, 'j'); + press(&mut app, 'o'); + let request = app.take_editor_request().expect("request"); + assert_eq!(request.path, root.join("docs/a.md")); + assert_eq!(request.editor, "nvim -u x"); + // `# Plan`, blank, `one`, blank, `two`: the third block starts on line 5. + assert_eq!(request.line, 5); + assert!(!app.wants_editor()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_saved_file_reloads_and_reanchors_the_notes() { + let (root, mut app, _) = file_app("editor-saved"); + app.editor = Some("vim".into()); + app.add_quote_annotation("two", plannotator_tui_schema::Kind::Comment, "check".into()).expect("note"); + press(&mut app, 'o'); + let request = app.take_editor_request().expect("request"); + save(&root, "# Plan\n\nzero\n\none\n\ntwo\n\nthree\n"); + app.editor_returned(&request, Ok(exited(0))); + assert!(app.open.doc.source.contains("zero")); + assert_eq!(app.open.store.orphans(), 0); + assert_eq!(app.status.as_deref(), Some("reloaded · 0 orphaned")); + // The disk check does not report the editor's own save afterwards. + assert!(!app.check_disk()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn an_unchanged_file_is_not_reloaded_and_a_later_save_is_still_noticed() { + let (root, mut app, _) = file_app("editor-unchanged"); + app.editor = Some("code".into()); + press(&mut app, 'o'); + let request = app.take_editor_request().expect("request"); + app.editor_returned(&request, Ok(exited(0))); + assert_eq!(app.status.as_deref(), Some("no changes from code")); + // A GUI editor that returned at once saves later: the usual hint covers it. + save(&root, "# Plan\n\nedited later\n"); + assert!(app.check_disk()); + assert_eq!(app.status.as_deref(), Some("File changed on disk. Press r to reload.")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn a_failed_editor_says_so_and_still_reloads_what_it_saved() { + let (root, mut app, _) = file_app("editor-failed"); + app.editor = Some("/usr/bin/vim -u NONE".into()); + press(&mut app, 'o'); + let request = app.take_editor_request().expect("request"); + save(&root, "# Plan\n\nsaved before quitting\n"); + app.editor_returned(&request, Ok(exited(1))); + assert_eq!(app.status.as_deref(), Some("reloaded · 0 orphaned · vim exited with 1")); + + app.editor_returned(&request, Err(std::io::Error::from(std::io::ErrorKind::NotFound))); + assert!( + app.status.as_deref().is_some_and(|s| s.starts_with("could not start vim: ")), + "{:?}", + app.status + ); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 2f5f0d0..b925557 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -109,6 +109,10 @@ impl App { return Ok(()); } Some(Action::Reload) => return self.reload(), + Some(Action::OpenInEditor) => { + self.request_editor(); + return Ok(()); + } Some(Action::PickMessage) => { self.reopen_picker(); return Ok(()); diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 44b7c1a..debd3a7 100644 --- a/crates/plannotator-tui/src/app/mod.rs +++ b/crates/plannotator-tui/src/app/mod.rs @@ -5,6 +5,7 @@ mod archive_view; mod compose; mod disk; mod draw; +mod editor; mod feedback; mod file_search; mod header; @@ -203,6 +204,11 @@ pub(crate) struct App { /// Whether the terminal reports Shift+Enter distinctly (kitty keyboard protocol). pub(super) shift_enter: bool, pub(crate) keys: Keymap, + /// `$VISUAL` or `$EDITOR`, read once at startup; `None` leaves `open-in-editor` asking + /// for one. + pub(crate) editor: Option, + /// Set by `open-in-editor` for the event loop, which owns the terminal, to act on. + editor_request: Option, /// `[ui]` from the config: the centered viewport and the document width. pub(crate) ui: UiConfig, /// The last primary-button press, for double-click detection. @@ -276,6 +282,8 @@ impl App { compose: Compose::default(), shift_enter: false, keys: Keymap::default(), + editor: None, + editor_request: None, ui: UiConfig::default(), last_click: None, geometry: Geometry::default(), diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index c12a028..4d565a3 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -343,37 +343,79 @@ pub(crate) fn run_ui(build: impl FnOnce(usize) -> Result) -> Result<()> { ); let mut terminal = ratatui::init(); execute!(stdout(), EnableMouseCapture)?; - let _ = execute!(stdout(), EnableBracketedPaste); // Shift+Enter is only distinguishable from Enter where the kitty keyboard protocol // holds end to end (terminal, and any multiplexer in between). Ask for it; the flag // gates the compose box's hint so it never advertises a key that cannot arrive. let shift_enter = ratatui::crossterm::terminal::supports_keyboard_enhancement().unwrap_or(false); - if shift_enter { - let _ = execute!( - stdout(), - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) - ); - } + claim_input(shift_enter); let width = doc_width(terminal.size().map_or(120, |s| s.width)); let result = build(width).and_then(|mut app| { app.shift_enter = shift_enter; app.keys = config.keys; app.ui = config.ui; + app.editor = crate::editor::from_env(|key| std::env::var(key).ok()); event_loop(&mut terminal, app) }); + release_input(shift_enter); + ratatui::restore(); + result +} + +/// Bracketed paste and, where the terminal has it, the kitty keyboard protocol. Mouse +/// capture is the caller's: the first claim turns it on before the keyboard query. +fn claim_input(shift_enter: bool) { + let _ = execute!(stdout(), EnableBracketedPaste); + if shift_enter { + let _ = execute!( + stdout(), + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + } +} + +/// Undo `claim_input` and mouse capture, in reverse order. +fn release_input(shift_enter: bool) { if shift_enter { let _ = execute!(stdout(), PopKeyboardEnhancementFlags); } let _ = execute!(stdout(), DisableBracketedPaste); let _ = execute!(stdout(), DisableMouseCapture); +} + +/// Give the terminal to the editor until it exits, then take the screen back. The outer +/// error is the terminal failing to come back, which ends the app; the inner one is the +/// editor failing to start, which the app reports and survives. +fn run_editor( + terminal: &mut ratatui::DefaultTerminal, + shift_enter: bool, + request: &crate::editor::EditorRequest, +) -> Result> { + use ratatui::crossterm::{cursor, terminal as term}; + release_input(shift_enter); ratatui::restore(); - result + let _ = execute!(stdout(), cursor::Show); + let outcome = match crate::editor::command(request) { + Some(mut command) => command.status(), + None => Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the editor command is empty")), + }; + term::enable_raw_mode().context("taking the terminal back from the editor")?; + execute!(stdout(), term::EnterAlternateScreen, EnableMouseCapture) + .context("taking the terminal back from the editor")?; + claim_input(shift_enter); + // The editor drew over the screen ratatui remembers; draw the next frame in full. + terminal.clear()?; + Ok(outcome) } fn event_loop(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<()> { app.clipboard = true; let mut dirty = true; while !app.quit { + if let Some(request) = app.take_editor_request() { + let outcome = run_editor(terminal, app.shift_enter, &request)?; + app.editor_returned(&request, outcome); + dirty = true; + } dirty |= app.poll_file_search(); dirty |= app.poll_disk(); if dirty { @@ -387,7 +429,8 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<( app.handle_event(&event::read()?)?; dirty = true; // Coalesce bursts (wheel, drag) into one redraw. - while event::poll(Duration::ZERO)? { + // Keys typed after `open-in-editor` belong to the editor, not to this loop. + while !app.wants_editor() && event::poll(Duration::ZERO)? { app.handle_event(&event::read()?)?; } } diff --git a/crates/plannotator-tui/src/editor.rs b/crates/plannotator-tui/src/editor.rs new file mode 100644 index 0000000..da636ed --- /dev/null +++ b/crates/plannotator-tui/src/editor.rs @@ -0,0 +1,169 @@ +//! The user's own editor, for changing the reviewed file by hand: which one `$VISUAL` or +//! `$EDITOR` names, and the command that opens a file in it. The viewer hands the terminal +//! over while it runs (see `cli::run_editor`) and reloads the file when it exits. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Editors that open a file at a line when it is preceded by `+N`. +const PLUS_LINE: &[&str] = + &["vi", "vim", "nvim", "gvim", "nano", "hx", "helix", "emacs", "emacsclient", "micro", "kak"]; + +/// A request to open `path` in `editor` (the raw `$VISUAL`/`$EDITOR` value) at `line`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EditorRequest { + pub(crate) editor: String, + pub(crate) path: PathBuf, + /// 1-based line of the document the reviewer was on. + pub(crate) line: usize, +} + +/// `$VISUAL`, then `$EDITOR`; a blank value counts as unset. +pub(crate) fn from_env(var: impl Fn(&str) -> Option) -> Option { + ["VISUAL", "EDITOR"].into_iter().filter_map(var).find(|value| !value.trim().is_empty()) +} + +/// Split an editor value into words the way a shell would for the simple cases people +/// write: whitespace separates, single and double quotes group, and a backslash escapes a +/// quote, a backslash or a space. Any other backslash is literal, so a Windows path keeps +/// its separators. `None` when the value is empty or a quote is left open. +pub(crate) fn split(value: &str) -> Option> { + let mut words = Vec::new(); + let mut word: Option = None; + let mut quote: Option = None; + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some('\''), c) => word.get_or_insert_default().push(c), + (_, '\\') if chars.peek().is_some_and(|next| matches!(next, '"' | '\'' | '\\' | ' ' | '\t')) => { + word.get_or_insert_default().extend(chars.next()); + } + (None, '"' | '\'') => { + quote = Some(c); + word.get_or_insert_default(); + } + (None, c) if c.is_whitespace() => words.extend(word.take()), + (_, c) => word.get_or_insert_default().push(c), + } + } + if quote.is_some() { + return None; + } + words.extend(word); + (!words.is_empty()).then_some(words) +} + +/// The program's name without its directory or a Windows extension, for display and for +/// telling which editors take `+N`. +pub(crate) fn program_name(program: &str) -> String { + let name = Path::new(program).file_name().map_or_else(|| program.into(), |n| n.to_string_lossy()); + let lower = name.to_lowercase(); + lower.strip_suffix(".exe").or_else(|| lower.strip_suffix(".cmd")).unwrap_or(&lower).to_owned() +} + +/// The program and arguments that open the request's file: the editor's own words, `+N` +/// when the editor understands it, then the path. `None` when the editor value is empty +/// or unbalanced. +pub(crate) fn argv(request: &EditorRequest) -> Option> { + let mut words = split(&request.editor)?; + let program = words.first().map(|program| program_name(program))?; + if PLUS_LINE.contains(&program.as_str()) { + words.push(format!("+{}", request.line.max(1))); + } + words.push(request.path.to_string_lossy().into_owned()); + Some(words) +} + +/// The command to run, inheriting this terminal's stdin, stdout and stderr. +pub(crate) fn command(request: &EditorRequest) -> Option { + let words = argv(request)?; + let (program, args) = words.split_first()?; + let mut command = Command::new(program); + command.args(args); + Some(command) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(editor: &str, line: usize) -> EditorRequest { + EditorRequest { editor: editor.into(), path: PathBuf::from("/notes/plan.md"), line } + } + + fn owned(words: &[&str]) -> Vec { + words.iter().map(ToString::to_string).collect() + } + + #[test] + fn visual_wins_over_editor_and_a_blank_value_is_unset() { + let env = |visual: Option<&str>, editor: Option<&str>| { + from_env(|key| match key { + "VISUAL" => visual.map(str::to_owned), + "EDITOR" => editor.map(str::to_owned), + _ => None, + }) + }; + assert_eq!(env(Some("code -w"), Some("vim")).as_deref(), Some("code -w")); + assert_eq!(env(None, Some("vim")).as_deref(), Some("vim")); + assert_eq!(env(Some(" "), Some("nano")).as_deref(), Some("nano")); + assert_eq!(env(None, Some("")), None); + assert_eq!(env(None, None), None); + } + + #[test] + fn an_editor_value_splits_into_words_with_quotes_grouping() { + assert_eq!(split("code -w"), Some(owned(&["code", "-w"]))); + assert_eq!(split(" nvim -u x "), Some(owned(&["nvim", "-u", "x"]))); + assert_eq!( + split("'/Applications/My Editor/bin/ed' --wait"), + Some(owned(&["/Applications/My Editor/bin/ed", "--wait"])) + ); + assert_eq!( + split(r#""C:\Program Files\Notepad++\notepad++.exe" -multiInst"#), + Some(owned(&[r"C:\Program Files\Notepad++\notepad++.exe", "-multiInst"])) + ); + assert_eq!(split(r"my\ editor -f"), Some(owned(&["my editor", "-f"]))); + assert_eq!(split(r#"vim -c "set tw=0""#), Some(owned(&["vim", "-c", "set tw=0"]))); + assert_eq!(split(r#"ed """#), Some(owned(&["ed", ""]))); + } + + #[test] + fn an_empty_or_unbalanced_value_is_no_editor() { + assert_eq!(split(""), None); + assert_eq!(split(" "), None); + assert_eq!(split("vim 'unclosed"), None); + assert_eq!(argv(&request("\"code", 3)), None); + } + + #[test] + fn editors_that_take_plus_n_open_at_the_line() { + assert_eq!( + argv(&request("nvim -u x", 12)), + Some(owned(&["nvim", "-u", "x", "+12", "/notes/plan.md"])) + ); + for editor in + ["vim", "/usr/bin/vi", "nano", "hx", "emacs -nw", "emacsclient -t", "micro", "kak", "NVIM.EXE"] + { + let argv = argv(&request(editor, 7)).unwrap_or_default(); + assert!(argv.contains(&"+7".to_owned()), "{editor}: {argv:?}"); + } + } + + #[test] + fn other_editors_get_the_path_alone() { + assert_eq!(argv(&request("code -w", 12)), Some(owned(&["code", "-w", "/notes/plan.md"]))); + assert_eq!( + argv(&request("subl --wait", 5)).and_then(|argv| argv.last().cloned()).as_deref(), + Some("/notes/plan.md") + ); + // A name that merely contains `vim` is not vim. + assert!(!argv(&request("vimdiff-wrapper", 5)).unwrap_or_default().contains(&"+5".to_owned())); + } + + #[test] + fn the_line_is_never_zero() { + assert!(argv(&request("vim", 0)).unwrap_or_default().contains(&"+1".to_owned())); + } +} diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index fdb2ab4..3ed68ec 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -36,6 +36,7 @@ pub(crate) enum Action { SendAndClose, ToggleTree, Reload, + OpenInEditor, PickMessage, QuickOpen, ReviewMenu, @@ -59,8 +60,8 @@ pub(crate) enum Action { use Action::{ Archive, Back, Bottom, ClearAll, ClearBlock, Comment, Confirm, CycleFocus, Delete, Down, Edit, - FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, PageDown, PageUp, - PickMessage, QuickOpen, Quit, Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, + FinishReview, Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, OpenInEditor, PageDown, + PageUp, PickMessage, QuickOpen, Quit, Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, WordForward, Yes, }; @@ -78,6 +79,7 @@ pub(crate) const GLOBAL: &[Action] = &[ Archive, ToggleTree, Reload, + OpenInEditor, QuickOpen, PickMessage, ]; @@ -238,6 +240,7 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (SendAndClose, &[ch('S')]), (ToggleTree, &[ch('t')]), (Reload, &[ch('r')]), + (OpenInEditor, &[ch('o')]), (PickMessage, &[ch('p')]), (QuickOpen, &[ctrl('p')]), (ReviewMenu, &[ch('m')]), @@ -367,6 +370,7 @@ impl Action { Self::SendAndClose => "send-and-close", Self::ToggleTree => "toggle-tree", Self::Reload => "reload", + Self::OpenInEditor => "open-in-editor", Self::PickMessage => "pick-message", Self::QuickOpen => "quick-open", Self::ReviewMenu => "review-menu", diff --git a/crates/plannotator-tui/src/main.rs b/crates/plannotator-tui/src/main.rs index ae43277..3584808 100644 --- a/crates/plannotator-tui/src/main.rs +++ b/crates/plannotator-tui/src/main.rs @@ -7,6 +7,7 @@ mod cli; mod config; mod delivery; mod doc; +mod editor; mod export; mod file_search; mod herdr;