Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
211 changes: 211 additions & 0 deletions crates/plannotator-tui/src/app/editor.rs
Original file line number Diff line number Diff line change
@@ -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<EditorRequest> {
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<ExitStatus>) {
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);
}
}
4 changes: 4 additions & 0 deletions crates/plannotator-tui/src/app/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
8 changes: 8 additions & 0 deletions crates/plannotator-tui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod archive_view;
mod compose;
mod disk;
mod draw;
mod editor;
mod feedback;
mod file_search;
mod header;
Expand Down Expand Up @@ -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<String>,
/// Set by `open-in-editor` for the event loop, which owns the terminal, to act on.
editor_request: Option<crate::editor::EditorRequest>,
/// `[ui]` from the config: the centered viewport and the document width.
pub(crate) ui: UiConfig,
/// The last primary-button press, for double-click detection.
Expand Down Expand Up @@ -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(),
Expand Down
61 changes: 52 additions & 9 deletions crates/plannotator-tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,37 +343,79 @@ pub(crate) fn run_ui(build: impl FnOnce(usize) -> Result<App>) -> 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<std::io::Result<std::process::ExitStatus>> {
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 {
Expand All @@ -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()?)?;
}
}
Expand Down
Loading
Loading