Search every document (stage 9d)

<Space>/ (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 <[email protected]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-02 12:15:04 -04:00
1 parent 9d15a054f2
commit 893edbda9f
15 files changed
+438 -52

No files matched your search

+23 -5
View File
@@ -317,6 +317,23 @@ lines.
tags, and lines after a `| ` are the body of the node above. Looks and tags, and lines after a `| ` are the body of the node above. Looks and
cross-links stay out of both exports. cross-links stay out of both exports.
**Searching every document** (settled in part 9d)
- `<Space>/` (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) **Views, panes and popups** (settled in stage 7)
- The Note view is made of parts: the title, the tags, the body, each link - 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 (out, then in) and each child. `j` and `k` step through them, with a bar
@@ -541,12 +558,12 @@ text).
| Group | Keys | | 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 (`<Space>/` 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 | | **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 | | **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 | | **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 | | **Modes** | `v` `V` visual · `M` move · `C` connect · `:` command · Esc back to normal |
| **Leader** `<Space>` | `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** `<Space>` | `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) | | **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 | | **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 | | **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 (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 edited, rapid entry), registers (text or nodes), clipboard (the system's, for
"+), yank (nodes into registers and back), visual (selections), repeat (`.`), "+), yank (nodes into registers and back), visual (selections), repeat (`.`),
search (`/`), jumps (each pane's jump list), marks (capital marks, `:marks`), search (`/`, and `<Space>/` across documents), jumps (each pane's jump list),
marks (capital marks, `:marks`),
connect (connect mode), portals (what portals show, going through them, connect (connect mode), portals (what portals show, going through them,
`:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split `:portal`, backlinks, renames), branch_out (`:branchout`), panes (the split
tree), export (:export, :import), popup (help, menu, pickers, card, questions) 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`, 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/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/portals.rs`, `app/tests/arrange.rs`, `app/tests/branch_out.rs`,
`app/tests/export.rs`): `app/tests/export.rs` and `app/tests/everywhere.rs`):
they copy the sample they copy the sample
documents to a temporary folder, type keys and click on a test terminal, and documents to a temporary folder, type keys and click on a test terminal, and
check the screen, the documents and the files. check the screen, the documents and the files.
+1
View File
@@ -92,6 +92,7 @@ The plan for the everyday keys (all of them are in DESIGN.md):
| `<Space>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) | | `<Space>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, `<Space>` `a` `l` `c` `s` `t` style it, Esc takes it all back) | | `C` | connect: go to another node, Enter links them (`d` removes the line, `>` puts it under that node, `<Space>` `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 | | `/` `n` `N` | search: titles, tags and bodies, fuzzily, with the note of each match beside it / the next match / the previous one |
| `<Space>/` | 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) | | `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 | | `ma` `'a` `mA` | mark a node / go back to it / a capital mark: from any document |
| `gd` | follow a cross-link | | `gd` | follow a cross-link |
+6 -1
View File
@@ -11,6 +11,7 @@ use super::mode::Connect;
use super::panes::Arrange; use super::panes::Arrange;
use super::popup::{MenuFor, PickKind}; use super::popup::{MenuFor, PickKind};
use super::repeat::Look; use super::repeat::Look;
use super::search::LastSearch;
use super::text_edit::StartEdit; use super::text_edit::StartEdit;
use crate::command::parse::{ExportFormat, SetArg}; use crate::command::parse::{ExportFormat, SetArg};
use crate::config::ConfigFile; use crate::config::ConfigFile;
@@ -200,8 +201,12 @@ pub enum AppAction {
OpenHelp(String), OpenHelp(String),
/// `/`: the search. /// `/`: the search.
OpenSearch, OpenSearch,
/// `<Space>/`: 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. /// 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. /// `Ctrl-o` (`back`) and `Ctrl-i`: through the focused pane's jump list.
Travel { Travel {
back: bool, back: bool,
+1
View File
@@ -72,6 +72,7 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>, on: &[NodeId]) -> Vec<Acti
}, },
Call::Unlink(target) => unlink(ctx, target), Call::Unlink(target) => unlink(ctx, target),
Call::Find(text) => app(AppAction::Find(text)), Call::Find(text) => app(AppAction::Find(text)),
Call::Search(text) => app(AppAction::SearchEverywhere(text)),
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))], Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
Call::Version => say(Message::info(crate::version())), Call::Version => say(Message::info(crate::version())),
Call::Portal(path) => app(AppAction::NewPortal(path)), Call::Portal(path) => app(AppAction::NewPortal(path)),
+1
View File
@@ -31,6 +31,7 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let cursor = ctx.cursor; let cursor = ctx.cursor;
let actions = match typed.command { let actions = match typed.command {
Command::Search => app(AppAction::OpenSearch), Command::Search => app(AppAction::OpenSearch),
Command::SearchEverywhere => app(AppAction::OpenSearchEverywhere),
Command::JumpBack => app(AppAction::Travel { back: true, times }), Command::JumpBack => app(AppAction::Travel { back: true, times }),
Command::JumpForward => app(AppAction::Travel { back: false, times }), Command::JumpForward => app(AppAction::Travel { back: false, times }),
Command::Move(Motion::SearchNext) => app(AppAction::FindAgain { forward: true, times }), Command::Move(Motion::SearchNext) => app(AppAction::FindAgain { forward: true, times }),
+1 -1
View File
@@ -105,7 +105,7 @@ pub struct App {
/// `.` is making a change again: it isn't recorded again. /// `.` is making a change again: it isn't recorded again.
repeating: bool, repeating: bool,
/// The last search, for `n` and `N`. /// The last search, for `n` and `N`.
last_search: Option<String>, last_search: Option<search::LastSearch>,
screen: Screen, screen: Screen,
/// The pane a mouse drag started in: it gets the whole drag. /// The pane a mouse drag started in: it gets the whole drag.
dragging: Option<PaneId>, dragging: Option<PaneId>,
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn folder_of(path: &Path) -> PathBuf {
} }
/// The document in a file. /// The document in a file.
fn read(path: &Path) -> Result<Document, String> { pub(super) fn read(path: &Path) -> Result<Document, String> {
let text = fs::read_to_string(path).map_err(|error| error.to_string())?; let text = fs::read_to_string(path).map_err(|error| error.to_string())?;
document_file::parse(&text).map_err(|error| error.to_string()) document_file::parse(&text).map_err(|error| error.to_string())
} }
+308 -43
View File
@@ -14,9 +14,19 @@
//! ╰ 1 of 2 · ↑↓ move · Enter go there · Esc close ────────────────╯ //! ╰ 1 of 2 · ↑↓ move · Enter go there · Esc close ────────────────╯
//! ``` //! ```
//! //!
//! `<Space>/` (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 //! Case only matters once the search has a capital in it, as with vim's
//! `smartcase`. //! `smartcase`.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use ratatui::Frame; use ratatui::Frame;
use ratatui::layout::{Position, Rect}; use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
@@ -25,11 +35,15 @@ use ratatui::widgets::{Block, Clear};
use super::App; use super::App;
use super::action::{Action, AppAction, DocAction, Message}; use super::action::{Action, AppAction, DocAction, Message};
use super::jumps::Place;
use super::mode::Mode; use super::mode::Mode;
use super::portals::{folder_of, read};
use crate::command::complete::fuzzy_match; use crate::command::complete::fuzzy_match;
use crate::input::key::{Key, KeyName}; use crate::input::key::{Key, KeyName};
use crate::input::mouse::{Cell, Gesture}; 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::ui::text_input::TextInput;
use crate::view::note::NoteView; use crate::view::note::NoteView;
use crate::view::{View, ViewContext}; use crate::view::{View, ViewContext};
@@ -44,11 +58,15 @@ const MOST_WIDTH: u16 = 120;
const MOST_HEIGHT: u16 = 30; const MOST_HEIGHT: u16 = 30;
/// Narrower than this, the note isn't shown beside the matches. /// Narrower than this, the note isn't shown beside the matches.
const PREVIEW_FROM: u16 = 60; const PREVIEW_FROM: u16 = 60;
/// The most documents a search across documents reads.
const MOST_DOCUMENTS: usize = 200;
/// A node a search found. /// A node a search found.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Found { pub struct Found {
pub id: NodeId, pub id: NodeId,
/// Its document, among those searched (0 in a search of one document).
pub doc: usize,
score: i32, score: i32,
/// Where it matched. /// Where it matched.
pub place: Where, 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<Vec<Hit>>,
}
/// 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<Hit> {
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 /// The nodes that match `query`, best first. An empty query matches every
/// node, in reading order. /// node, in reading order.
pub fn find(doc: &Document, query: &str) -> Vec<Found> { pub fn find(doc: &Document, query: &str) -> Vec<Found> {
@@ -87,7 +145,10 @@ pub fn find(doc: &Document, query: &str) -> Vec<Found> {
.filter_map(|(order, id)| { .filter_map(|(order, id)| {
let node = doc.node(id)?; let node = doc.node(id)?;
if query.is_empty() { 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) let title = fuzzy_match(query, node.title().as_str(), ignore_case)
.map(|(score, letters)| (score + TITLE_BONUS, Where::Title, letters)); .map(|(score, letters)| (score + TITLE_BONUS, Where::Title, letters));
@@ -109,13 +170,25 @@ pub fn find(doc: &Document, query: &str) -> Vec<Found> {
}; };
let (score, place, letters) = let (score, place, letters) =
[title, tags, body].into_iter().flatten().max_by_key(|(score, _, _)| *score)?; [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(); .collect();
found.sort_by_key(|(order, found)| (-found.score, *order)); found.sort_by_key(|(order, found)| (-found.score, *order));
found.into_iter().map(|(_, found)| found).collect() 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<Found> {
let mut found: Vec<Found> = 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. /// The search popup.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Search { pub struct Search {
@@ -129,6 +202,9 @@ pub struct Search {
/// land on these). /// land on these).
area: Rect, area: Rect,
rows: Rect, rows: Rect,
/// The documents searched across (`<Space>/`); empty for `/`, which
/// searches the one the pane shows.
everywhere: Vec<Searched>,
} }
impl Search { impl Search {
@@ -141,6 +217,20 @@ impl Search {
scroll: 0, scroll: 0,
area: Rect::default(), area: Rect::default(),
rows: Rect::default(), rows: Rect::default(),
everywhere: Vec::new(),
}
}
/// The search across these documents (the shown one first).
pub fn everywhere(searched: Vec<Searched>) -> 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), (KeyName::PageUp, _) => self.move_by(-page),
_ => { _ => {
if self.input.edit(key) { 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); (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 /// Enter: to the match you're on (in its document, opened if it was
/// `n` and `N`. /// closed). A search with text in it is kept for `n` and `N`.
fn go(&self) -> Vec<Action> { fn go(&self) -> Vec<Action> {
let Some(found) = self.found.get(self.selected) else { return leave() }; let Some(found) = self.found.get(self.selected) else { return leave() };
let mut actions = 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(); let query = self.input.text();
if !query.is_empty() { 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 (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!( actions.push(Action::say(Message::info(format!(
"/{query} · {at} of {count} · {}", "{what} · {at} of {count} · {in_doc}{}",
found.place.words() found.place.words()
)))); ))));
} }
@@ -252,13 +359,15 @@ impl Search {
} else { } else {
format!(" {} of {} · ↑↓ move · Enter go there · Esc close ", self.selected + 1, self.found.len()) 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() let block = Block::bordered()
.border_set(theme.glyphs.frame(true)) .border_set(theme.glyphs.frame(true))
.border_style(muted) .border_style(muted)
.title(Line::styled( .title(Line::styled(title, accent.add_modifier(Modifier::BOLD)))
format!(" search · {} ", ctx.doc.title()),
accent.add_modifier(Modifier::BOLD),
))
.title_bottom(Line::styled(footer, muted)); .title_bottom(Line::styled(footer, muted));
frame.render_widget(block, self.area); frame.render_widget(block, self.area);
let inner = inner(self.area); let inner = inner(self.area);
@@ -272,8 +381,22 @@ impl Search {
if index == self.selected { if index == self.selected {
buf.set_string(list.x, y, &bar, accent); buf.set_string(list.x, y, &bar, accent);
} }
let line = row(found, ctx, accent.add_modifier(Modifier::BOLD), muted); let line = row(found, &self.ctx_for(found, ctx), accent.add_modifier(Modifier::BOLD), muted);
buf.set_line(list.x + 1, y, &line, list.width.saturating_sub(1)); 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 { if let Some(preview) = preview {
let line = theme.glyphs.frame(false).vertical_left; let line = theme.glyphs.frame(false).vertical_left;
@@ -281,7 +404,7 @@ impl Search {
buf.set_string(preview.x - 1, y, line, muted); buf.set_string(preview.x - 1, y, line, muted);
} }
if let Some(found) = self.found.get(self.selected) { 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(); let mut note = NoteView::preview();
note.layout(preview, &ctx); note.layout(preview, &ctx);
note.render(frame, &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); 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)); 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, /// 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) 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<usize>, 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 { impl App {
/// `n` and `N`: the next (or previous) match of the last search, `times` /// `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. /// over, going round. Off the matches, `n` goes to the best one.
pub(super) fn find_again(&mut self, forward: bool, times: u32) -> Option<Message> { pub(super) fn find_again(&mut self, forward: bool, times: u32) -> Option<Message> {
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")); 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 found = find(self.current().doc(), &query);
let count = found.len(); let count = found.len();
if count == 0 { if count == 0 {
return Some(Message::problem(format!("nothing matches /{query}"))); return Some(Message::problem(format!("nothing matches /{query}")));
} }
let cursor = self.current().cursor(); let cursor = self.current().cursor();
let mut at = found.iter().position(|found| found.id == cursor); let (at, wrapped) = step(found.iter().position(|found| found.id == cursor), count, forward, times);
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 said = self.doc_action(DocAction::MoveCursor { to: found[at].id, jump: true }); let said = self.doc_action(DocAction::MoveCursor { to: found[at].id, jump: true });
let place = found[at].place.words(); let place = found[at].place.words();
let again = match (wrapped, forward) { let again = went_round(wrapped, forward);
(false, _) => "",
(true, true) => " · back to the best match",
(true, false) => " · round to the last match",
};
said.or_else(|| Some(Message::info(format!("/{query} · {} of {count} · {place}{again}", at + 1)))) 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<Message> {
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`. /// `:find`: straight to the best match, kept for `n` and `N`.
pub(super) fn find_now(&mut self, query: String) -> Option<Message> { pub(super) fn find_now(&mut self, query: String) -> Option<Message> {
let found = find(self.current().doc(), &query); let found = find(self.current().doc(), &query);
@@ -401,10 +577,99 @@ impl App {
return Some(Message::problem(format!("nothing matches /{query}"))); return Some(Message::problem(format!("nothing matches /{query}")));
}; };
let (id, place, count) = (best.id, best.place.words(), found.len()); 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 }); let said = self.doc_action(DocAction::MoveCursor { to: id, jump: true });
said.or_else(|| Some(Message::info(format!("/{query} · 1 of {count} · {place}")))) 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<Message> {
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 `<Space>/` 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<Searched> {
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<PathBuf> = self.memory.recent.iter().map(|recent| recent.path.clone()).collect();
if let Ok(entries) = std::fs::read_dir(self.folder()) {
let mut here: Vec<PathBuf> = 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<PathBuf> = 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<Searched>,
uids: BTreeSet<DocUid>,
paths: BTreeSet<PathBuf>,
}
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)] #[cfg(test)]
+1
View File
@@ -7,6 +7,7 @@ mod arrange;
mod branch_out; mod branch_out;
mod config; mod config;
mod connect; mod connect;
mod everywhere;
mod export; mod export;
mod files; mod files;
mod portals; mod portals;
+73
View File
@@ -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("<Space>/");
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("<CR>");
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("<C-o>");
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<CR>");
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<CR>");
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());
}
+6 -1
View File
@@ -181,7 +181,12 @@ impl App {
AppAction::OpenSearch => { AppAction::OpenSearch => {
self.shared.mode = Mode::Popup(Popup::Search(Search::new(self.current().doc()))); 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::FindAgain { forward, times } => return self.find_again(forward, times),
AppAction::Travel { back, times } => return self.travel(back, times), AppAction::Travel { back, times } => return self.travel(back, times),
AppAction::SetFileMark(name) => return self.set_file_mark(name), AppAction::SetFileMark(name) => return self.set_file_mark(name),
+4
View File
@@ -97,6 +97,8 @@ pub enum Call {
Tidy(Layout), Tidy(Layout),
Align(Side), Align(Side),
Find(String), Find(String),
/// `:search`: every document.
Search(String),
Backlinks, Backlinks,
Jumps, Jumps,
Marks, 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::Tidy => Call::Tidy(first.map(layout).transpose()?.unwrap_or(Layout::Map)),
Id::Align => Call::Align(side(text)?), Id::Align => Call::Align(side(text)?),
Id::Find => Call::Find(text.to_string()), Id::Find => Call::Find(text.to_string()),
Id::Search => Call::Search(text.to_string()),
Id::Backlinks => Call::Backlinks, Id::Backlinks => Call::Backlinks,
Id::Jumps => Call::Jumps, Id::Jumps => Call::Jumps,
Id::Marks => Call::Marks, Id::Marks => Call::Marks,
@@ -546,6 +549,7 @@ mod tests {
("fold", "open"), ("fold", "open"),
("align", "left"), ("align", "left"),
("find", "beds"), ("find", "beds"),
("search", "beds"),
("set", "mouse"), ("set", "mouse"),
("theme", "light"), ("theme", "light"),
("export", "md"), ("export", "md"),
+8
View File
@@ -39,6 +39,7 @@ pub enum CommandId {
Tidy, Tidy,
Align, Align,
Find, Find,
Search,
Backlinks, Backlinks,
Jumps, Jumps,
Marks, Marks,
@@ -308,6 +309,13 @@ pub const SPECS: &[Spec] = &[
spec(Id::Align, "align", "al", &[required("side", K::Choice(SIDES))], "line up the selected nodes"), spec(Id::Align, "align", "al", &[required("side", K::Choice(SIDES))], "line up the selected nodes"),
// Finding. // Finding.
spec(Id::Find, "find", "fin", &[required("text", K::Text)], "search titles, bodies and #tags"), 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::Backlinks, "backlinks", "back", &[], "the portals that lead here"),
spec(Id::Jumps, "jumps", "ju", &[], "the jump list"), spec(Id::Jumps, "jumps", "ju", &[], "the jump list"),
spec(Id::Marks, "marks", "marks", &[], "the marks"), spec(Id::Marks, "marks", "marks", &[], "the marks"),
+1
View File
@@ -238,6 +238,7 @@ const NORMAL: &[DefaultBinding] = &[
bind("<leader>t", C::EditTags), bind("<leader>t", C::EditTags),
bind("<leader>p", C::NewPortal), bind("<leader>p", C::NewPortal),
bind("<leader>o", C::BranchOut), bind("<leader>o", C::BranchOut),
bind("<leader>/", C::SearchEverywhere),
bind("<leader>=m", C::Tidy(Layout::Map)), bind("<leader>=m", C::Tidy(Layout::Map)),
bind("<leader>=c", C::Tidy(Layout::Chart)), bind("<leader>=c", C::Tidy(Layout::Chart)),
bind("<leader>=g", C::Tidy(Layout::Grid)), bind("<leader>=g", C::Tidy(Layout::Grid)),
+3
View File
@@ -215,6 +215,8 @@ pub enum Command {
// Jumping around. // Jumping around.
Search, Search,
/// `<Space>/`: search every document.
SearchEverywhere,
JumpBack, JumpBack,
JumpForward, JumpForward,
FollowPortal, FollowPortal,
@@ -437,6 +439,7 @@ pub const COMMANDS: &[CommandInfo] = &[
with_char("set-mark", CharKey::SetMark, "mark this node"), with_char("set-mark", CharKey::SetMark, "mark this node"),
with_char("jump-to-mark", CharKey::JumpToMark, "go to a mark"), with_char("jump-to-mark", CharKey::JumpToMark, "go to a mark"),
info("search", C::Search, "search"), info("search", C::Search, "search"),
info("search-everywhere", C::SearchEverywhere, "search every document"),
info("jump-back", C::JumpBack, "jump back"), info("jump-back", C::JumpBack, "jump back"),
info("jump-forward", C::JumpForward, "jump forward"), info("jump-forward", C::JumpForward, "jump forward"),
info("follow-portal", C::FollowPortal, "through the portal"), info("follow-portal", C::FollowPortal, "through the portal"),