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"),