From 893edbda9fd05732b57c42dba1b94ad71a4a016f Mon Sep 17 00:00:00 2001 From: Skkeye Date: Fri, 2 Oct 2026 12:15:04 -0400 Subject: [PATCH] Search every document (stage 9d) / (the user's pick), or :search text straight to the best match, searches every document cumulus knows of: the open ones (the shown one first), the ones opened lately, the .cum files in this document's folder, and every document their portals and backlinks lead to, however far (the user's pick; up to 200). Open documents are searched as they are, the others as their files have them. It's the / popup with a list of documents to search: one list, best first whatever the document, each match with its document's name at the end of its row (the user's pick), the note beside it from that document. Enter goes to the match through the jump list's GoToPlace, opening a closed document. n and N then go through every match, document to document (the user's pick), going round. The matches are kept in the last search as they were, as vim's :cnext goes through a :vimgrep list: searched again from each document, equal matches would trade places and n would bounce between two. Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 28 ++- README.md | 1 + src/app/action.rs | 7 +- src/app/calls.rs | 1 + src/app/commands.rs | 1 + src/app/mod.rs | 2 +- src/app/portals.rs | 2 +- src/app/search.rs | 351 +++++++++++++++++++++++++++++++----- src/app/tests.rs | 1 + src/app/tests/everywhere.rs | 73 ++++++++ src/app/update.rs | 7 +- src/command/parse.rs | 4 + src/command/registry.rs | 8 + src/input/bindings.rs | 1 + src/input/command.rs | 3 + 15 files changed, 438 insertions(+), 52 deletions(-) create mode 100644 src/app/tests/everywhere.rs diff --git a/DESIGN.md b/DESIGN.md index 34fe24e..33c509e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -317,6 +317,23 @@ lines. tags, and lines after a `| ` are the body of the node above. Looks and cross-links stay out of both exports. +**Searching every document** (settled in part 9d) +- `/` (the user's pick), or `:search text` straight to the best + match, searches every document cumulus knows of: the open ones (the one + shown first), the ones opened lately, the `.cum` files in this + document's folder, and every document their portals and backlinks lead + to, however far (the user's pick; up to 200 documents). The open ones are + searched as they are, the others as their files have them. +- It's the `/` popup: one list, best first whatever the document (titles + before tags before bodies), each match with its document's name at the + end of its row (the user's pick), and the note of the one you're on + beside them. Enter goes there, opening its document if it's closed: a + jump, so `Ctrl-o` comes back. +- `n` and `N` then go through every match, from document to document (the + user's pick), going round, as vim's `:cnext` goes through a `:vimgrep`'s + list: the matches as they were when you searched. `/` and `:find` still + search the document shown, and `n` after them stays in it. + **Views, panes and popups** (settled in stage 7) - The Note view is made of parts: the title, the tags, the body, each link (out, then in) and each child. `j` and `k` step through them, with a bar @@ -541,12 +558,12 @@ text). | Group | Keys | |---|---| -| **Move** (every view) | `hjkl` or arrows · `gh gj gk gl` tree moves · `w` `b` next / previous in reading order · `gg` root · `G` last · `/` `n` `N` search · `Ctrl-o` `Ctrl-i` (Tab) jump back / forward · `m{a-z}` `'{a-z}` marks (`A–Z` across documents) · Enter or `gf` through a portal · `gu` up to the portals that lead here · `gd` follow a cross-link | +| **Move** (every view) | `hjkl` or arrows · `gh gj gk gl` tree moves · `w` `b` next / previous in reading order · `gg` root · `G` last · `/` `n` `N` search (`/` every document) · `Ctrl-o` `Ctrl-i` (Tab) jump back / forward · `m{a-z}` `'{a-z}` marks (`A–Z` across documents) · Enter or `gf` through a portal · `gu` up to the portals that lead here · `gd` follow a cross-link | | **View** | `zz` center · `Ctrl-e` `Ctrl-y` `zh` `zl` pan · `Ctrl-d` `Ctrl-u` half a screen · `+` `-` zoom · `za zo zc zR zM` folds · `zf` Auto · `zi` focus · `K` body card | | **Create and edit** | `o` `O` sibling below / above · `a` child · `i` `A` Enter edit the title · `I` edit from its start · `cc` `S` retype | | **Operators** | `dd` or `dab` delete branch · `dib` delete children · `x` delete just the node · `yy` `yab` `yib` yank · `p` paste as children · `P` paste as siblings below · `>>` `<<` indent / outdent · `"a`–`"z` and `"+` registers · `.` repeat · `u` `Ctrl-r` undo / redo | | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal | -| **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | +| **Leader** `` | `c` color · `s` style · `B` border · `t` tags · `=` tidy (`m` map, `c` chart, `g` grid) · `p` new portal (`:portal `) · `o` this branch out, to a document of its own · `/` search every document · `i` inbox · `b` documents · `v` then `c`/`o`/`n` for canvas / outline / note · `w` which-key on/off · `?` help | | **Panes, documents** | `Ctrl-w v` `Ctrl-w s` split · `Ctrl-w hjkl` focus · `Ctrl-w w` the next pane · `Ctrl-w c` close · `Ctrl-w o` only · `Ctrl-w =` even sizes · `gt` `gT` `]b` `[b` next / previous document · `ZZ` close pane (the last one quits) | | **Outline** | `j` `k` rows · `h` `l` parent / first child · `zb` every body · `M` then `j` `k` (`J` `K`: 5) swap with siblings, `h` out, `l` under the one above | | **Note view** | `j` `k` parts · Enter `i` `A` edit the part (`I` from its start, `cc` `S` anew), or go to that link or child · `h` `l` the sibling before / after | @@ -749,7 +766,8 @@ cumulus/ (keys every view shares), calls (`:` commands), text_edit (a node's text being edited, rapid entry), registers (text or nodes), clipboard (the system's, for "+), yank (nodes into registers and back), visual (selections), repeat (`.`), - search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), + search (`/`, and `/` across documents), jumps (each pane's jump list), + marks (capital marks, `:marks`), connect (connect mode), portals (what portals show, going through them, `:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split tree), export (:export, :import), popup (help, menu, pickers, card, questions) @@ -757,8 +775,8 @@ cumulus/ The end-to-end tests are in `app/tests.rs` (with `app/tests/files.rs`, `app/tests/config.rs`, `app/tests/vim.rs`, `app/tests/connect.rs`, -`app/tests/portals.rs`, `app/tests/arrange.rs`, `app/tests/branch_out.rs` and -`app/tests/export.rs`): +`app/tests/portals.rs`, `app/tests/arrange.rs`, `app/tests/branch_out.rs`, +`app/tests/export.rs` and `app/tests/everywhere.rs`): they copy the sample documents to a temporary folder, type keys and click on a test terminal, and check the screen, the documents and the files. diff --git a/README.md b/README.md index a5392ba..b92b5c5 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The plan for the everyday keys (all of them are in DESIGN.md): | `a` | in visual mode: line the selection up, `h` `l` `k` `j` left, right, top, bottom, `c` middles (`:align`; `:` in visual mode runs a command on the selection) | | `C` | connect: go to another node, Enter links them (`d` removes the line, `>` puts it under that node, `` `a` `l` `c` `s` `t` style it, Esc takes it all back) | | `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one | +| `/` | search every document (open, recent, this folder, and what portals lead to); `n` `N` then go from document to document (`:search text`) | | `Ctrl-o` `Tab` | back to where you jumped from / forward again (across documents) | | `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document | | `gd` | follow a cross-link | diff --git a/src/app/action.rs b/src/app/action.rs index 867d169..f7e0814 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -11,6 +11,7 @@ use super::mode::Connect; use super::panes::Arrange; use super::popup::{MenuFor, PickKind}; use super::repeat::Look; +use super::search::LastSearch; use super::text_edit::StartEdit; use crate::command::parse::{ExportFormat, SetArg}; use crate::config::ConfigFile; @@ -200,8 +201,12 @@ pub enum AppAction { OpenHelp(String), /// `/`: the search. OpenSearch, + /// `/`: the search across documents. + OpenSearchEverywhere, + /// `:search`: straight to the best match in every document. + SearchEverywhere(String), /// A search went somewhere: `n` and `N` go on with it. - Searched(String), + Searched(LastSearch), /// `Ctrl-o` (`back`) and `Ctrl-i`: through the focused pane's jump list. Travel { back: bool, diff --git a/src/app/calls.rs b/src/app/calls.rs index 04c8d9e..78e1dde 100644 --- a/src/app/calls.rs +++ b/src/app/calls.rs @@ -72,6 +72,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec unlink(ctx, target), Call::Find(text) => app(AppAction::Find(text)), + Call::Search(text) => app(AppAction::SearchEverywhere(text)), Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], Call::Version => say(Message::info(crate::version())), Call::Portal(path) => app(AppAction::NewPortal(path)), diff --git a/src/app/commands.rs b/src/app/commands.rs index 8bfebd1..2c2ba01 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -31,6 +31,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option> { let cursor = ctx.cursor; let actions = match typed.command { Command::Search => app(AppAction::OpenSearch), + Command::SearchEverywhere => app(AppAction::OpenSearchEverywhere), Command::JumpBack => app(AppAction::Travel { back: true, times }), Command::JumpForward => app(AppAction::Travel { back: false, times }), Command::Move(Motion::SearchNext) => app(AppAction::FindAgain { forward: true, times }), diff --git a/src/app/mod.rs b/src/app/mod.rs index 01f1527..6c86867 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -105,7 +105,7 @@ pub struct App { /// `.` is making a change again: it isn't recorded again. repeating: bool, /// The last search, for `n` and `N`. - last_search: Option, + last_search: Option, screen: Screen, /// The pane a mouse drag started in: it gets the whole drag. dragging: Option, diff --git a/src/app/portals.rs b/src/app/portals.rs index 2253912..a213e3b 100644 --- a/src/app/portals.rs +++ b/src/app/portals.rs @@ -129,7 +129,7 @@ pub fn folder_of(path: &Path) -> PathBuf { } /// The document in a file. -fn read(path: &Path) -> Result { +pub(super) fn read(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|error| error.to_string())?; document_file::parse(&text).map_err(|error| error.to_string()) } diff --git a/src/app/search.rs b/src/app/search.rs index 151e36f..f097b10 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -14,9 +14,19 @@ //! ╰ 1 of 2 · ↑↓ move · Enter go there · Esc close ────────────────╯ //! ``` //! +//! `/` (and `:search`) does the same across documents (part 9d): the +//! open ones, the ones opened lately, the ones in this document's folder, +//! and every one their portals and backlinks lead to (the user's pick). The +//! matches are one list, best first, each with its document's name beside +//! it; Enter opens that document there, and `n` and `N` then go through +//! every match, from document to document, as vim's `:cnext` does. +//! //! Case only matters once the search has a capital in it, as with vim's //! `smartcase`. +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; @@ -25,11 +35,15 @@ use ratatui::widgets::{Block, Clear}; use super::App; use super::action::{Action, AppAction, DocAction, Message}; +use super::jumps::Place; use super::mode::Mode; +use super::portals::{folder_of, read}; use crate::command::complete::fuzzy_match; use crate::input::key::{Key, KeyName}; use crate::input::mouse::{Cell, Gesture}; -use crate::model::{Content, Direction, Document, NodeId}; +use crate::model::{Content, Direction, DocUid, Document, NodeId}; +use crate::storage::link_paths::resolve; +use crate::storage::names; use crate::ui::text_input::TextInput; use crate::view::note::NoteView; use crate::view::{View, ViewContext}; @@ -44,11 +58,15 @@ const MOST_WIDTH: u16 = 120; const MOST_HEIGHT: u16 = 30; /// Narrower than this, the note isn't shown beside the matches. const PREVIEW_FROM: u16 = 60; +/// The most documents a search across documents reads. +const MOST_DOCUMENTS: usize = 200; /// A node a search found. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Found { pub id: NodeId, + /// Its document, among those searched (0 in a search of one document). + pub doc: usize, score: i32, /// Where it matched. pub place: Where, @@ -76,6 +94,46 @@ impl Where { } } +/// The last search, for `n` and `N`: what was searched, and for a search +/// across documents, its matches as they were then, best first. `n` and `N` +/// go through those, as vim's `:cnext` goes through a `:vimgrep`'s list: +/// searched again from each document, equal matches would change places. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LastSearch { + pub query: String, + pub hits: Option>, +} + +/// A match of a search across documents: where it is, its document's name, +/// and where in the node it matched (`in the title`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hit { + pub place: Place, + pub document: String, + pub words: &'static str, +} + +/// The matches of a search across documents, as `n` and `N` go through +/// them. +fn hits(searched: &[Searched], found: &[Found]) -> Vec { + found + .iter() + .filter_map(|found| { + let document = searched.get(found.doc)?; + let place = Place { uid: document.doc.uid(), path: document.path.clone(), node: found.id }; + Some(Hit { place, document: document.doc.title().to_string(), words: found.place.words() }) + }) + .collect() +} + +/// A document a search across documents looks in: where it is, and what it +/// holds (the open ones as they are, the others as their files have them). +#[derive(Debug, Clone)] +pub struct Searched { + pub path: PathBuf, + pub doc: Document, +} + /// The nodes that match `query`, best first. An empty query matches every /// node, in reading order. pub fn find(doc: &Document, query: &str) -> Vec { @@ -87,7 +145,10 @@ pub fn find(doc: &Document, query: &str) -> Vec { .filter_map(|(order, id)| { let node = doc.node(id)?; if query.is_empty() { - return Some((order, Found { id, score: 0, place: Where::Title, letters: Vec::new() })); + return Some(( + order, + Found { id, doc: 0, score: 0, place: Where::Title, letters: Vec::new() }, + )); } let title = fuzzy_match(query, node.title().as_str(), ignore_case) .map(|(score, letters)| (score + TITLE_BONUS, Where::Title, letters)); @@ -109,13 +170,25 @@ pub fn find(doc: &Document, query: &str) -> Vec { }; let (score, place, letters) = [title, tags, body].into_iter().flatten().max_by_key(|(score, _, _)| *score)?; - Some((order, Found { id, score, place, letters })) + Some((order, Found { id, doc: 0, score, place, letters })) }) .collect(); found.sort_by_key(|(order, found)| (-found.score, *order)); found.into_iter().map(|(_, found)| found).collect() } +/// The nodes that match `query` in all these documents, best first (and in +/// the documents' order between equals). +pub fn find_all(searched: &[Searched], query: &str) -> Vec { + let mut found: Vec = searched + .iter() + .enumerate() + .flat_map(|(doc, one)| find(&one.doc, query).into_iter().map(move |found| Found { doc, ..found })) + .collect(); + found.sort_by_key(|found| (-found.score, found.doc)); + found +} + /// The search popup. #[derive(Debug, Clone)] pub struct Search { @@ -129,6 +202,9 @@ pub struct Search { /// land on these). area: Rect, rows: Rect, + /// The documents searched across (`/`); empty for `/`, which + /// searches the one the pane shows. + everywhere: Vec, } impl Search { @@ -141,6 +217,20 @@ impl Search { scroll: 0, area: Rect::default(), rows: Rect::default(), + everywhere: Vec::new(), + } + } + + /// The search across these documents (the shown one first). + pub fn everywhere(searched: Vec) -> Search { + Search { + input: TextInput::default(), + found: find_all(&searched, ""), + selected: 0, + scroll: 0, + area: Rect::default(), + rows: Rect::default(), + everywhere: searched, } } @@ -157,7 +247,11 @@ impl Search { (KeyName::PageUp, _) => self.move_by(-page), _ => { if self.input.edit(key) { - self.found = find(ctx.doc, self.input.text()); + self.found = if self.everywhere.is_empty() { + find(ctx.doc, self.input.text()) + } else { + find_all(&self.everywhere, self.input.text()) + }; (self.selected, self.scroll) = (0, 0); } } @@ -195,18 +289,31 @@ impl Search { } } - /// Enter: to the match you're on. A search with text in it is kept for - /// `n` and `N`. + /// Enter: to the match you're on (in its document, opened if it was + /// closed). A search with text in it is kept for `n` and `N`. fn go(&self) -> Vec { let Some(found) = self.found.get(self.selected) else { return leave() }; let mut actions = leave(); - actions.push(Action::jump(found.id)); + let in_doc = match self.everywhere.get(found.doc) { + Some(searched) => { + let place = Place { uid: searched.doc.uid(), path: searched.path.clone(), node: found.id }; + actions.push(Action::App(AppAction::GoToPlace(place))); + format!("{} · ", searched.doc.title()) + } + None => { + actions.push(Action::jump(found.id)); + String::new() + } + }; let query = self.input.text(); if !query.is_empty() { - actions.push(Action::App(AppAction::Searched(query.to_string()))); + let everywhere = !self.everywhere.is_empty(); + let hits = everywhere.then(|| hits(&self.everywhere, &self.found)); + actions.push(Action::App(AppAction::Searched(LastSearch { query: query.to_string(), hits }))); let (at, count) = (self.selected + 1, self.found.len()); + let what = if everywhere { format!("/{query} in every document") } else { format!("/{query}") }; actions.push(Action::say(Message::info(format!( - "/{query} · {at} of {count} · {}", + "{what} · {at} of {count} · {in_doc}{}", found.place.words() )))); } @@ -252,13 +359,15 @@ impl Search { } else { format!(" {} of {} · ↑↓ move · Enter go there · Esc close ", self.selected + 1, self.found.len()) }; + let title = match self.everywhere.len() { + 0 => format!(" search · {} ", ctx.doc.title()), + 1 => " search · 1 document ".to_string(), + count => format!(" search · {count} documents "), + }; let block = Block::bordered() .border_set(theme.glyphs.frame(true)) .border_style(muted) - .title(Line::styled( - format!(" search · {} ", ctx.doc.title()), - accent.add_modifier(Modifier::BOLD), - )) + .title(Line::styled(title, accent.add_modifier(Modifier::BOLD))) .title_bottom(Line::styled(footer, muted)); frame.render_widget(block, self.area); let inner = inner(self.area); @@ -272,8 +381,22 @@ impl Search { if index == self.selected { buf.set_string(list.x, y, &bar, accent); } - let line = row(found, ctx, accent.add_modifier(Modifier::BOLD), muted); - buf.set_line(list.x + 1, y, &line, list.width.saturating_sub(1)); + let line = row(found, &self.ctx_for(found, ctx), accent.add_modifier(Modifier::BOLD), muted); + let width = list.width.saturating_sub(1); + buf.set_line(list.x + 1, y, &line, width); + // The document's name, at the row's right end. + if let Some(searched) = self.everywhere.get(found.doc) { + let name = format!(" {} ", searched.doc.title()); + let room = usize::from(width); + let taken = line.width(); + let name_width = unicode_width::UnicodeWidthStr::width(name.as_str()); + let at = room.saturating_sub(name_width).max(taken.min(room)); + if at < room { + let x = list.x + 1 + u16::try_from(at).unwrap_or(u16::MAX); + let fits = u16::try_from(room - at).unwrap_or(0); + buf.set_line(x, y, &Line::styled(name, muted), fits); + } + } } if let Some(preview) = preview { let line = theme.glyphs.frame(false).vertical_left; @@ -281,7 +404,7 @@ impl Search { buf.set_string(preview.x - 1, y, line, muted); } if let Some(found) = self.found.get(self.selected) { - let ctx = ViewContext { cursor: found.id, mode: &Mode::Normal, ..*ctx }; + let ctx = ViewContext { cursor: found.id, mode: &Mode::Normal, ..self.ctx_for(found, ctx) }; let mut note = NoteView::preview(); note.layout(preview, &ctx); note.render(frame, &ctx); @@ -290,6 +413,19 @@ impl Search { let col = u16::try_from(1 + 1 + self.input.cursor_column()).unwrap_or(u16::MAX); frame.set_cursor_position(Position::new((list.x + col).min(list.right().saturating_sub(1)), list.y)); } + + /// What a match's document looks like to a view: the pane's own, or one + /// of the documents searched across. + fn ctx_for<'a>(&'a self, found: &Found, ctx: &ViewContext<'a>) -> ViewContext<'a> { + match self.everywhere.get(found.doc) { + Some(searched) => ViewContext { + doc: &searched.doc, + folder: searched.path.parent().unwrap_or(Path::new(".")), + ..*ctx + }, + None => *ctx, + } + } } /// A match's row: the title, with the letters that matched standing out, @@ -351,49 +487,89 @@ fn position(cell: Cell) -> Position { Position::new(cell.col, cell.row) } +/// `n` (`forward`) or `N`, `times` over, from the match at `at` among +/// `count` (off them: `n` goes to the best, `N` to the last), going round. +/// Returns where it lands, and whether it went round. +fn step(mut at: Option, count: usize, forward: bool, times: u32) -> (usize, bool) { + let mut wrapped = false; + for _ in 0..times.max(1) { + at = Some(match (at, forward) { + (None, true) => 0, + (None, false) => count - 1, + (Some(last), true) if last + 1 == count => { + wrapped = true; + 0 + } + (Some(0), false) => { + wrapped = true; + count - 1 + } + (Some(here), true) => here + 1, + (Some(here), false) => here - 1, + }); + } + (at.unwrap_or(0), wrapped) +} + +/// What `n` and `N` add when they went round. +fn went_round(wrapped: bool, forward: bool) -> &'static str { + match (wrapped, forward) { + (false, _) => "", + (true, true) => " · back to the best match", + (true, false) => " · round to the last match", + } +} + impl App { /// `n` and `N`: the next (or previous) match of the last search, `times` /// over, going round. Off the matches, `n` goes to the best one. pub(super) fn find_again(&mut self, forward: bool, times: u32) -> Option { - let Some(query) = self.last_search.clone() else { + let Some(LastSearch { query, hits }) = self.last_search.clone() else { return Some(Message::info("no search yet: / starts one")); }; + if let Some(hits) = hits { + return self.find_again_everywhere(&query, &hits, forward, times); + } let found = find(self.current().doc(), &query); let count = found.len(); if count == 0 { return Some(Message::problem(format!("nothing matches /{query}"))); } let cursor = self.current().cursor(); - let mut at = found.iter().position(|found| found.id == cursor); - let mut wrapped = false; - for _ in 0..times.max(1) { - at = Some(match (at, forward) { - // Not on a match: n goes to the best one, N to the last. - (None, true) => 0, - (None, false) => count - 1, - (Some(last), true) if last + 1 == count => { - wrapped = true; - 0 - } - (Some(0), false) => { - wrapped = true; - count - 1 - } - (Some(here), true) => here + 1, - (Some(here), false) => here - 1, - }); - } - let at = at.unwrap_or(0); + let (at, wrapped) = step(found.iter().position(|found| found.id == cursor), count, forward, times); let said = self.doc_action(DocAction::MoveCursor { to: found[at].id, jump: true }); let place = found[at].place.words(); - let again = match (wrapped, forward) { - (false, _) => "", - (true, true) => " · back to the best match", - (true, false) => " · round to the last match", - }; + let again = went_round(wrapped, forward); said.or_else(|| Some(Message::info(format!("/{query} · {} of {count} · {place}{again}", at + 1)))) } + /// `n` and `N` after a search across documents: through every match, + /// from document to document (opening it if it was closed), as vim's + /// `:cnext` goes through a `:vimgrep`'s. + fn find_again_everywhere( + &mut self, + query: &str, + hits: &[Hit], + forward: bool, + times: u32, + ) -> Option { + let count = hits.len(); + if count == 0 { + return Some(Message::problem(format!("nothing matches /{query} in any document"))); + } + let (uid, cursor) = (self.current().doc().uid(), self.current().cursor()); + let here = hits.iter().position(|hit| hit.place.uid == uid && hit.place.node == cursor); + let (at, wrapped) = step(here, count, forward, times); + let Hit { place, document: name, words } = &hits[at]; + let said = self.jump_to_place(place); + let again = went_round(wrapped, forward); + said.filter(|said| said.tone == super::action::Tone::Problem).or_else(|| { + let text = + format!("/{query} in every document · {} of {count} · {name} · {words}{again}", at + 1); + Some(Message::info(text)) + }) + } + /// `:find`: straight to the best match, kept for `n` and `N`. pub(super) fn find_now(&mut self, query: String) -> Option { let found = find(self.current().doc(), &query); @@ -401,10 +577,99 @@ impl App { return Some(Message::problem(format!("nothing matches /{query}"))); }; let (id, place, count) = (best.id, best.place.words(), found.len()); - self.last_search = Some(query.clone()); + self.last_search = Some(LastSearch { query: query.clone(), hits: None }); let said = self.doc_action(DocAction::MoveCursor { to: id, jump: true }); said.or_else(|| Some(Message::info(format!("/{query} · 1 of {count} · {place}")))) } + + /// `:search`: straight to the best match in every document, kept for `n` + /// and `N`. + pub(super) fn search_now(&mut self, query: String) -> Option { + let searched = self.search_scope(); + let found = find_all(&searched, &query); + self.last_search = Some(LastSearch { query: query.clone(), hits: Some(hits(&searched, &found)) }); + let Some(best) = found.first() else { + return Some(Message::problem(format!("nothing matches /{query} in any document"))); + }; + let document = &searched[best.doc]; + let place = Place { uid: document.doc.uid(), path: document.path.clone(), node: best.id }; + let (name, words, count) = (document.doc.title().to_string(), best.place.words(), found.len()); + let said = self.jump_to_place(&place); + said.filter(|said| said.tone == super::action::Tone::Problem).or_else(|| { + Some(Message::info(format!("/{query} in every document · 1 of {count} · {name} · {words}"))) + }) + } + + /// What `/` searches (the user's pick in part 9d): the document + /// the pane shows first, then the other open ones, the ones opened + /// lately, the documents in this one's folder, and every document their + /// portals and backlinks lead to, however far (up to a couple hundred). + pub(super) fn search_scope(&self) -> Vec { + let mut scope = Scope::default(); + let here = self.buffer(); + let open = std::iter::once(self.shared.docs.get(here)) + .chain(self.shared.docs.iter().filter(|open| open.id != here)); + for open in open { + scope.add(open.file.path.clone(), open.buffer.doc().clone()); + } + let mut files: Vec = self.memory.recent.iter().map(|recent| recent.path.clone()).collect(); + if let Ok(entries) = std::fs::read_dir(self.folder()) { + let mut here: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|extension| extension == names::EXTENSION)) + .collect(); + here.sort(); + files.extend(here); + } + for path in files { + scope.read(path); + } + // Through portals and backlinks, breadth first. + let mut at = 0; + while at < scope.searched.len() && scope.searched.len() < MOST_DOCUMENTS { + let folder = folder_of(&scope.searched[at].path); + let doc = &scope.searched[at].doc; + let next: Vec = doc + .portals() + .map(|(_, portal)| resolve(&folder, &portal.path)) + .chain(doc.backlinks().iter().map(|backlink| resolve(&folder, &backlink.from))) + .collect(); + for path in next { + scope.read(path); + } + at += 1; + } + scope.searched + } +} + +/// The documents a search across documents gathers, each once (by its uid +/// and by its file). +#[derive(Default)] +struct Scope { + searched: Vec, + uids: BTreeSet, + paths: BTreeSet, +} + +impl Scope { + fn add(&mut self, path: PathBuf, doc: Document) { + if self.searched.len() < MOST_DOCUMENTS && !self.paths.contains(&path) && self.uids.insert(doc.uid()) + { + self.paths.insert(path.clone()); + self.searched.push(Searched { path, doc }); + } + } + + /// The document in a file, unless it's here already (or isn't one). + fn read(&mut self, path: PathBuf) { + if !self.paths.contains(&path) + && let Ok(doc) = read(&path) + { + self.add(path, doc); + } + } } #[cfg(test)] diff --git a/src/app/tests.rs b/src/app/tests.rs index 7b17215..d77287b 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -7,6 +7,7 @@ mod arrange; mod branch_out; mod config; mod connect; +mod everywhere; mod export; mod files; mod portals; diff --git a/src/app/tests/everywhere.rs b/src/app/tests/everywhere.rs new file mode 100644 index 0000000..b05b017 --- /dev/null +++ b/src/app/tests/everywhere.rs @@ -0,0 +1,73 @@ +//! The search across documents (part 9d), end to end: the open documents, +//! the ones in the folder, and the ones portals lead to. + +use super::*; + +/// A pantry in the garden's folder (not open), with a portal to an attic in +/// a folder of its own: only the portal leads there. +fn with_pantry_and_attic(h: &Harness) { + let pantry = r#"(format: 1, uid: "00000000000000b1", root: 1, nodes: [ + (id: 1, title: "Pantry", pos: (0, 0)), + (id: 2, parent: 1, title: "Compost tea", pos: (20, 0)), + (id: 3, parent: 1, portal: (path: "rooms/attic.cum", uid: "00000000000000a7", title: "Attic"), pos: (20, 4)), + ])"#; + let attic = r#"(format: 1, uid: "00000000000000a7", root: 1, nodes: [ + (id: 1, title: "Attic", pos: (0, 0)), + (id: 2, parent: 1, title: "an old compost heap", pos: (20, 0)), + ])"#; + std::fs::write(h.path("pantry.cum"), pantry).unwrap(); + std::fs::create_dir_all(h.path("rooms")).unwrap(); + std::fs::write(h.path("rooms").join("attic.cum"), attic).unwrap(); +} + +#[test] +fn space_slash_searches_every_document() { + let mut h = Harness::new(); + with_pantry_and_attic(&h); + h.type_keys("/"); + assert!(h.screen().contains("search · 4 documents"), "{}", h.screen()); + h.type_keys("compost"); + // Each match with its document's name beside it. + for (title, document) in + [("Compost tea", "Pantry"), ("an old compost heap", "Attic"), ("Compost bin", "Kitchen")] + { + let both = h.rows().iter().any(|row| row.contains(title) && row.contains(document)); + assert!(both, "{title} in {document}:\n{}", h.screen()); + } + h.type_keys(""); + assert!(h.message().starts_with("/compost in every document · 1 of 4 · "), "{}", h.message()); + // n goes through every match, document to document, and round. + let mut order = vec![(h.doc().title().to_string(), h.cursor())]; + for _ in 0..3 { + h.type_keys("n"); + order.push((h.doc().title().to_string(), h.cursor())); + } + let mut seen = order.clone(); + seen.sort(); + let expected = [ + ("Attic", "an old compost heap"), + ("Garden plans", "ask about compost"), + ("Kitchen", "Compost bin"), + ("Pantry", "Compost tea"), + ]; + assert_eq!(seen, expected.map(|(doc, node)| (doc.to_string(), node.to_string()))); + h.type_keys("n"); + assert!(h.message().ends_with("back to the best match"), "{}", h.message()); + // Each was a jump: Ctrl-o goes back to the last one. + h.type_keys(""); + assert_eq!((h.doc().title().to_string(), h.cursor()), order[3]); +} + +#[test] +fn search_goes_straight_to_the_best_match() { + let mut h = Harness::new(); + with_pantry_and_attic(&h); + h.type_keys(":search heap"); + assert_eq!(h.message(), "/heap in every document · 1 of 2 · Attic · in the title"); + assert_eq!((h.doc().title().as_str(), h.cursor().as_str()), ("Attic", "an old compost heap")); + h.type_keys(":search nothing like it"); + assert_eq!(h.message(), "nothing matches /nothing like it in any document"); + // / still searches just the document shown. + h.type_keys("/"); + assert!(h.screen().contains("search · Attic"), "{}", h.screen()); +} diff --git a/src/app/update.rs b/src/app/update.rs index 7b80735..ee08b47 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -181,7 +181,12 @@ impl App { AppAction::OpenSearch => { self.shared.mode = Mode::Popup(Popup::Search(Search::new(self.current().doc()))); } - AppAction::Searched(query) => self.last_search = Some(query), + AppAction::OpenSearchEverywhere => { + let searched = self.search_scope(); + self.shared.mode = Mode::Popup(Popup::Search(Search::everywhere(searched))); + } + AppAction::SearchEverywhere(query) => return self.search_now(query), + AppAction::Searched(search) => self.last_search = Some(search), AppAction::FindAgain { forward, times } => return self.find_again(forward, times), AppAction::Travel { back, times } => return self.travel(back, times), AppAction::SetFileMark(name) => return self.set_file_mark(name), diff --git a/src/command/parse.rs b/src/command/parse.rs index 7f266a1..560955b 100644 --- a/src/command/parse.rs +++ b/src/command/parse.rs @@ -97,6 +97,8 @@ pub enum Call { Tidy(Layout), Align(Side), Find(String), + /// `:search`: every document. + Search(String), Backlinks, Jumps, Marks, @@ -259,6 +261,7 @@ fn build(spec: &Spec, bang: bool, args: &[Option<&str>], sources: &Sources) -> R Id::Tidy => Call::Tidy(first.map(layout).transpose()?.unwrap_or(Layout::Map)), Id::Align => Call::Align(side(text)?), Id::Find => Call::Find(text.to_string()), + Id::Search => Call::Search(text.to_string()), Id::Backlinks => Call::Backlinks, Id::Jumps => Call::Jumps, Id::Marks => Call::Marks, @@ -546,6 +549,7 @@ mod tests { ("fold", "open"), ("align", "left"), ("find", "beds"), + ("search", "beds"), ("set", "mouse"), ("theme", "light"), ("export", "md"), diff --git a/src/command/registry.rs b/src/command/registry.rs index 11ffb53..3eccb98 100644 --- a/src/command/registry.rs +++ b/src/command/registry.rs @@ -39,6 +39,7 @@ pub enum CommandId { Tidy, Align, Find, + Search, Backlinks, Jumps, Marks, @@ -308,6 +309,13 @@ pub const SPECS: &[Spec] = &[ spec(Id::Align, "align", "al", &[required("side", K::Choice(SIDES))], "line up the selected nodes"), // Finding. spec(Id::Find, "find", "fin", &[required("text", K::Text)], "search titles, bodies and #tags"), + spec( + Id::Search, + "search", + "sea", + &[required("text", K::Text)], + "search every document, to the best match", + ), spec(Id::Backlinks, "backlinks", "back", &[], "the portals that lead here"), spec(Id::Jumps, "jumps", "ju", &[], "the jump list"), spec(Id::Marks, "marks", "marks", &[], "the marks"), diff --git a/src/input/bindings.rs b/src/input/bindings.rs index 8906382..6ed5eec 100644 --- a/src/input/bindings.rs +++ b/src/input/bindings.rs @@ -238,6 +238,7 @@ const NORMAL: &[DefaultBinding] = &[ bind("t", C::EditTags), bind("p", C::NewPortal), bind("o", C::BranchOut), + bind("/", C::SearchEverywhere), bind("=m", C::Tidy(Layout::Map)), bind("=c", C::Tidy(Layout::Chart)), bind("=g", C::Tidy(Layout::Grid)), diff --git a/src/input/command.rs b/src/input/command.rs index 8633067..6398eda 100644 --- a/src/input/command.rs +++ b/src/input/command.rs @@ -215,6 +215,8 @@ pub enum Command { // Jumping around. Search, + /// `/`: search every document. + SearchEverywhere, JumpBack, JumpForward, FollowPortal, @@ -437,6 +439,7 @@ pub const COMMANDS: &[CommandInfo] = &[ with_char("set-mark", CharKey::SetMark, "mark this node"), with_char("jump-to-mark", CharKey::JumpToMark, "go to a mark"), info("search", C::Search, "search"), + info("search-everywhere", C::SearchEverywhere, "search every document"), info("jump-back", C::JumpBack, "jump back"), info("jump-forward", C::JumpForward, "jump forward"), info("follow-portal", C::FollowPortal, "through the portal"),