From ce4d6098330c9e15fdae88f3edfeda46da10da21 Mon Sep 17 00:00:00 2001 From: Phawin Khongkhasawan Date: Fri, 23 Oct 2026 14:41:38 -0400 Subject: [PATCH 1/2] feat(search): quick open markdown files by filename Ctrl+P in a folder review finds a Markdown file by name, scanning in the background, and reveals the opened file in the tree. --- README.md | 6 + crates/plannotator-tui/README.md | 1 + crates/plannotator-tui/src/app/draw.rs | 7 +- crates/plannotator-tui/src/app/file_search.rs | 313 ++++++++++++++++++ .../src/app/file_search/tests.rs | 250 ++++++++++++++ crates/plannotator-tui/src/app/input.rs | 22 +- crates/plannotator-tui/src/app/mod.rs | 36 +- crates/plannotator-tui/src/cli.rs | 4 +- crates/plannotator-tui/src/file_search.rs | 50 +++ .../plannotator-tui/src/file_search/scan.rs | 130 ++++++++ crates/plannotator-tui/src/main.rs | 1 + crates/plannotator-tui/src/tree.rs | 23 +- 12 files changed, 835 insertions(+), 8 deletions(-) create mode 100644 crates/plannotator-tui/src/app/file_search.rs create mode 100644 crates/plannotator-tui/src/app/file_search/tests.rs create mode 100644 crates/plannotator-tui/src/file_search.rs create mode 100644 crates/plannotator-tui/src/file_search/scan.rs diff --git a/README.md b/README.md index a5ceebc..b934132 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ dimmed. The keys also work without opening the menu. | toolbar | `a` looks good · `c` comment · `d` delete · `Esc` | | notes | `j`/`k`; `e` edit; `x` remove; click a bubble | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | +| folder review | `Ctrl+P` open a file by name, including in collapsed folders: type, `↑`/`↓` or click, `Enter`, `Esc` | | tree | `j`/`k`; `Enter` open; `.` show/hide dot-prefixed entries (`.agents/`, `.github/`); `E` sends new notes across all reviewed files, including collapsed folders | These are the defaults; every key can be rebound under `[keys]` (see [Keys](#keys)). @@ -104,6 +105,11 @@ Hidden folders are out of the tree until `.` asks for them, and `.git`, `.hg`, ` Showing or hiding is a view choice: notes recorded for a file inside a hidden folder are part of the review and are sent whether or not its folder is listed. +`Ctrl+P` matches file names, not contents, ignoring case and allowing gaps (`prd` finds +`product-requirements.md`). Results show the path within the folder, so files with the same +name are told apart, and opening one expands the tree down to it. It follows the tree's +hidden-entries setting and skips the same folders the tree does. + ## Inside Herdr Install [Herdr Annotate](https://github.com/plannotator/herdr-annotate); it bundles this binary, diff --git a/crates/plannotator-tui/README.md b/crates/plannotator-tui/README.md index 5e9e50f..4e705f4 100644 --- a/crates/plannotator-tui/README.md +++ b/crates/plannotator-tui/README.md @@ -19,6 +19,7 @@ cargo build --release | reply review | `S` send feedback and quit; stays open if the agent is at a dialog | | file/folder review | `E` send new · `m` review menu (`R` resend all · `F` finish review · `U` undo · `H` archive) | | tree | `j`/`k` move · `Enter` open · `.` show/hide hidden (dot-prefixed) entries · `E` send new feedback across files, including collapsed folders · counts show active notes per file | +| quick open (folder) | `Ctrl+P` find a Markdown file by name anywhere in the folder · type to filter · ↑/↓ or wheel select · `Enter` or click open · `Esc` cancel | | archive | `j`/`k` or ↑/↓ select · `Enter` or click restore · `Esc` close | Selections and exports are copied to the terminal clipboard (OSC 52). diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index ffa26c6..15ba6c9 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -90,6 +90,7 @@ impl App { Mode::Edit(_) => self.draw_compose(frame, &self.compose_title("edit")), Mode::Browse if self.pending.is_some() => self.draw_toolbar(frame), Mode::Pick => self.draw_pick(frame), + Mode::FileSearch => self.draw_file_search(frame), Mode::Archive => self.draw_archive(frame), Mode::ReviewMenu => self.draw_review_menu(frame), Mode::Browse | Mode::ConfirmQuit => {} @@ -479,7 +480,11 @@ impl App { .chain(quit) .collect(), }; - let help: Vec = help.into_iter().flatten().collect(); + let mut help: Vec = help.into_iter().flatten().collect(); + // Quick Open works from any pane of a folder review, so every pane's help names it. + if self.tree.is_some() && self.pending.is_none() { + help.insert(0, "ctrl+p find".to_owned()); + } let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; // The status must stay readable at any width, so the key help yields columns to it // (and is clipped) rather than the other way round. diff --git a/crates/plannotator-tui/src/app/file_search.rs b/crates/plannotator-tui/src/app/file_search.rs new file mode 100644 index 0000000..5449e1e --- /dev/null +++ b/crates/plannotator-tui/src/app/file_search.rs @@ -0,0 +1,313 @@ +//! Quick Open (`ctrl+p` in a folder review): find a Markdown file by name and open it. +//! Paths arrive from a background scan in batches; matching never reads file contents. +use std::path::PathBuf; +use std::sync::mpsc::TryRecvError; + +use anyhow::Result; +use ratatui::Frame; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; +use unicode_width::{UnicodeWidthChar as _, UnicodeWidthStr as _}; + +use super::{App, Mode}; +use crate::file_search::{ + rank, + scan::{Scan, Update}, +}; +use crate::theme::palette; + +/// Scan batches taken per poll; the channel holds as many, so one poll can drain it. +const BATCHES_PER_POLL: usize = 64; + +struct Candidate { + path: PathBuf, + name: String, + relative: String, +} + +pub(super) struct FileSearch { + root: PathBuf, + scan: Scan, + query: String, + files: Vec, + /// Indices into `files`, best match first. + results: Vec, + cursor: usize, + /// Set once the user moves the cursor. Until then it stays on the best match while + /// results stream in, so `Enter` right after typing opens the top result. + moved: bool, + done: bool, + errors: usize, + error: Option, +} + +impl FileSearch { + fn rematch(&mut self) { + let selected = self.moved.then(|| self.results.get(self.cursor).copied()).flatten(); + let query = self.query.to_lowercase(); + let mut ranked: Vec<_> = self + .files + .iter() + .enumerate() + .filter_map(|(index, file)| rank(&file.name, &query).map(|score| (score, index))) + .collect(); + ranked.sort_by(|(a, i), (b, j)| { + a.cmp(b).then_with(|| { + self.files.get(*i).map(|f| &f.relative).cmp(&self.files.get(*j).map(|f| &f.relative)) + }) + }); + self.results = ranked.into_iter().map(|(_, index)| index).collect(); + self.cursor = selected.and_then(|index| self.results.iter().position(|i| *i == index)).unwrap_or(0); + } + + fn set_query(&mut self, query: String) { + self.query = query; + self.moved = false; + self.rematch(); + } + + fn move_cursor(&mut self, delta: isize) { + let last = self.results.len().saturating_sub(1); + self.cursor = self.cursor.saturating_add_signed(delta).min(last); + self.moved = true; + } + + fn selected_path(&self) -> Option { + self.results.get(self.cursor).and_then(|i| self.files.get(*i)).map(|f| f.path.clone()) + } +} + +impl App { + /// Open Quick Open on the tree's folder, honouring its hidden-entries setting. Each + /// opening rescans, so files added or removed since last time are found. + pub(super) fn start_file_search(&mut self) -> Result<()> { + let Some(tree) = &self.tree else { return Ok(()) }; + let root = tree.root().to_path_buf(); + self.file_search = Some(FileSearch { + scan: Scan::start(root.clone(), tree.show_hidden())?, + root, + query: String::new(), + files: Vec::new(), + results: Vec::new(), + cursor: 0, + moved: false, + done: false, + errors: 0, + error: None, + }); + self.mode = Mode::FileSearch; + Ok(()) + } + + fn close_file_search(&mut self) { + self.file_search = None; + self.mode = Mode::Browse; + } + + pub(crate) fn file_search_scanning(&self) -> bool { + self.file_search.as_ref().is_some_and(|search| !search.done) + } + + /// Take what the scan has found so far. The event loop calls this on idle ticks too, so + /// results appear while the user is not typing. Returns whether anything changed. + pub(crate) fn poll_file_search(&mut self) -> bool { + let Some(search) = &mut self.file_search else { return false }; + if search.done { + return false; + } + let mut changed = false; + let mut added = false; + for _ in 0..BATCHES_PER_POLL { + match search.scan.updates.try_recv() { + Ok(Update::Files(paths)) => { + for path in paths { + let name = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let relative = + path.strip_prefix(&search.root).unwrap_or(&path).to_string_lossy().into_owned(); + search.files.push(Candidate { path, name, relative }); + } + added = true; + } + Ok(Update::Error(error)) => { + search.errors += 1; + search.error = Some(error); + } + Ok(Update::Done) => search.done = true, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + search.done = true; + search.errors += 1; + search.error = Some("file search stopped unexpectedly".into()); + } + } + changed = true; + if search.done { + break; + } + } + if added { + search.rematch(); + } + changed + } + + pub(super) fn file_search_paste(&mut self, text: &str) { + if let Some(search) = &mut self.file_search { + let query = search.query.chars().chain(text.chars().filter(|ch| !ch.is_control())).collect(); + search.set_query(query); + } + } + + pub(super) fn file_search_key(&mut self, key: KeyEvent) { + let Some(search) = &mut self.file_search else { + self.mode = Mode::Browse; + return; + }; + match key.code { + KeyCode::Esc => self.close_file_search(), + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => self.close_file_search(), + KeyCode::Enter => self.open_file_search_result(), + KeyCode::Up => search.move_cursor(-1), + KeyCode::Down => search.move_cursor(1), + KeyCode::Backspace => { + let mut query = search.query.clone(); + query.pop(); + search.set_query(query); + } + KeyCode::Char(ch) if !key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { + self.file_search_paste(&ch.to_string()); + } + _ => {} + } + } + + pub(super) fn file_search_mouse(&mut self, mouse: MouseEvent) { + let Some(search) = &mut self.file_search else { return }; + match mouse.kind { + MouseEventKind::ScrollDown => search.move_cursor(1), + MouseEventKind::ScrollUp => search.move_cursor(-1), + MouseEventKind::Down(MouseButton::Left) => { + let hit = self.geometry.search_rows.iter().find(|(rect, _)| { + mouse.row == rect.y && mouse.column >= rect.x && mouse.column < rect.right() + }); + if let Some(&(_, row)) = hit { + search.cursor = row; + search.moved = true; + self.open_file_search_result(); + } + } + _ => {} + } + } + + /// Open the selected result and reveal it in the tree. A file that cannot be read keeps + /// the popup open with the error, and the document that was open stays open. + fn open_file_search_result(&mut self) { + let Some(path) = self.file_search.as_ref().and_then(FileSearch::selected_path) else { return }; + if let Err(error) = self.open_file_path(&path) { + if let Some(search) = &mut self.file_search { + search.error = Some(format!("{error:#}")); + } + return; + } + self.close_file_search(); + self.reveal_in_tree(&path); + } + + pub(super) fn draw_file_search(&mut self, frame: &mut Frame) { + let Some(search) = &self.file_search else { return }; + let area = frame.area(); + let width = area.width.saturating_sub(4).clamp(1, 90).min(area.width); + let height = area.height.saturating_sub(2).clamp(1, 18).min(area.height); + let rect = Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ); + frame.render_widget(Clear, rect); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(Color::Cyan)) + .title(Span::styled(" open a Markdown file ", Style::new().dim())) + .title_bottom(Span::styled(" ↑↓ select · enter/click open · esc cancel ", Style::new().dim())); + let inner = block.inner(rect); + frame.render_widget(block, rect); + if inner.height == 0 || inner.width == 0 { + return; + } + + // Query on the first row, the scan's progress on the last, results between. + let prompt = format!("> {}", search.query); + let prompt_width = prompt.width() as u16; + frame.render_widget(Paragraph::new(prompt), Rect { height: 1, ..inner }); + frame.set_cursor_position((inner.x + prompt_width.min(inner.width.saturating_sub(1)), inner.y)); + + let list_height = usize::from(inner.height.saturating_sub(2)); + let start = search.cursor.saturating_sub(list_height.saturating_sub(1)); + let mut rows = Vec::new(); + let mut lines = Vec::new(); + for (row, index) in search.results.iter().enumerate().skip(start).take(list_height) { + let Some(file) = search.files.get(*index) else { continue }; + let style = if row == search.cursor { palette().selection } else { Style::new() }; + let text = tail(&file.relative, usize::from(inner.width).saturating_sub(1)); + lines.push(Line::from(Span::styled(format!(" {text}"), style))); + let y = inner.y + 1 + (row - start) as u16; + rows.push((Rect { x: inner.x, y, width: inner.width, height: 1 }, row)); + } + if search.results.is_empty() && list_height > 0 { + let empty = match (search.done, search.files.is_empty()) { + (false, _) => " searching…", + (true, true) => " no Markdown files in this folder", + (true, false) => " no matching files", + }; + lines.push(Line::from(Span::styled(empty, Style::new().dim()))); + } + let list = Rect { y: inner.y + 1, height: inner.height.saturating_sub(2), ..inner }; + frame.render_widget(Paragraph::new(lines), list); + + let mut parts = vec![format!(" {} of {} files", search.results.len(), search.files.len())]; + if !search.done { + parts.push("scanning…".to_owned()); + } + if search.errors > 0 { + parts.push(format!("{} unreadable", search.errors)); + } + let status = parts.join(" · "); + let status = match &search.error { + Some(error) => Line::from(vec![ + Span::styled(status, Style::new().dim()), + Span::styled(format!(" · {error}"), Style::new().fg(Color::Yellow)), + ]), + None => Line::from(Span::styled(status, Style::new().dim())), + }; + if inner.height > 1 { + frame.render_widget(Paragraph::new(status), Rect { y: inner.bottom() - 1, height: 1, ..inner }); + } + self.geometry.search_rows = rows; + } +} + +/// The end of `text` in at most `width` columns, so a deep path keeps its file name. +fn tail(text: &str, width: usize) -> String { + if text.width() <= width { + return text.to_owned(); + } + let mut kept = Vec::new(); + let mut used = 1; // the leading ellipsis + for ch in text.chars().rev() { + used += ch.width().unwrap_or(0); + if used > width { + break; + } + kept.push(ch); + } + std::iter::once('…').chain(kept.into_iter().rev()).collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/plannotator-tui/src/app/file_search/tests.rs b/crates/plannotator-tui/src/app/file_search/tests.rs new file mode 100644 index 0000000..ae3ad1a --- /dev/null +++ b/crates/plannotator-tui/src/app/file_search/tests.rs @@ -0,0 +1,250 @@ +#![allow(clippy::expect_used, reason = "tests assert by panicking")] +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use plannotator_tui_schema::{DocumentSource, Kind, Provenance}; +use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; + +use crate::app::{App, Focus, Mode}; +use crate::delivery::Discard; +use crate::tree::Tree; + +fn fixture() -> (PathBuf, App) { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "plannotator-search-app-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(root.join("docs/deep")).expect("mkdir"); + for (name, content) in [("plan.md", "# Root\n\none\n"), ("docs/deep/plan.md", "# Nested\n\ntwo\n")] { + std::fs::write(root.join(name), content).expect("write"); + } + let mut app = App::open( + DocumentSource::new("# Original".into(), "original", true, Provenance::Stdin), + 60, + Box::new(Discard), + ) + .expect("app"); + app.data_dir = root.join("data"); + app.tree = Some(Tree::scan(&root).expect("tree")); + (root, app) +} + +fn key(app: &mut App, code: KeyCode) { + app.handle_event(&Event::Key(KeyEvent::from(code))).expect("key"); +} + +fn start(app: &mut App) { + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL))).expect("start"); + assert_eq!(app.mode, Mode::FileSearch); +} + +fn finish_scan(app: &mut App) { + let deadline = Instant::now() + Duration::from_secs(5); + while app.file_search.as_ref().is_some_and(|s| !s.done) { + app.poll_file_search(); + assert!(Instant::now() < deadline, "scan must finish"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn screen(app: &mut App, width: u16, height: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal.draw(|frame| app.draw(frame)).expect("draw"); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| { + (0..width) + .filter_map(|x| buffer.cell((x, y))) + .map(ratatui::buffer::Cell::symbol) + .collect::() + }) + .collect::>() + .join("\n") +} + +#[test] +fn cancelling_keeps_document_focus_scroll_and_selection_and_discards_old_scan() { + let (root, mut app) = fixture(); + app.focus = Focus::Tree; + app.scroll = 7; + app.selected = 3; + app.cursor = (7, 2); + start(&mut app); + key(&mut app, KeyCode::Char('j')); + assert_eq!(app.file_search.as_ref().map(|s| s.query.as_str()), Some("j")); + key(&mut app, KeyCode::Esc); + assert_eq!(app.mode, Mode::Browse); + assert_eq!(app.focus, Focus::Tree); + assert_eq!((app.scroll, app.selected, app.cursor), (7, 3, (7, 2))); + assert_eq!(app.open.source.name, "original"); + start(&mut app); + finish_scan(&mut app); + assert_eq!(app.file_search.as_ref().map(|s| s.files.len()), Some(2)); + key(&mut app, KeyCode::Esc); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn duplicate_names_open_the_selected_path_reveal_ancestors_and_keep_annotations() { + let (root, mut app) = fixture(); + start(&mut app); + finish_scan(&mut app); + // Shown with the platform's separator. + let nested = std::path::Path::new("docs").join("deep").join("plan.md"); + assert!(screen(&mut app, 100, 24).contains(nested.to_string_lossy().as_ref())); + key(&mut app, KeyCode::Enter); + assert!( + matches!(&app.open.source.provenance, Provenance::File { path } if path == &root.join("docs/deep/plan.md")) + ); + assert_eq!( + app.tree.as_ref().and_then(|t| t.position(&root.join("docs/deep/plan.md"))), + Some(app.tree_cursor) + ); + app.add_block_annotation(0, Kind::Comment, "keep me".into()).expect("annotate"); + start(&mut app); + finish_scan(&mut app); + key(&mut app, KeyCode::Down); + key(&mut app, KeyCode::Enter); + assert!( + matches!(&app.open.source.provenance, Provenance::File { path } if path == &root.join("plan.md")) + ); + start(&mut app); + finish_scan(&mut app); + key(&mut app, KeyCode::Enter); + assert_eq!(app.open.store.len(), 1); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn a_deleted_result_reports_its_path_without_leaving_the_original_document() { + let (root, mut app) = fixture(); + start(&mut app); + finish_scan(&mut app); + std::fs::remove_file(root.join("docs/deep/plan.md")).expect("remove"); + key(&mut app, KeyCode::Enter); + assert_eq!(app.mode, Mode::FileSearch); + assert_eq!(app.open.source.name, "original"); + assert!(app.file_search.as_ref().and_then(|s| s.error.as_ref()).is_some_and(|s| s.contains("plan.md"))); + key(&mut app, KeyCode::Esc); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn the_selected_result_stays_visible_when_scrolling() { + let (root, mut app) = fixture(); + for index in 0..40 { + std::fs::write(root.join(format!("note-{index:02}.md")), "# Note").expect("write"); + } + start(&mut app); + finish_scan(&mut app); + for _ in 0..25 { + key(&mut app, KeyCode::Down); + } + let selected = app.file_search.as_ref().and_then(super::FileSearch::selected_path).expect("selected"); + let relative = selected.strip_prefix(&root).expect("relative").to_string_lossy(); + assert!(screen(&mut app, 80, 10).contains(relative.as_ref())); + key(&mut app, KeyCode::Esc); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn a_failed_scan_is_reported_as_unreadable_not_as_no_files() { + let (root, mut app) = fixture(); + std::fs::remove_dir_all(&root).expect("remove root"); + start(&mut app); + finish_scan(&mut app); + assert_eq!(app.file_search.as_ref().map(|s| s.errors), Some(1)); + assert!(screen(&mut app, 100, 24).contains("1 unreadable")); + key(&mut app, KeyCode::Esc); +} + +#[test] +fn paste_unicode_empty_results_and_tiny_resizes_are_safe() { + let (root, mut app) = fixture(); + start(&mut app); + finish_scan(&mut app); + app.handle_event(&Event::Paste("แผน\nงาน".into())).expect("paste"); + assert_eq!(app.file_search.as_ref().map(|s| s.query.as_str()), Some("แผนงาน")); + assert!(screen(&mut app, 100, 24).contains("no matching files")); + for size in [(1, 1), (10, 4), (80, 20)] { + screen(&mut app, size.0, size.1); + } + key(&mut app, KeyCode::Backspace); + assert_eq!(app.file_search.as_ref().map(|s| s.query.as_str()), Some("แผนงา")); + key(&mut app, KeyCode::Enter); + assert_eq!(app.open.source.name, "original"); + key(&mut app, KeyCode::Esc); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn the_cursor_stays_on_the_best_match_while_results_stream_in_until_the_user_moves_it() { + let (root, mut app) = fixture(); + start(&mut app); + finish_scan(&mut app); + let search = app.file_search.as_mut().expect("search"); + // Replay the scan as two batches: the root's file first, then the nested one, which + // sorts above it. + let mut files = std::mem::take(&mut search.files); + files.sort_by(|a, b| b.relative.cmp(&a.relative)); + let nested = files.pop().expect("nested"); + search.files = files; + search.rematch(); + search.files.push(nested); + search.rematch(); + assert_eq!(search.selected_path(), Some(root.join("docs/deep/plan.md"))); + // Once the user has picked a row, a later batch must not move it. + search.move_cursor(1); + search.rematch(); + assert_eq!(search.selected_path(), Some(root.join("plan.md"))); + key(&mut app, KeyCode::Esc); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn clicking_a_result_opens_it_and_ctrl_c_closes_without_quitting() { + let (root, mut app) = fixture(); + start(&mut app); + finish_scan(&mut app); + screen(&mut app, 100, 24); + let (rect, _) = *app.geometry.search_rows.get(1).expect("second row"); + let click = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down(ratatui::crossterm::event::MouseButton::Left), + column: rect.x + 1, + row: rect.y, + modifiers: KeyModifiers::NONE, + }; + app.handle_event(&Event::Mouse(click)).expect("click"); + assert_eq!(app.mode, Mode::Browse); + assert!( + matches!(&app.open.source.provenance, Provenance::File { path } if path == &root.join("plan.md")) + ); + start(&mut app); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))).expect("ctrl+c"); + assert_eq!(app.mode, Mode::Browse); + assert!(!app.quit); + assert!(app.file_search.is_none()); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn ctrl_p_does_nothing_outside_a_folder_review() { + let (root, mut app) = fixture(); + app.tree = None; + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL))).expect("ctrl+p"); + assert_eq!(app.mode, Mode::Browse); + assert!(app.file_search.is_none()); + std::fs::remove_dir_all(root).expect("cleanup"); +} + +#[test] +fn a_long_path_keeps_its_file_name_visible() { + assert_eq!(super::tail("docs/deep/plan.md", 40), "docs/deep/plan.md"); + assert_eq!(super::tail("docs/deep/plan.md", 10), "…p/plan.md"); + // Wide characters count two columns. + assert_eq!(super::tail("文档/计划.md", 8), "…计划.md"); +} diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index 3982e05..f57c353 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -2,7 +2,9 @@ use anyhow::Result; use plannotator_tui_schema::Kind; -use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::crossterm::event::{ + Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; use super::compose::ComposeAction; use super::menu::ReviewAction; @@ -19,6 +21,10 @@ impl App { Mode::Browse => self.browse_key(*key), Mode::ConfirmQuit => self.confirm_quit_key(*key), Mode::Pick => self.pick_key(*key), + Mode::FileSearch => { + self.file_search_key(*key); + Ok(()) + } Mode::Archive => { self.archive_key(*key); Ok(()) @@ -32,8 +38,17 @@ impl App { self.compose.insert_text(text); Ok(()) } + // In Quick Open a paste joins the query, newlines dropped. + Event::Paste(text) if self.mode == Mode::FileSearch => { + self.file_search_paste(text); + Ok(()) + } Event::Mouse(mouse) if self.mode == Mode::Browse => self.mouse(*mouse), Event::Mouse(mouse) if self.mode == Mode::Pick => self.pick_mouse(*mouse), + Event::Mouse(mouse) if self.mode == Mode::FileSearch => { + self.file_search_mouse(*mouse); + Ok(()) + } Event::Mouse(mouse) if self.mode == Mode::ReviewMenu => self.menu_mouse(*mouse), Event::Mouse(mouse) if self.mode == Mode::Archive => { self.archive_mouse(*mouse); @@ -45,6 +60,10 @@ impl App { fn browse_key(&mut self, key: KeyEvent) -> Result<()> { // Global keys first. + // Quick Open exists only in a folder review; elsewhere ctrl+p falls through to `p`. + if key.code == KeyCode::Char('p') && key.modifiers == KeyModifiers::CONTROL && self.tree.is_some() { + return self.start_file_search(); + } match self.keys.action(&key, keys::GLOBAL) { Some(Action::Quit | Action::Interrupt) => { self.request_quit(); @@ -360,6 +379,7 @@ impl App { | Mode::Browse | Mode::ConfirmQuit | Mode::Pick + | Mode::FileSearch | Mode::Archive | Mode::ReviewMenu => { if !body.is_empty() diff --git a/crates/plannotator-tui/src/app/mod.rs b/crates/plannotator-tui/src/app/mod.rs index 140d2d6..e5753aa 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 draw; mod feedback; +mod file_search; mod header; mod input; mod menu; @@ -57,6 +58,8 @@ enum Mode { ConfirmQuit, /// Choosing which of the agent's recent messages to review. Pick, + /// Quick Open: finding a file in the folder by name. + FileSearch, /// Restoring annotations from finished file reviews. Archive, /// The header's Review menu is open over a file or folder review. @@ -91,6 +94,8 @@ struct Geometry { archive_rows: Vec<(Rect, usize)>, /// Picker rows drawn last frame, with their candidate index. pick_rows: Vec<(Rect, usize)>, + /// Quick Open result rows drawn last frame, with their position in the results. + search_rows: Vec<(Rect, usize)>, } /// A finished selection waiting for an action. @@ -134,6 +139,8 @@ pub(crate) struct App { project: String, /// Present in folder mode. tree: Option, + /// Present while Quick Open is showing. + file_search: Option, tree_cursor: usize, /// First tree row drawn; follows `tree_cursor` so the selected row stays visible. tree_scroll: usize, @@ -221,6 +228,7 @@ impl App { data_dir, project, tree: None, + file_search: None, tree_cursor: 0, tree_scroll: 0, tree_visible: None, @@ -360,12 +368,36 @@ impl App { return Ok(()); } let path = row.path.clone(); - if matches!(&self.open.source.provenance, Provenance::File { path: p } if *p == path) { + self.open_file_path(&path) + } + + /// Expand the folders above `path` and put the tree cursor on it, with the same count + /// refresh an expanded directory gets. Folders the user had expanded stay expanded. The + /// file is already open by now, so a folder that cannot be listed is only reported. + fn reveal_in_tree(&mut self, path: &Path) { + let Some(mut tree) = self.tree.take() else { return }; + let result = tree.reveal(path); + self.refresh_counts(&mut tree); + self.tree = Some(tree); + match result { + Ok(Some(index)) => self.tree_cursor = index, + Ok(None) => {} + Err(error) => self.status = Some(format!("showing {} in the tree: {error:#}", path.display())), + } + self.refresh_review_counts(); + self.derive_send_state(); + self.keep_tree_cursor_visible(usize::from(self.geometry.tree.height)); + } + + /// Open `path` in place of the current document. The new document is built first, so a + /// file that cannot be read leaves the current one open. + fn open_file_path(&mut self, path: &Path) -> Result<()> { + if matches!(&self.open.source.provenance, Provenance::File { path: p } if p == path) { self.focus = Focus::Document; return Ok(()); } let width = self.open.layout.width; - self.open = Open::new(read_file(&path)?, width, &self.data_dir, &self.project)?; + self.open = Open::new(read_file(path)?, width, &self.data_dir, &self.project)?; self.update_open_review_counts(); self.derive_send_state(); self.scroll = 0; diff --git a/crates/plannotator-tui/src/cli.rs b/crates/plannotator-tui/src/cli.rs index 33f4bc0..961b7b4 100644 --- a/crates/plannotator-tui/src/cli.rs +++ b/crates/plannotator-tui/src/cli.rs @@ -373,13 +373,15 @@ fn event_loop(terminal: &mut ratatui::DefaultTerminal, mut app: App) -> Result<( app.clipboard = true; let mut dirty = true; while !app.quit { + dirty |= app.poll_file_search(); if dirty { let started = Instant::now(); terminal.draw(|frame| app.draw(frame))?; app.record_frame(started.elapsed().as_secs_f64() * 1000.0); dirty = false; } - if event::poll(Duration::from_millis(250))? { + let wait_ms = if app.file_search_scanning() { 25 } else { 250 }; + if event::poll(Duration::from_millis(wait_ms))? { app.handle_event(&event::read()?)?; dirty = true; // Coalesce bursts (wheel, drag) into one redraw. diff --git a/crates/plannotator-tui/src/file_search.rs b/crates/plannotator-tui/src/file_search.rs new file mode 100644 index 0000000..2e251e9 --- /dev/null +++ b/crates/plannotator-tui/src/file_search.rs @@ -0,0 +1,50 @@ +//! Filename matching, independent of the filesystem and UI. +pub(crate) mod scan; + +/// Lower ranks win: exact, substring, then ordered characters with fewer gaps. +/// Case folding is Unicode lowercase, not accent or normalization folding. +pub(crate) fn rank(name: &str, query: &str) -> Option<(u8, usize, usize, usize)> { + let name = name.to_lowercase(); + let length = name.chars().count(); + if query.is_empty() { + return Some((0, 0, 0, 0)); + } + if name == query { + return Some((0, 0, 0, length)); + } + if let Some(start) = name.find(query) { + return Some((1, 0, name.get(..start)?.chars().count(), length)); + } + let mut wanted = query.chars(); + let mut next = wanted.next()?; + let mut first = None; + let mut matched = 0; + for (index, ch) in name.chars().enumerate() { + if ch != next { + continue; + } + let start = *first.get_or_insert(index); + matched += 1; + match wanted.next() { + Some(ch) => next = ch, + None => return Some((2, index + 1 - start - matched, start, length)), + } + } + None +} + +#[cfg(test)] +mod tests { + use super::rank; + + #[test] + fn exact_then_substring_then_ordered_characters() { + assert!(rank("PRD", "prd") < rank("prd.md", "prd")); + assert!(rank("prd.md", "prd") < rank("product-requirements.md", "prd")); + assert!(rank("product-requirements.md", "prd").is_some()); + assert_eq!(rank("readme.md", "raedme"), None); + assert!(rank("แผนงาน.md", "ผง").is_some()); + assert!(rank("ÉTUDE.md", "ét").is_some()); + assert!(rank("anything.md", "").is_some()); + } +} diff --git a/crates/plannotator-tui/src/file_search/scan.rs b/crates/plannotator-tui/src/file_search/scan.rs new file mode 100644 index 0000000..a1ec138 --- /dev/null +++ b/crates/plannotator-tui/src/file_search/scan.rs @@ -0,0 +1,130 @@ +//! The Quick Open scan: a cancellable walk of the whole folder on its own thread, with the +//! tree's rules (`tree::is_walkable_dir`): dependency/build directories and symlinked +//! directories are never entered, so a symlink loop cannot trap it. A bounded channel +//! limits how many unread batches pile up. +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, SyncSender, sync_channel}; + +use anyhow::{Context, Result}; + +use crate::tree::{is_hidden, is_markdown, is_walkable_dir}; + +pub(crate) enum Update { + Files(Vec), + Error(String), + Done, +} + +pub(crate) struct Scan { + pub(crate) updates: Receiver, + cancelled: Arc, +} + +impl Scan { + pub(crate) fn start(root: PathBuf, show_hidden: bool) -> Result { + let (sender, updates) = sync_channel(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let stop = cancelled.clone(); + std::thread::Builder::new() + .name("file-search".into()) + .spawn(move || walk(root, show_hidden, &stop, &sender)) + .context("starting filename search")?; + Ok(Self { updates, cancelled }) + } +} + +impl Drop for Scan { + fn drop(&mut self) { + self.cancelled.store(true, Ordering::Relaxed); + // Dropping the receiver also releases a worker blocked on a full channel. + } +} + +fn walk(root: PathBuf, show_hidden: bool, stop: &AtomicBool, sender: &SyncSender) { + let mut directories = vec![root]; + let mut batch = Vec::new(); + while let Some(dir) = directories.pop() { + if stop.load(Ordering::Relaxed) { + return; + } + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) => { + if sender.send(Update::Error(format!("reading {}: {error}", dir.display()))).is_err() { + return; + } + continue; + } + }; + for entry in entries { + if stop.load(Ordering::Relaxed) { + return; + } + let path = match entry { + Ok(entry) => entry.path(), + Err(error) => { + if sender.send(Update::Error(format!("reading {}: {error}", dir.display()))).is_err() { + return; + } + continue; + } + }; + if !show_hidden && is_hidden(&path) { + continue; + } + if is_walkable_dir(&path) { + directories.push(path); + } else if path.is_file() && is_markdown(&path) { + batch.push(path); + if batch.len() == 64 && sender.send(Update::Files(std::mem::take(&mut batch))).is_err() { + return; + } + } + } + if !batch.is_empty() && sender.send(Update::Files(std::mem::take(&mut batch))).is_err() { + return; + } + } + let _ = sender.send(Update::Done); +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn discovery_descends_but_respects_hidden_skipped_and_symlink_directories() { + let root = std::env::temp_dir().join(format!("plannotator-search-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + for dir in ["docs/deep", ".agents", "node_modules", ".git"] { + std::fs::create_dir_all(root.join(dir)).expect("mkdir"); + std::fs::write(root.join(dir).join("plan.MDX"), "").expect("write"); + } + std::fs::write(root.join("notes.txt"), "").expect("write"); + #[cfg(unix)] + std::os::unix::fs::symlink(&root, root.join("loop")).expect("symlink"); + for hidden in [false, true] { + let scan = Scan::start(root.clone(), hidden).expect("scan"); + let mut paths = Vec::new(); + loop { + match scan.updates.recv_timeout(Duration::from_secs(5)).expect("update") { + Update::Files(batch) => paths.extend(batch), + Update::Error(error) => panic!("{error}"), + Update::Done => break, + } + } + paths.sort(); + let mut expected = vec![root.join("docs/deep/plan.MDX")]; + if hidden { + expected.push(root.join(".agents/plan.MDX")); + } + expected.sort(); + assert_eq!(paths, expected); + } + std::fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/crates/plannotator-tui/src/main.rs b/crates/plannotator-tui/src/main.rs index 6666039..ae43277 100644 --- a/crates/plannotator-tui/src/main.rs +++ b/crates/plannotator-tui/src/main.rs @@ -8,6 +8,7 @@ mod config; mod delivery; mod doc; mod export; +mod file_search; mod herdr; mod keys; mod last; diff --git a/crates/plannotator-tui/src/tree.rs b/crates/plannotator-tui/src/tree.rs index f68e3c9..e737d24 100644 --- a/crates/plannotator-tui/src/tree.rs +++ b/crates/plannotator-tui/src/tree.rs @@ -54,18 +54,18 @@ pub(crate) struct Tree { pub(crate) rows: Vec, } -fn is_markdown(path: &Path) -> bool { +pub(crate) fn is_markdown(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) .is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "md" | "markdown" | "mdx")) } -fn is_hidden(path: &Path) -> bool { +pub(crate) fn is_hidden(path: &Path) -> bool { path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) } /// A real (non-symlinked) directory that is not a dependency or build tree. -fn is_walkable_dir(path: &Path) -> bool { +pub(crate) fn is_walkable_dir(path: &Path) -> bool { let by_name = path.file_name().and_then(|n| n.to_str()).is_none_or(|n| !SKIPPED_DIRS.contains(&n)); by_name && std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_dir()) } @@ -141,6 +141,23 @@ impl Tree { self.rows.iter().position(|r| r.path == path) } + /// Expand only the ancestors needed to list a search result. + pub(crate) fn reveal(&mut self, path: &Path) -> Result> { + let relative = path.strip_prefix(&self.root).context("search result outside folder")?; + let mut ancestor = self.root.clone(); + let mut components = relative.components().peekable(); + while let Some(component) = components.next() { + ancestor.push(component); + if components.peek().is_some() + && let Some(index) = self.position(&ancestor) + && self.rows.get(index).is_some_and(|row| !row.expanded) + { + self.toggle(index)?; + } + } + Ok(self.position(path)) + } + /// The first listed file, in display order. #[cfg(test)] pub(crate) fn first_file(&self) -> Option<&Row> { From f2784206af80e605e0654bacfb3a772123f25cad Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Fri, 23 Oct 2026 14:42:40 -0400 Subject: [PATCH 2/2] feat(keys): make quick open a rebindable quick-open action - ctrl+p is the quick-open default and runs only in a folder review; elsewhere it still picks another reply - the footer names its bound key at the end of the hints - the picker's frame and error use the palette's accent and comment colours - readme lists quick-open in the keys table --- README.md | 1 + crates/plannotator-tui/src/app/draw.rs | 10 ++++----- crates/plannotator-tui/src/app/file_search.rs | 6 +++--- .../src/app/file_search/tests.rs | 13 ++++++++++++ crates/plannotator-tui/src/app/input.rs | 21 ++++++++++++------- crates/plannotator-tui/src/app/tests.rs | 9 ++++++++ crates/plannotator-tui/src/keys.rs | 8 +++++-- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b934132..f8c1c9e 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,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 | +| `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 | | `toggle-hidden` | `.` | show/hide dot-prefixed entries in the tree | diff --git a/crates/plannotator-tui/src/app/draw.rs b/crates/plannotator-tui/src/app/draw.rs index 15ba6c9..f042dac 100644 --- a/crates/plannotator-tui/src/app/draw.rs +++ b/crates/plannotator-tui/src/app/draw.rs @@ -480,11 +480,11 @@ impl App { .chain(quit) .collect(), }; - let mut help: Vec = help.into_iter().flatten().collect(); - // Quick Open works from any pane of a folder review, so every pane's help names it. - if self.tree.is_some() && self.pending.is_none() { - help.insert(0, "ctrl+p find".to_owned()); - } + // Quick Open works from any pane of a folder review, so every pane's help names it, + // last. + let find = + if self.tree.is_some() && self.pending.is_none() { key(Action::QuickOpen, "find") } else { None }; + let help: Vec = help.into_iter().chain([find]).flatten().collect(); let help = if help.is_empty() { String::new() } else { format!("{} ", help.join(" · ")) }; // The status must stay readable at any width, so the key help yields columns to it // (and is clipped) rather than the other way round. diff --git a/crates/plannotator-tui/src/app/file_search.rs b/crates/plannotator-tui/src/app/file_search.rs index 5449e1e..c3c7259 100644 --- a/crates/plannotator-tui/src/app/file_search.rs +++ b/crates/plannotator-tui/src/app/file_search.rs @@ -7,7 +7,7 @@ use anyhow::Result; use ratatui::Frame; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph}; use unicode_width::{UnicodeWidthChar as _, UnicodeWidthStr as _}; @@ -232,7 +232,7 @@ impl App { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::new().fg(Color::Cyan)) + .border_style(Style::new().fg(palette().accent)) .title(Span::styled(" open a Markdown file ", Style::new().dim())) .title_bottom(Span::styled(" ↑↓ select · enter/click open · esc cancel ", Style::new().dim())); let inner = block.inner(rect); @@ -281,7 +281,7 @@ impl App { let status = match &search.error { Some(error) => Line::from(vec![ Span::styled(status, Style::new().dim()), - Span::styled(format!(" · {error}"), Style::new().fg(Color::Yellow)), + Span::styled(format!(" · {error}"), Style::new().fg(palette().comment)), ]), None => Line::from(Span::styled(status, Style::new().dim())), }; diff --git a/crates/plannotator-tui/src/app/file_search/tests.rs b/crates/plannotator-tui/src/app/file_search/tests.rs index ae3ad1a..7e46ebd 100644 --- a/crates/plannotator-tui/src/app/file_search/tests.rs +++ b/crates/plannotator-tui/src/app/file_search/tests.rs @@ -248,3 +248,16 @@ fn a_long_path_keeps_its_file_name_visible() { // Wide characters count two columns. assert_eq!(super::tail("文档/计划.md", 8), "…计划.md"); } + +#[test] +fn the_footer_names_quick_open_last_with_its_bound_key() { + let (root, mut app) = fixture(); + let footer = screen(&mut app, 200, 20).lines().last().expect("footer").trim_end().to_owned(); + assert!(footer.ends_with("q quit · ctrl+p find"), "{footer:?}"); + app.keys = toml::from_str("quick-open = [\"ctrl+o\"]\n").expect("keys"); + let footer = screen(&mut app, 200, 20).lines().last().expect("footer").trim_end().to_owned(); + assert!(footer.ends_with("q quit · ctrl+o find"), "{footer:?}"); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL))).expect("ctrl+o"); + assert_eq!(app.mode, Mode::FileSearch); + std::fs::remove_dir_all(root).expect("cleanup"); +} diff --git a/crates/plannotator-tui/src/app/input.rs b/crates/plannotator-tui/src/app/input.rs index f57c353..5434e21 100644 --- a/crates/plannotator-tui/src/app/input.rs +++ b/crates/plannotator-tui/src/app/input.rs @@ -2,9 +2,7 @@ use anyhow::Result; use plannotator_tui_schema::Kind; -use ratatui::crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, -}; +use ratatui::crossterm::event::{Event, KeyEvent, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; use super::compose::ComposeAction; use super::menu::ReviewAction; @@ -60,11 +58,18 @@ impl App { fn browse_key(&mut self, key: KeyEvent) -> Result<()> { // Global keys first. - // Quick Open exists only in a folder review; elsewhere ctrl+p falls through to `p`. - if key.code == KeyCode::Char('p') && key.modifiers == KeyModifiers::CONTROL && self.tree.is_some() { - return self.start_file_search(); - } - match self.keys.action(&key, keys::GLOBAL) { + let action = match self.keys.action(&key, keys::GLOBAL) { + // Quick Open needs the file tree. Without one its key means what it did before + // `quick-open` existed: ctrl+p is `p`, which picks another reply. + Some(Action::QuickOpen) if self.tree.is_none() => { + let others: Vec = + keys::GLOBAL.iter().copied().filter(|&action| action != Action::QuickOpen).collect(); + self.keys.action(&key, &others) + } + action => action, + }; + match action { + Some(Action::QuickOpen) => return self.start_file_search(), Some(Action::Quit | Action::Interrupt) => { self.request_quit(); return Ok(()); diff --git a/crates/plannotator-tui/src/app/tests.rs b/crates/plannotator-tui/src/app/tests.rs index 110daa8..f78c32d 100644 --- a/crates/plannotator-tui/src/app/tests.rs +++ b/crates/plannotator-tui/src/app/tests.rs @@ -771,3 +771,12 @@ fn a_ctrl_binding_beats_the_plain_key_it_shares_a_letter_with() { app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL))).expect("key"); assert_eq!(app.selected, 1, "ctrl+k goes to the last block instead of up"); } + +/// Without a file tree `quick-open` has nothing to search, so ctrl+p keeps meaning `p`. +#[test] +fn ctrl_p_in_a_reply_review_still_picks_another_reply() { + let mut app = message_app(None, Box::new(Discard)); + app.handle_event(&Event::Key(KeyEvent::from(KeyCode::Esc))).expect("esc"); + app.handle_event(&Event::Key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL))).expect("ctrl+p"); + assert_eq!(app.mode, Mode::Pick); +} diff --git a/crates/plannotator-tui/src/keys.rs b/crates/plannotator-tui/src/keys.rs index 5602135..2c34fd9 100644 --- a/crates/plannotator-tui/src/keys.rs +++ b/crates/plannotator-tui/src/keys.rs @@ -37,6 +37,7 @@ pub(crate) enum Action { ToggleTree, Reload, PickMessage, + QuickOpen, ReviewMenu, ResendAll, FinishReview, @@ -57,8 +58,8 @@ pub(crate) enum Action { use Action::{ Archive, Back, Bottom, ClearBlock, Comment, Confirm, CycleFocus, Delete, Down, Edit, FinishReview, - Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, PageDown, PageUp, PickMessage, Quit, - Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, + Interrupt, Left, LineEnd, LineStart, LooksGood, MoveCursor, No, PageDown, PageUp, PickMessage, QuickOpen, + Quit, Reload, Remove, ResendAll, ReviewMenu, Right, Send, SendAndClose, ToggleHidden, ToggleTree, Top, UndoFinish, Up, Visual, WordBack, WordForward, Yes, }; @@ -76,6 +77,7 @@ pub(crate) const GLOBAL: &[Action] = &[ Archive, ToggleTree, Reload, + QuickOpen, PickMessage, ]; pub(crate) const TREE: &[Action] = &[Down, Up, Confirm, Right, ToggleHidden, Back]; @@ -232,6 +234,7 @@ const DEFAULTS: &[(Action, &[Key])] = &[ (ToggleTree, &[ch('t')]), (Reload, &[ch('r')]), (PickMessage, &[ch('p')]), + (QuickOpen, &[ctrl('p')]), (ReviewMenu, &[ch('m')]), (ResendAll, &[ch('R')]), (FinishReview, &[ch('F')]), @@ -359,6 +362,7 @@ impl Action { Self::ToggleTree => "toggle-tree", Self::Reload => "reload", Self::PickMessage => "pick-message", + Self::QuickOpen => "quick-open", Self::ReviewMenu => "review-menu", Self::ResendAll => "resend-all", Self::FinishReview => "finish-review",