notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
cce-notes: backlinks, outline, search, rename, [[ completion, MCP (milestone 3)
- right pane: Backlinks (linked mentions grouped by note with their lines,
then unlinked mentions) and Outline (headings; a click scrolls the
reading view or moves the source caret)
- left pane gains a Search tab: full text, every word required, grouped by
note; `#tag` / `tag:x` lists tagged notes. A tag click in the reading
view opens it; Enter opens the first hit; `cce-notes search` too
- rename (F2 or a click on the title): previews the link count from
plan_rename, then Index::rename rewrites every link across the vault
- `[[` completion in source mode under the caret: shortest link text that
still resolves, closed with `]]`, one undo step. Painted as a hole the
editor is drawn around, since text draws over every plate
- MCP on 127.0.0.1:3002 (CCE_NOTES_MCP_PORT): search, find_notes,
read_note, backlinks, open_note, append_daily, current_note
- fix: load_text clears the TextBox undo history, which let Ctrl+Z after
switching notes in source mode restore the previous note's text
- the reading view maps list items to lines, so a search hit or task
lands on its item rather than its list
- status bar counts linking notes, as the backlinks pane does
Verified in scale-1 and scale-2 shadows against a copy of the vault.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 64 +++--
src/complete.rs | 118 ++++++++++
src/instance.rs | 11 +-
src/main.rs | 715 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/mcp.rs | 210 +++++++++++++++++
src/panel.rs | 163 +++++++++++++
src/reading.rs | 9 +-
src/side.rs | 180 ++++++++++++++
8 files changed, 1426 insertions(+), 44 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7102585..b1d7163 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,9 +1,11 @@
# cce-notes
-The vault's notes editor — milestone 2 of the Obsidian-on-cce plan, built
-on `cce-vault` (milestone 1). A file tree on the left and one note on the
-right, shown as rendered Markdown (**reading view**) or edited as text
-(**source mode**), toggled with Ctrl+E as in Obsidian.
+The vault's notes editor — milestones 2 and 3 of the Obsidian-on-cce plan,
+built on `cce-vault` (milestone 1). Three panes: files or search on the
+left, one note in the middle — shown as rendered Markdown (**reading
+view**) or edited as text (**source mode**), toggled with Ctrl+E as in
+Obsidian — and backlinks or the outline on the right. Agents reach the
+same vault through its MCP tools.
Read `../cce-vault/CLAUDE.md` first: the index, watcher, writes and the
`markdown` document model this app draws all live there, along with their
@@ -14,9 +16,13 @@ proposal".
| File | What it owns |
| --- | --- |
-| `main.rs` | The `Application`: panes, modes, history, quick switcher, autosave, conflicts, input |
+| `main.rs` | The `Application`: panes, modes, history, switcher + rename prompt, completion, autosave, conflicts, input |
| `reading.rs` | Lays out `cce_vault::markdown::Block`s at a width into draw items + click targets |
| `tree.rs` | The file tree's rows (folders first, case-insensitive, collapsible) |
+| `panel.rs` | The scrollable header/title/line list both side panes draw through |
+| `side.rs` | What the panes list: backlinks + unlinked mentions, outline, text and tag search |
+| `complete.rs` | `[[` completion: the open link at the caret, the shortest link text, the splice |
+| `mcp.rs` | MCP tools (`search`, `find_notes`, `read_note`, `backlinks`, `open_note`, `append_daily`, `current_note`) |
| `instance.rs` | Single instance on `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock`; the CLI's commands |
## Behaviour worth knowing before changing it
@@ -33,6 +39,26 @@ proposal".
- **Source mode autosaves** 1.5 s after typing stops (`AUTOSAVE_AFTER`,
polled through `idle_poll_interval` only while dirty), on leaving a note,
on switching to reading, and in `on_exit`.
+- **Rename (F2, or a click on the title in the band)** goes through
+ `Index::rename`, which rewrites every link to the note across the vault;
+ the prompt previews the count from `plan_rename` before Enter. History
+ entries follow the rename.
+- **`[[` completion** opens while the caret sits in an unclosed `[[` on
+ its line (`complete::open_link`; a `|` or `#` ends it). Enter/Tab
+ inserts the shortest link text that still resolves (`link_text`) and
+ `]]`, recording one undo step (`TextBox::history.record`); Escape shuts
+ it for that link. It needs the caret's pixel position, which TextBox
+ does not expose: `completion_rect` recomputes it from the public
+ `wrap_text`/`char_width`/`line_height`/`scroll_*` the way TextBox's own
+ `selection_quads` does (monospace, 8 px inner pad) — change both if
+ TextBox's padding changes.
+- **Popups over text:** text draws after every plate, so the completion
+ popup is a *hole*: the editor is painted four times, clipped to the
+ bands around the popup (clips intersect), and the popup fills the gap.
+ The quick switcher instead stops painting what it covers.
+- **`load_text` clears the TextBox undo history** and re-syncs
+ `editor_state`; without that, Ctrl+Z after switching notes in source
+ mode stepped back into the previous note's text.
- **Links:** click in reading, Ctrl+click in source (the caret's byte
offset is matched against `cce_vault::parse` link spans). An unresolved
link creates the note at the vault root and opens it in source mode, as
@@ -75,14 +101,23 @@ today, which a long note can churn).
```sh
cce-notes [--vault <dir>] [open] <note>[#heading] [line] # line is 1-based
cce-notes daily [YYYY-MM-DD]
-cce-notes search <query> # opens the quick switcher holding it
+cce-notes search <query> # the search pane holding it (#tag works)
```
A second launch forwards its command to the running instance and exits.
Keys come from input.kdl's `cce-notes` domain: `quick_switcher` (ctrl+o),
`toggle_mode` (ctrl+e), `save` (ctrl+s), `reload` (ctrl+r), `back`
(alt+arrowleft), `forward` (alt+arrowright), `toggle_tree` (ctrl+\\),
-`daily` (alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
+`toggle_side` (ctrl+]), `search` (ctrl+shift+f), `rename` (f2), `daily`
+(alt+d), `quit` (ctrl+q). Mouse back/forward walk the history too.
+
+## MCP
+
+`claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp`.
+Only the single instance serves it, and only with a vault. Tools run on
+the app's event loop against the window's own index, so a write to a note
+open and dirty in the window raises the usual conflict. A shadow instance
+must move the port (`CCE_NOTES_MCP_PORT=3902`) — the live one holds 3002.
## Testing
@@ -91,11 +126,14 @@ tree and the socket commands. Anything visual goes through a shadow at
`--scale 2` with `CCE_FONTS_DIR` set, against a **copy** of the vault
(`CCE_VAULT=<copy>`) — never the live vault, which syncs to other devices.
Fullscreen the window first (`ctl set-mode fullscreen cce-notes`): the
-scale-2 shadow output is only 640×360 logical.
+scale-2 shadow output is only 640×360 logical, too narrow for the right
+pane (it yields below a 360 px note) — check three-pane layout in a
+scale-1 shadow (1280×720) and pointer/caret maths at scale 2.
-## Not done yet (milestone 2 → 3)
+## Not done yet
-Backlinks and outline panes, `[[` completion, rename, the search pane, tag
-search (a tag click does nothing yet), MCP tools, embedded images, the
-icon (`Icon=cce-notes` has no SVG in cce-icons yet), per-note scroll in
-the history, and the move of the reading view into cce-ui.
+Heading completion (`[[Note#`), rendered snippets in the panes (they show
+raw lines), search debounce for large vaults (it scans every note per
+keystroke), embedded images, the icon (`Icon=cce-notes` has no SVG in
+cce-icons yet), per-note scroll in the history, and the move of the
+reading view into cce-ui.
diff --git a/src/complete.rs b/src/complete.rs
new file mode 100644
index 0000000..7eedd17
--- /dev/null
+++ b/src/complete.rs
@@ -0,0 +1,118 @@
+//! `[[` completion in source mode: find the link being typed at the caret,
+//! and write the chosen note back in the shortest form that still reaches
+//! it, as Obsidian does. Positions are char indices (the TextBox's own
+//! `cursor_idx` unit), not bytes.
+
+use cce_vault::index::stem;
+use cce_vault::{FileKind, Index};
+
+/// How many choices the popup offers.
+pub const LIMIT: usize = 8;
+
+/// An open `[[` before `cursor` on the caret's line, with nothing closing
+/// it yet: the char index just past the `[[`, and what has been typed
+/// since. A `|` (display text) or `#` (a heading) ends completion — the
+/// note part is already chosen by then.
+pub fn open_link(text: &str, cursor: usize) -> Option<(usize, String)> {
+ let chars: Vec<char> = text.chars().collect();
+ let cursor = cursor.min(chars.len());
+ let mut i = cursor;
+ while i >= 2 {
+ let c = chars[i - 1];
+ if c == '\n' || c == ']' || c == '|' || c == '#' {
+ return None;
+ }
+ if c == '[' && chars[i - 2] == '[' {
+ let query: String = chars[i..cursor].iter().collect();
+ return Some((i, query));
+ }
+ i -= 1;
+ }
+ None
+}
+
+/// What to write between the brackets for `path`: its bare name when that
+/// resolves back to it from `from`, else its vault path; `.md` dropped,
+/// other extensions kept.
+pub fn link_text(ix: &Index, from: Option<&str>, path: &str) -> String {
+ let is_note = FileKind::of(path) == FileKind::Note;
+ let file = path.rsplit('/').next().unwrap_or(path);
+ let short = if is_note { stem(path).to_string() } else { file.to_string() };
+ if ix.resolve_text(from, &short).as_deref() == Some(path) {
+ short
+ } else if is_note {
+ path.strip_suffix(".md").unwrap_or(path).to_string()
+ } else {
+ path.to_string()
+ }
+}
+
+/// Choices for a query: fuzzy over names, aliases and paths.
+pub fn choices(ix: &Index, query: &str) -> Vec<String> {
+ if query.trim().is_empty() {
+ let mut notes: Vec<(&String, u64)> =
+ ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, e)| (p, e.mtime)).collect();
+ notes.sort_by(|a, b| b.1.cmp(&a.1));
+ return notes.into_iter().take(LIMIT).map(|(p, _)| p.clone()).collect();
+ }
+ ix.find(query, LIMIT).into_iter().map(|m| m.path).collect()
+}
+
+/// Replace chars `start..cursor` with `link`, closing it with `]]` unless
+/// the text already closes it there. Returns the new text and the caret
+/// just past the closing brackets.
+pub fn apply(text: &str, start: usize, cursor: usize, link: &str) -> (String, usize) {
+ let chars: Vec<char> = text.chars().collect();
+ let cursor = cursor.min(chars.len());
+ let closed = chars.get(cursor) == Some(&']') && chars.get(cursor + 1) == Some(&']');
+ let mut out: String = chars[..start].iter().collect();
+ out.push_str(link);
+ if !closed {
+ out.push_str("]]");
+ }
+ let caret = start + link.chars().count() + 2;
+ out.extend(&chars[cursor..]);
+ (out, caret)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn finds_the_open_link_at_the_caret() {
+ let t = "see [[Pro";
+ assert_eq!(open_link(t, 9), Some((6, "Pro".into())));
+ assert_eq!(open_link("x [[", 4), Some((4, String::new())));
+ assert_eq!(open_link("[[A]] b", 7), None);
+ assert_eq!(open_link("[[A|sh", 6), None);
+ assert_eq!(open_link("[[A#h", 5), None);
+ assert_eq!(open_link("[[A\nb", 5), None);
+ assert_eq!(open_link("[x", 2), None);
+ // Non-ASCII before the link: indices are chars.
+ assert_eq!(open_link("é [[ab", 6), Some((4, "ab".into())));
+ }
+
+ #[test]
+ fn applies_and_closes_once() {
+ assert_eq!(apply("see [[Pro", 6, 9, "Projects"), ("see [[Projects]]".into(), 16));
+ assert_eq!(apply("[[Pro]] x", 2, 5, "Projects"), ("[[Projects]] x".into(), 12));
+ assert_eq!(apply("é [[a tail", 4, 5, "Alpha"), ("é [[Alpha]] tail".into(), 11));
+ }
+
+ #[test]
+ fn link_text_is_shortest_that_resolves() {
+ let dir = tempfile::tempdir().unwrap();
+ for p in ["a/Note.md", "b/Note.md", "Solo.md", "img/pic.png"] {
+ let abs = dir.path().join(p);
+ std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+ std::fs::write(abs, "x").unwrap();
+ }
+ let ix = Index::open(dir.path(), false).unwrap();
+ assert_eq!(link_text(&ix, None, "Solo.md"), "Solo");
+ assert_eq!(link_text(&ix, None, "img/pic.png"), "pic.png");
+ // Two notes share the name: from a/, the bare name reaches a/Note.
+ assert_eq!(link_text(&ix, Some("a/Other.md"), "a/Note.md"), "Note");
+ assert_eq!(link_text(&ix, Some("a/Other.md"), "b/Note.md"), "b/Note");
+ }
+}
diff --git a/src/instance.rs b/src/instance.rs
index f0d6f64..89c942c 100644
--- a/src/instance.rs
+++ b/src/instance.rs
@@ -9,7 +9,7 @@
//! - `open <path>[#heading] [line]` — a vault path, an absolute path inside
//! the vault, or a note name; `line` is 1-based.
//! - `daily [YYYY-MM-DD]` — open (creating it from the template) a day's note.
-//! - `search <query>` — open the quick switcher holding `query`.
+//! - `search <query>` — show the search pane holding `query` (`#tag` too).
//! - `show` — nothing but bringing the instance up.
//!
//! Connect before binding, as cce-browser does: a refused connect means a
@@ -143,10 +143,12 @@ fn try_forward(path: &str, cmd: &Command) -> bool {
BufReader::new(stream).read_line(&mut reply).is_ok()
}
-/// Serve the claimed listener on a thread, feeding the app's loop.
-pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
+/// Serve the claimed listener on a thread, feeding the app's loop. False
+/// when this process holds no listener (single-instance handling failed
+/// and it runs standalone).
+pub fn spawn_listener(sender: calloop::channel::Sender<Message>) -> bool {
let Some(listener) = CLAIMED.lock().unwrap().take() else {
- return;
+ return false;
};
std::thread::spawn(move || {
for conn in listener.incoming() {
@@ -168,6 +170,7 @@ pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
let _ = reader.get_mut().write_all(reply);
}
});
+ true
}
pub fn cleanup() {
diff --git a/src/main.rs b/src/main.rs
index 0373464..8d18209 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -20,6 +20,10 @@
mod instance;
mod reading;
mod tree;
+mod complete;
+mod mcp;
+mod panel;
+mod side;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -38,6 +42,7 @@ use cce_vault::{FileKind, Index, VaultWatcher};
use wayland_client::QueueHandle;
use instance::Command;
+use panel::{Action, Panel};
use reading::{srgb_u8, Hit, Layout, Measure, Theme};
const BAND_H: f32 = 40.0;
@@ -51,6 +56,13 @@ const READ_SIZE: f32 = 15.0;
const SWITCHER_W: f32 = 560.0;
const SWITCHER_ROW_H: f32 = 28.0;
const SWITCHER_ROWS: usize = 10;
+const SIDE_W: f32 = 260.0;
+/// The note pane's narrowest before the side pane gives way to it.
+const NOTE_MIN_W: f32 = 360.0;
+const TAB_H: f32 = 30.0;
+const SEARCH_H: f32 = 30.0;
+const COMPLETE_W: f32 = 320.0;
+const COMPLETE_ROW_H: f32 = 24.0;
/// Source mode saves once typing has paused this long.
const AUTOSAVE_AFTER: Duration = Duration::from_millis(1500);
@@ -64,6 +76,7 @@ const CONFLICT: [f32; 4] = [0.95, 0.45, 0.35, 1.0];
pub enum Message {
Command(Command),
VaultChanged(Vec<PathBuf>),
+ Mcp(cce_ui::mcp::McpToolCall),
Exit,
}
@@ -85,6 +98,9 @@ struct Keys {
back: String,
forward: String,
toggle_tree: String,
+ toggle_side: String,
+ search: String,
+ rename: String,
daily: String,
quit: String,
}
@@ -100,6 +116,9 @@ impl Keys {
back: get("back", "alt+arrowleft"),
forward: get("forward", "alt+arrowright"),
toggle_tree: get("toggle_tree", "ctrl+\\"),
+ toggle_side: get("toggle_side", "ctrl+]"),
+ search: get("search", "ctrl+shift+f"),
+ rename: get("rename", "f2"),
daily: get("daily", "alt+d"),
quit: get("quit", "ctrl+q"),
}
@@ -112,6 +131,30 @@ enum Mode {
Source,
}
+/// The left pane's tabs.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum LeftTab {
+ Files,
+ Search,
+}
+
+/// The right pane's tabs.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum SideTab {
+ Backlinks,
+ Outline,
+}
+
+/// `[[` completion in source mode: the link being typed and its choices.
+struct Completion {
+ /// Char index just past the `[[`.
+ start: usize,
+ query: String,
+ /// Vault paths, best first.
+ choices: Vec<String>,
+ selected: usize,
+}
+
/// One quick-switcher result: an existing note, or "create this".
#[derive(Clone, Debug)]
enum Choice {
@@ -124,6 +167,10 @@ struct Switcher {
selected: usize,
/// The query the choices were computed for.
query: String,
+ /// A rename prompt for this note rather than a switcher.
+ rename: Option<String>,
+ /// A line under the input: what a rename will do, or why it cannot.
+ hint: Option<(String, bool)>,
}
/// Widths through the renderer's own shaping entry, cached per run.
@@ -188,6 +235,24 @@ struct NotesApp {
switcher: Option<Switcher>,
switcher_input: Adapted<TextBox>,
+ left_tab: LeftTab,
+ search_input: Adapted<TextBox>,
+ search_panel: Panel,
+ /// The query the search panel shows results for.
+ search_seen: String,
+
+ show_side: bool,
+ side_tab: SideTab,
+ side_panel: Panel,
+ /// What the side panel was built for; a change resets its scroll.
+ side_key: Option<(String, SideTab)>,
+ side_dirty: bool,
+
+ completion: Option<Completion>,
+ /// A `[[` whose completion was dismissed with Escape: it stays shut
+ /// until the caret leaves that link.
+ completion_dismissed: Option<usize>,
+
conflict: bool,
status: Option<(String, bool)>,
@@ -202,8 +267,19 @@ struct NotesApp {
/// Where everything sits for a window size.
struct Metrics {
+ /// The whole left pane: tabs, then the tree or the search.
tree: Rect,
+ left_tabs: Rect,
+ /// The search box, when the search tab shows.
+ search_box: Rect,
+ /// Below the tabs (and search box): the tree's rows or the results.
+ tree_body: Rect,
note: Rect,
+ side: Rect,
+ side_tabs: Rect,
+ side_body: Rect,
+ /// The note's name in the band; a click renames it.
+ title: Rect,
mode_chip: Rect,
}
@@ -212,11 +288,29 @@ impl NotesApp {
let (w, h) = (self.width as f32, self.height as f32);
let body_h = (h - BAND_H - STATUS_H).max(0.0);
let tree_w = if self.show_tree && self.index.is_some() { TREE_W.min(w * 0.4) } else { 0.0 };
+ let side_w = if self.show_side && self.current.is_some() && w - tree_w - SIDE_W >= NOTE_MIN_W {
+ SIDE_W
+ } else {
+ 0.0
+ };
let inset = cce_ui::layout::root_plate_inset();
+ let tree = Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h };
+ let left_tabs = Rect { x: 0.0, y: BAND_H, width: tree_w, height: TAB_H };
+ let search_box = Rect { x: 8.0, y: BAND_H + TAB_H + 4.0, width: (tree_w - 16.0).max(0.0), height: SEARCH_H };
+ let body_top = if self.left_tab == LeftTab::Search { search_box.y + SEARCH_H + 4.0 } else { BAND_H + TAB_H };
+ let side = Rect { x: w - side_w, y: BAND_H, width: side_w, height: body_h };
+ let mode_chip = Rect { x: w - inset - 84.0, y: (BAND_H - 24.0) / 2.0, width: 84.0, height: 24.0 };
Metrics {
- tree: Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h },
- note: Rect { x: tree_w, y: BAND_H, width: w - tree_w, height: body_h },
- mode_chip: Rect { x: w - inset - 84.0, y: (BAND_H - 24.0) / 2.0, width: 84.0, height: 24.0 },
+ tree,
+ left_tabs,
+ search_box,
+ tree_body: Rect { x: 0.0, y: body_top, width: tree_w, height: (BAND_H + body_h - body_top).max(0.0) },
+ note: Rect { x: tree_w, y: BAND_H, width: w - tree_w - side_w, height: body_h },
+ side,
+ side_tabs: Rect { x: side.x, y: BAND_H, width: side_w, height: TAB_H },
+ side_body: Rect { x: side.x, y: BAND_H + TAB_H, width: side_w, height: (body_h - TAB_H).max(0.0) },
+ title: Rect { x: inset, y: 0.0, width: (mode_chip.x - 12.0 - inset).max(0.0), height: BAND_H },
+ mode_chip,
}
}
@@ -241,6 +335,7 @@ impl NotesApp {
let Some(ix) = &self.index else { return };
let notes = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str());
self.rows = self.tree.rows(notes);
+ self.tree_hover = None;
}
fn editor_text(&self) -> &str {
@@ -273,9 +368,16 @@ impl NotesApp {
self.editor.edit_buffer = text.clone();
self.editor.cursor_idx = 0;
self.editor.select_anchor = None;
+ self.editor.all_selected = false;
+ self.editor.sync_editor_state();
+ // Undo must not step back into another note's (or the pre-reload)
+ // text.
+ self.editor.history.clear();
+ self.completion = None;
self.edit_seen = text.clone();
self.saved = text;
self.conflict = false;
+ self.side_dirty = true;
self.invalidate();
}
@@ -341,7 +443,9 @@ impl NotesApp {
self.read_scroll = 0.0;
self.read_motion = ScrollMotion::new();
}
- self.pending_line = line;
+ if let Some(l) = line {
+ self.goto_line(l);
+ }
self.tree.reveal(path);
self.rebuild_rows();
if record {
@@ -409,8 +513,7 @@ impl NotesApp {
SpanLink::Url(url) => {
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
- // Tag search comes with the search pane.
- SpanLink::Tag(_) => {}
+ SpanLink::Tag(tag) => self.open_search(&format!("#{tag}")),
}
}
@@ -494,7 +597,7 @@ impl NotesApp {
match cmd {
Command::Open { target, line } => self.open_target(&target, line),
Command::Daily(d) => self.open_daily(d),
- Command::Search(q) => self.open_switcher(&q),
+ Command::Search(q) => self.open_search(&q),
Command::Show => {}
}
self.needs_rebuild = true;
@@ -506,12 +609,18 @@ impl NotesApp {
let Some(ix) = self.index.as_mut() else { return };
ix.apply_changes(&paths);
self.rebuild_rows();
- // Link colours depend on what resolves.
+ // Link colours, backlinks and search hits depend on every file.
self.layout = None;
+ self.side_dirty = true;
+ if self.left_tab == LeftTab::Search {
+ self.refresh_search(true);
+ }
let Some(cur) = self.current.clone() else { return };
let Some(ix) = self.index.as_ref() else { return };
let abs = ix.abs(&cur);
- if !paths.iter().any(|p| p == &abs || p.canonicalize().ok().as_deref() == abs.canonicalize().ok().as_deref()) {
+ let canon = abs.canonicalize().ok();
+ let touches = |p: &PathBuf| p == &abs || (canon.is_some() && p.canonicalize().ok() == canon);
+ if !paths.iter().any(touches) {
return;
}
match ix.read_text(&cur) {
@@ -536,7 +645,8 @@ impl NotesApp {
self.switcher_input.edit_buffer = query.to_string();
self.switcher_input.cursor_idx = query.chars().count();
self.switcher_input.select_anchor = None;
- self.switcher = Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into() });
+ self.switcher =
+ Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into(), rename: None, hint: None });
self.ui_context.set_focused(&mut self.switcher_input);
WidgetHost::focus(&mut self.switcher_input);
self.refresh_switcher();
@@ -567,6 +677,28 @@ impl NotesApp {
if sw.query == query {
return;
}
+ if let Some(from) = sw.rename.clone() {
+ sw.choices = Vec::new();
+ sw.hint = Some(match rename_target(&from, &query) {
+ None => ("Type the note's new name or path".into(), false),
+ Some(to) if to == from => ("Unchanged".into(), false),
+ Some(to) => match ix.plan_rename(&from, &to) {
+ Ok(plan) => {
+ let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
+ let what = if plan.edits.is_empty() {
+ "no links to update".to_string()
+ } else {
+ format!("updates {} in {}", side::plural(plan.edits.len(), "link"), side::plural(notes.len(), "note"))
+ };
+ (format!("Enter renames to {to} — {what}"), false)
+ }
+ Err(e) => (e.to_string(), true),
+ },
+ });
+ sw.query = query;
+ self.needs_rebuild = true;
+ return;
+ }
sw.choices = if query.trim().is_empty() {
// Recent first: the history, then the newest files.
let mut seen = std::collections::HashSet::new();
@@ -615,6 +747,20 @@ impl NotesApp {
fn switcher_pick(&mut self, create: bool) {
let Some(sw) = &self.switcher else { return };
let query = self.switcher_query();
+ if let Some(from) = sw.rename.clone() {
+ match rename_target(&from, &query) {
+ Some(to) if to != from => {
+ if sw.hint.as_ref().is_some_and(|h| h.1) {
+ return; // the plan already said why it cannot
+ }
+ self.close_switcher();
+ self.rename(&from, &to);
+ }
+ Some(_) => self.close_switcher(),
+ None => {}
+ }
+ return;
+ }
let choice = if create { Some(Choice::Create(query.trim().to_string())) } else { sw.choices.get(sw.selected).cloned() };
self.close_switcher();
match choice {
@@ -626,7 +772,7 @@ impl NotesApp {
fn switcher_rect(&self) -> Rect {
let w = SWITCHER_W.min(self.width as f32 - 32.0);
- let n = self.switcher.as_ref().map_or(0, |s| s.choices.len());
+ let n = self.switcher.as_ref().map_or(0, |s| s.choices.len() + usize::from(s.hint.is_some()));
Rect {
x: (self.width as f32 - w) / 2.0,
y: BAND_H + 24.0,
@@ -645,6 +791,240 @@ impl NotesApp {
(i < self.switcher.as_ref()?.choices.len()).then_some(i)
}
+ // ---- rename ----------------------------------------------------------
+
+ fn open_rename(&mut self) {
+ let Some(cur) = self.current.clone() else { return };
+ let shown = cur.strip_suffix(".md").unwrap_or(&cur).to_string();
+ self.open_switcher(&shown);
+ if let Some(sw) = self.switcher.as_mut() {
+ sw.rename = Some(cur);
+ sw.query = "\u{0}".into();
+ }
+ self.refresh_switcher();
+ }
+
+ /// Rename (or move) a note and rewrite every link to it, as Obsidian
+ /// does with "automatically update internal links" on.
+ fn rename(&mut self, from: &str, to: &str) {
+ if self.current.as_deref() == Some(from) && !self.save() {
+ return;
+ }
+ let Some(ix) = self.index.as_mut() else { return };
+ match ix.rename(from, to) {
+ Ok(plan) => {
+ for h in &mut self.history {
+ if h == from {
+ *h = plan.to.clone();
+ }
+ }
+ let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
+ let msg = format!(
+ "Renamed to {} · updated {} in {}",
+ plan.to,
+ side::plural(plan.edits.len(), "link"),
+ side::plural(notes.len(), "note")
+ );
+ if self.current.as_deref() == Some(from) {
+ // Its own self-links may have been rewritten too.
+ let to = plan.to.clone();
+ self.current = Some(to.clone());
+ if let Ok(text) = self.index.as_ref().map(|ix| ix.read_text(&to)).transpose().map(Option::unwrap_or_default) {
+ let scroll = self.read_scroll;
+ self.load_text(text);
+ self.read_scroll = scroll;
+ }
+ self.tree.reveal(&to);
+ }
+ self.rebuild_rows();
+ self.side_dirty = true;
+ self.layout = None;
+ self.set_status(msg, false);
+ }
+ Err(e) => self.set_status(format!("Could not rename: {e}"), true),
+ }
+ }
+
+ // ---- side panes ------------------------------------------------------
+
+ fn refresh_side(&mut self) {
+ self.side_dirty = false;
+ let (Some(ix), Some(cur)) = (&self.index, self.current.clone()) else {
+ self.side_panel.set(Vec::new(), true);
+ self.side_key = None;
+ return;
+ };
+ let items = match self.side_tab {
+ SideTab::Backlinks => side::backlinks(ix, &cur),
+ SideTab::Outline => side::outline(ix, &cur),
+ };
+ let key = (cur, self.side_tab);
+ let reset = self.side_key.as_ref() != Some(&key);
+ self.side_panel.set(items, reset);
+ self.side_key = Some(key);
+ }
+
+ fn search_query(&self) -> String {
+ if self.search_input.editing {
+ self.search_input.edit_buffer.clone()
+ } else {
+ self.search_input.text.clone()
+ }
+ }
+
+ /// Rerun the search if the query changed, or always with `force`.
+ fn refresh_search(&mut self, force: bool) {
+ let q = self.search_query();
+ if !force && q == self.search_seen {
+ return;
+ }
+ let Some(ix) = &self.index else { return };
+ let reset = q != self.search_seen;
+ self.search_panel.set(side::search(ix, &q), reset);
+ self.search_seen = q;
+ self.needs_rebuild = true;
+ }
+
+ /// Show the search tab holding `query` (a tag click, Ctrl+Shift+F).
+ fn open_search(&mut self, query: &str) {
+ self.left_tab = LeftTab::Search;
+ self.show_tree = true;
+ self.search_input.text = query.to_string();
+ self.search_input.edit_buffer = query.to_string();
+ self.search_input.cursor_idx = query.chars().count();
+ self.search_input.select_anchor = None;
+ self.search_input.sync_editor_state();
+ self.ui_context.set_focused(&mut self.search_input);
+ WidgetHost::focus(&mut self.search_input);
+ self.refresh_search(true);
+ self.invalidate();
+ }
+
+ fn run_action(&mut self, action: Action) {
+ match action {
+ Action::Open { path, line } => self.open_path(&path, line, true),
+ Action::Line(line) => self.goto_line(line),
+ }
+ }
+
+ /// Bring a 0-based source line of the open note into view: scroll the
+ /// reading view to its block, or put the source caret at its start.
+ fn goto_line(&mut self, line: usize) {
+ match self.mode {
+ Mode::Reading => self.pending_line = Some(line),
+ Mode::Source => {
+ let text = self.editor_text().to_string();
+ let idx: usize = text.split('\n').take(line).map(|l| l.chars().count() + 1).sum();
+ self.ui_context.set_focused(&mut self.editor);
+ WidgetHost::focus(&mut self.editor);
+ self.editor.edit_buffer = text;
+ self.editor.cursor_idx = idx.min(self.editor.edit_buffer.chars().count());
+ self.editor.select_anchor = None;
+ self.editor.all_selected = false;
+ self.editor.sync_editor_state();
+ self.editor.scroll_to_cursor();
+ }
+ }
+ self.needs_rebuild = true;
+ }
+
+ // ---- [[ completion -----------------------------------------------------
+
+ /// Open, update or close the completion for the caret's position.
+ fn update_completion(&mut self) {
+ if self.mode != Mode::Source || !self.editor.editing {
+ self.completion = None;
+ return;
+ }
+ let found = complete::open_link(&self.editor.edit_buffer, self.editor.cursor_idx);
+ let Some((start, query)) = found else {
+ self.completion = None;
+ self.completion_dismissed = None;
+ return;
+ };
+ if self.completion_dismissed == Some(start) {
+ return;
+ }
+ if self.completion.as_ref().is_some_and(|c| c.start == start && c.query == query) {
+ return;
+ }
+ let Some(ix) = &self.index else { return };
+ let choices = complete::choices(ix, &query);
+ self.completion = Some(Completion { start, query, choices, selected: 0 });
+ self.needs_rebuild = true;
+ }
+
+ /// Keys the open completion takes; true when it took this one.
+ fn completion_key(&mut self, key: &Key) -> bool {
+ let Some(c) = self.completion.as_mut() else { return false };
+ if c.choices.is_empty() {
+ return false;
+ }
+ let n = c.choices.len();
+ match key {
+ Key::Named(NamedKey::ArrowDown) => c.selected = (c.selected + 1) % n,
+ Key::Named(NamedKey::ArrowUp) => c.selected = (c.selected + n - 1) % n,
+ Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => self.accept_completion(),
+ Key::Named(NamedKey::Escape) => {
+ self.completion_dismissed = Some(c.start);
+ self.completion = None;
+ }
+ _ => return false,
+ }
+ self.needs_rebuild = true;
+ true
+ }
+
+ fn accept_completion(&mut self) {
+ let Some(c) = self.completion.take() else { return };
+ let (Some(ix), Some(path)) = (&self.index, c.choices.get(c.selected)) else { return };
+ let link = complete::link_text(ix, self.current.as_deref(), path);
+ let e = &mut self.editor;
+ let before = cce_ui::widget::editor::TextEditorState {
+ buffer: e.edit_buffer.clone(),
+ cursor_idx: e.cursor_idx,
+ select_anchor: e.select_anchor,
+ all_selected: e.all_selected,
+ };
+ let (text, caret) = complete::apply(&e.edit_buffer, c.start, e.cursor_idx, &link);
+ e.history.record(before);
+ e.edit_buffer = text;
+ e.cursor_idx = caret;
+ e.select_anchor = None;
+ e.all_selected = false;
+ e.sync_editor_state();
+ e.scroll_to_cursor();
+ }
+
+ /// The completion popup's rect, under the caret and inside the note pane.
+ fn completion_rect(&self, m: &Metrics) -> Option<Rect> {
+ let c = self.completion.as_ref().filter(|c| !c.choices.is_empty())?;
+ let e = &self.editor;
+ let r = self.editor_rect(m);
+ let (cw, lh) = (e.char_width(), e.line_height());
+ let max_chars = (((r.width - 16.0) / cw).floor() as usize).max(1);
+ let (_, map) = e.wrap_text(max_chars);
+ let (line, col) = *map.get(e.cursor_idx.min(map.len().saturating_sub(1)))?;
+ let caret_x = r.x + 8.0 + col as f32 * cw - e.scroll_x;
+ let caret_y = r.y + 8.0 + line as f32 * lh - e.scroll_y;
+ let h = c.choices.len() as f32 * COMPLETE_ROW_H + 8.0;
+ let w = COMPLETE_W.min(r.width);
+ let x = caret_x.min(r.x + r.width - w).max(r.x);
+ // Below the caret line, or above it when there is no room below.
+ let below = caret_y + lh + 2.0;
+ let y = if below + h <= r.y + r.height { below } else { (caret_y - h - 2.0).max(r.y) };
+ Some(Rect { x, y, width: w, height: h })
+ }
+
+ fn completion_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
+ let r = self.completion_rect(m)?;
+ if !contains(r, x, y) {
+ return None;
+ }
+ let i = ((y - r.y - 4.0) / COMPLETE_ROW_H).floor();
+ (i >= 0.0 && (i as usize) < self.completion.as_ref()?.choices.len()).then_some(i as usize)
+ }
+
// ---- layout and scrolling -------------------------------------------
fn ensure_layout(&mut self, width: f32) {
@@ -671,14 +1051,14 @@ impl NotesApp {
}
fn tree_max_scroll(&self, m: &Metrics) -> f32 {
- (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree.height).max(0.0)
+ (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree_body.height).max(0.0)
}
fn tree_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
- if x < m.tree.x || x > m.tree.x + m.tree.width || y < m.tree.y || y > m.tree.y + m.tree.height {
+ if self.left_tab != LeftTab::Files || !contains(m.tree_body, x, y) {
return None;
}
- let i = ((y - m.tree.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
+ let i = ((y - m.tree_body.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
(i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
}
@@ -778,7 +1158,8 @@ impl NotesApp {
} else if let (Some(cur), Some(ix)) = (&self.current, &self.index) {
let text = if self.mode == Mode::Source { self.editor_text() } else { self.saved.as_str() };
let words = text.split_whitespace().count();
- let back = ix.backlinks(cur).len();
+ // Notes linking here, as the backlinks pane counts them.
+ let back = ix.backlinks(cur).iter().filter(|b| b.source != cur).map(|b| b.source).collect::<std::collections::HashSet<_>>().len();
(format!("{} · {}", plural(words, "word"), plural(back, "backlink")), DIM)
} else {
(String::new(), DIM)
@@ -795,8 +1176,28 @@ impl NotesApp {
if m.tree.width <= 0.0 {
return;
}
- let t = m.tree;
- pc.recess_edges(t, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
+ pc.recess_edges(m.tree, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
+ let active = if self.left_tab == LeftTab::Files { 0 } else { 1 };
+ paint_tabs(pc, m.left_tabs, &["Files", "Search"], active);
+ if self.left_tab == LeftTab::Search {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search_input, pc);
+ if self.search_panel.items.is_empty() {
+ let (family, size) = cce_ui::layout::tree_font_parsed();
+ let b = m.tree_body;
+ pc.text_with(
+ "Words, or #tag",
+ b.x + 12.0,
+ b.y + 10.0,
+ (size * 0.9).round(),
+ srgb_u8(DIM),
+ Some(family),
+ Some([b.x, b.y, b.x + b.width, b.y + b.height]),
+ );
+ }
+ self.search_panel.paint(pc, m.tree_body);
+ return;
+ }
+ let t = m.tree_body;
let (family, size) = cce_ui::layout::tree_font_parsed();
pc.clip(t, |pc| {
for (i, row) in self.rows.iter().enumerate() {
@@ -869,7 +1270,26 @@ impl NotesApp {
return;
}
match self.mode {
- Mode::Source => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
+ Mode::Source => match self.completion_rect(m) {
+ // Text draws over every plate, so the popup cannot simply
+ // cover the editor: paint the editor four times, clipped to
+ // the bands around the popup, and the popup into the hole.
+ Some(hole) => {
+ let e = self.editor_rect(m);
+ let (l, t, r, b) = (e.x - 4.0, e.y - 4.0, e.x + e.width + 4.0, e.y + e.height + 4.0);
+ let bands = [
+ Rect { x: l, y: t, width: r - l, height: hole.y - t },
+ Rect { x: l, y: hole.y + hole.height, width: r - l, height: b - hole.y - hole.height },
+ Rect { x: l, y: hole.y, width: hole.x - l, height: hole.height },
+ Rect { x: hole.x + hole.width, y: hole.y, width: r - hole.x - hole.width, height: hole.height },
+ ];
+ for band in bands.into_iter().filter(|b| b.width > 0.0 && b.height > 0.0) {
+ pc.clip(band, |pc| cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc));
+ }
+ self.paint_completion(pc, hole);
+ }
+ None => cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, pc),
+ },
Mode::Reading => {
let (ox, oy, width) = self.reading_frame(m);
self.ensure_layout(width);
@@ -913,6 +1333,102 @@ impl NotesApp {
Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
);
}
+ if let Some((hint, err)) = &sw.hint {
+ let y = top + sw.choices.len() as f32 * SWITCHER_ROW_H;
+ let row = Rect { x: r.x + 6.0, y, width: r.width - 12.0, height: SWITCHER_ROW_H };
+ pc.text_with(
+ hint.clone(),
+ row.x + 10.0,
+ cce_ui::layout::align_text_y(row.y, row.height, size, 0.0),
+ size,
+ srgb_u8(if *err { CONFLICT } else { DIM }),
+ Some(family.clone()),
+ Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
+ );
+ }
+ }
+
+ fn paint_side(&self, pc: &mut PaintCtx, m: &Metrics) {
+ if m.side.width <= 0.0 {
+ return;
+ }
+ pc.recess_edges(m.side, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, false, false, true));
+ let active = if self.side_tab == SideTab::Backlinks { 0 } else { 1 };
+ paint_tabs(pc, m.side_tabs, &["Backlinks", "Outline"], active);
+ self.side_panel.paint(pc, m.side_body);
+ }
+
+ fn paint_completion(&self, pc: &mut PaintCtx, r: Rect) {
+ let Some(c) = &self.completion else { return };
+ pc.rounded_rect(r, 8.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_BG);
+ let (family, size) = cce_ui::layout::list_font_parsed();
+ for (i, path) in c.choices.iter().enumerate() {
+ let row = Rect { x: r.x + 4.0, y: r.y + 4.0 + i as f32 * COMPLETE_ROW_H, width: r.width - 8.0, height: COMPLETE_ROW_H };
+ if i == c.selected {
+ pc.rounded_rect(row, 5.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
+ }
+ let (dir, file) = match path.rsplit_once('/') {
+ Some((d, f)) => (Some(d), f),
+ None => (None, path.as_str()),
+ };
+ let name = file.strip_suffix(".md").unwrap_or(file);
+ let ty = cce_ui::layout::align_text_y(row.y, row.height, size, 0.0);
+ let bounds = Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]);
+ pc.text_with(name.to_string(), row.x + 8.0, ty, size, srgb_u8(FG), Some(family.clone()), bounds);
+ if let Some(d) = dir {
+ let nx = row.x + 8.0 + (name.chars().count() as f32 + 2.0) * size * 0.6;
+ pc.text_with(d.to_string(), nx, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
+ }
+ }
+ }
+}
+
+/// Equal-width text tabs across `r`, the active one underlined.
+fn paint_tabs(pc: &mut PaintCtx, r: Rect, labels: &[&str], active: usize) {
+ if r.width <= 0.0 {
+ return;
+ }
+ let (family, size) = cce_ui::layout::tree_font_parsed();
+ let w = r.width / labels.len() as f32;
+ for (i, label) in labels.iter().enumerate() {
+ let cell = Rect { x: r.x + i as f32 * w, y: r.y, width: w, height: r.height };
+ let lw = label.chars().count() as f32 * size * 0.6;
+ let color = if i == active { FG } else { DIM };
+ pc.text_with(
+ label.to_string(),
+ cell.x + ((cell.width - lw) / 2.0).max(4.0),
+ cce_ui::layout::align_text_y(cell.y, cell.height, size, 0.0),
+ size,
+ srgb_u8(color),
+ Some(family.clone()),
+ Some([cell.x, cell.y, cell.x + cell.width, cell.y + cell.height]),
+ );
+ if i == active {
+ let u = Rect { x: cell.x + 14.0, y: cell.y + cell.height - 3.0, width: (cell.width - 28.0).max(4.0), height: 2.0 };
+ pc.rounded_rect(u, 1.0, (true, true, true, true), cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]));
+ }
+ }
+}
+
+fn tab_at(r: Rect, n: usize, x: f32, y: f32) -> Option<usize> {
+ if r.width <= 0.0 || !contains(r, x, y) {
+ return None;
+ }
+ Some((((x - r.x) / (r.width / n as f32)) as usize).min(n - 1))
+}
+
+/// The vault path a rename prompt's text names: `.md` added unless it
+/// names another extension the note already had. `None` while empty.
+fn rename_target(from: &str, query: &str) -> Option<String> {
+ let q = query.trim().trim_start_matches('/').trim_end_matches('/');
+ if q.is_empty() {
+ return None;
+ }
+ let keeps_ext = !from.to_lowercase().ends_with(".md") && from.rsplit('.').next() == q.rsplit('.').next();
+ if q.to_lowercase().ends_with(".md") || keeps_ext {
+ Some(q.to_string())
+ } else {
+ Some(format!("{q}.md"))
}
}
@@ -920,9 +1436,7 @@ fn contains(r: Rect, x: f32, y: f32) -> bool {
x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height
}
-fn plural(n: usize, what: &str) -> String {
- format!("{n} {what}{}", if n == 1 { "" } else { "s" })
-}
+use side::plural;
fn vault_name(root: &Path) -> String {
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| root.display().to_string())
@@ -959,13 +1473,18 @@ impl Application for NotesApp {
},
Err(e) => (None, Some(e.clone()), None),
};
- instance::spawn_listener(sender.clone());
+ // Only the instance serves MCP: a second copy running standalone
+ // would fight it for the port.
+ if instance::spawn_listener(sender.clone()) && index.is_some() {
+ mcp::start(sender.clone());
+ }
let _ = sender.send(Message::Command(startup.command.clone()));
let mut editor = TextBox::new(String::new()).with_multiline(true).with_draw_bg_border(true).with_max_width(None);
editor.font_family = "monospace".to_string();
editor.font_size = 13.0;
let switcher_input = TextBox::new(String::new()).with_placeholder("Find or create a note…");
+ let search_input = TextBox::new(String::new()).with_placeholder("Search");
let mut app = NotesApp {
keys: Keys::load(),
@@ -997,6 +1516,17 @@ impl Application for NotesApp {
hist_pos: 0,
switcher: None,
switcher_input,
+ left_tab: LeftTab::Files,
+ search_input,
+ search_panel: Panel::default(),
+ search_seen: String::new(),
+ show_side: true,
+ side_tab: SideTab::Backlinks,
+ side_panel: Panel::default(),
+ side_key: None,
+ side_dirty: true,
+ completion: None,
+ completion_dismissed: None,
conflict: false,
status: None,
width: 1000,
@@ -1026,6 +1556,7 @@ impl Application for NotesApp {
match msg {
Message::Command(cmd) => self.run_command(cmd),
Message::VaultChanged(paths) => self.vault_changed(paths),
+ Message::Mcp(call) => self.mcp_call(call),
Message::Exit => *_exit = true,
}
*needs_rebuild = true;
@@ -1053,6 +1584,9 @@ impl Application for NotesApp {
self.tree_scroll = self.tree_motion.y.pos();
*needs_rebuild = true;
}
+ if self.search_panel.tick(dt, m.tree_body) | self.side_panel.tick(dt, m.side_body) {
+ *needs_rebuild = true;
+ }
if self.dirty() && !self.conflict {
let now = Instant::now();
if self.editor_text() != self.edit_seen {
@@ -1072,6 +1606,8 @@ impl Application for NotesApp {
self.ui_context.register_widget(id, ptr);
let (id, ptr) = (self.switcher_input.id(), self.switcher_input.as_ptr_mut());
self.ui_context.register_widget(id, ptr);
+ let (id, ptr) = (self.search_input.id(), self.search_input.as_ptr_mut());
+ self.ui_context.register_widget(id, ptr);
}
let size_changed =
self.width != size.width as u32 || self.height != size.height as u32 || self.scale != scale;
@@ -1091,10 +1627,17 @@ impl Application for NotesApp {
self.editor.set_rect(e.x, e.y, e.width, e.height);
let r = self.switcher_rect();
self.switcher_input.set_rect(r.x + 12.0, r.y + 12.0, r.width - 24.0, 32.0);
+ let sb = m.search_box;
+ self.search_input.set_rect(sb.x, sb.y, sb.width, sb.height);
self.needs_rebuild = false;
self.ui_context.rebuild_spatial_grid();
// Keep scroll offsets in range after a resize or a new layout.
self.tree_scroll = self.tree_scroll.min(self.tree_max_scroll(&m));
+ self.search_panel.clamp(m.tree_body);
+ self.side_panel.clamp(m.side_body);
+ }
+ if self.side_dirty && m.side.width > 0.0 {
+ self.refresh_side();
}
let (w, h) = (self.width as f32, self.height as f32);
@@ -1103,6 +1646,7 @@ impl Application for NotesApp {
self.paint_band(&mut pc, &m);
if self.switcher.is_none() {
self.paint_tree(&mut pc, &m);
+ self.paint_side(&mut pc, &m);
}
self.paint_note(&mut pc, &m);
let max = self.read_max_scroll(&m);
@@ -1128,10 +1672,19 @@ impl Application for NotesApp {
}
fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
- if self.hover_hit || self.tree_hover.is_some() {
+ let m = self.metrics();
+ let over_title = self.current.is_some() && self.switcher.is_none() && contains(m.title, x, y);
+ if self.hover_hit
+ || self.tree_hover.is_some()
+ || self.search_panel.hover.is_some()
+ || self.side_panel.hover.is_some()
+ || over_title
+ {
return Some(CursorIcon::Pointer);
}
- let m = self.metrics();
+ if self.left_tab == LeftTab::Search && contains(m.search_box, x, y) {
+ return Some(CursorIcon::Text);
+ }
(self.mode == Mode::Source && self.switcher.is_none() && contains(self.editor_rect(&m), x, y))
.then_some(CursorIcon::Text)
}
@@ -1168,6 +1721,13 @@ impl Application for NotesApp {
self.tree_hover = hover;
*needs_rebuild = true;
}
+ let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
+ if self.search_panel.hover_at(search_area, x, y) | self.side_panel.hover_at(m.side_body, x, y) {
+ *needs_rebuild = true;
+ }
+ if self.left_tab == LeftTab::Search && self.ui_context.propagate_event(&ev, self.search_input.id()) {
+ *needs_rebuild = true;
+ }
let hit = self.reading_hit(x, y).is_some();
if hit != self.hover_hit {
self.hover_hit = hit;
@@ -1209,6 +1769,52 @@ impl Application for NotesApp {
}
let m = self.metrics();
+ // The search box keeps the keyboard only while it is clicked into.
+ let in_search = self.left_tab == LeftTab::Search && contains(m.search_box, x, y);
+ if pressed && !in_search && self.search_input.editing {
+ self.search_input.unfocus();
+ }
+ if in_search {
+ if pressed && !self.search_input.editing {
+ self.editor.unfocus();
+ self.ui_context.set_focused(&mut self.search_input);
+ WidgetHost::focus(&mut self.search_input);
+ }
+ self.ui_context.propagate_event(&ev, self.search_input.id());
+ return None;
+ }
+ if pressed {
+ if let Some(i) = self.completion_row_at(&m, x, y) {
+ if let Some(c) = self.completion.as_mut() {
+ c.selected = i;
+ }
+ self.accept_completion();
+ return None;
+ }
+ if let Some(t) = tab_at(m.left_tabs, 2, x, y) {
+ self.left_tab = if t == 0 { LeftTab::Files } else { LeftTab::Search };
+ if self.left_tab == LeftTab::Search {
+ let q = self.search_query();
+ self.open_search(&q);
+ }
+ self.needs_rebuild = true;
+ return None;
+ }
+ if let Some(t) = tab_at(m.side_tabs, 2, x, y) {
+ self.side_tab = if t == 0 { SideTab::Backlinks } else { SideTab::Outline };
+ self.side_dirty = true;
+ return None;
+ }
+ let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
+ if let Some(a) = self.search_panel.click(search_area, x, y).or_else(|| self.side_panel.click(m.side_body, x, y)) {
+ self.run_action(a);
+ return None;
+ }
+ if self.current.is_some() && contains(m.title, x, y) {
+ self.open_rename();
+ return None;
+ }
+ }
if pressed && contains(m.mode_chip, x, y) && self.current.is_some() {
let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
self.set_mode(next);
@@ -1254,8 +1860,10 @@ impl Application for NotesApp {
if ctrl_click && contains(self.editor_rect(&m), x, y) {
if let Some(link) = self.link_at_caret() {
self.follow(link);
+ return None;
}
}
+ self.update_completion();
}
None
}
@@ -1266,7 +1874,19 @@ impl Application for NotesApp {
return;
}
let m = self.metrics();
- if contains(m.tree, x, y) {
+ if contains(m.side_body, x, y) {
+ if self.side_panel.wheel(delta, m.side_body) {
+ *needs_rebuild = true;
+ }
+ return;
+ }
+ if self.left_tab == LeftTab::Search && contains(m.tree_body, x, y) {
+ if self.search_panel.wheel(delta, m.tree_body) {
+ *needs_rebuild = true;
+ }
+ return;
+ }
+ if contains(m.tree_body, x, y) && self.left_tab == LeftTab::Files {
let max = self.tree_max_scroll(&m);
self.tree_motion.reconcile(0.0, self.tree_scroll);
if self.tree_motion.apply(delta, (TREE_ROW_H, TREE_ROW_H), Bounds::max(0.0), Bounds::max(max)) {
@@ -1376,11 +1996,56 @@ impl Application for NotesApp {
self.open_daily(None);
return None;
}
+ if k(&self.keys.toggle_side) {
+ self.show_side = !self.show_side;
+ self.side_dirty = true;
+ self.invalidate();
+ return None;
+ }
+ if k(&self.keys.search) && self.index.is_some() {
+ let q = self.search_query();
+ self.open_search(&q);
+ self.search_input.select_all();
+ return None;
+ }
+ if k(&self.keys.rename) && self.current.is_some() {
+ self.open_rename();
+ return None;
+ }
+ }
+
+ // The search box, while it holds the keyboard.
+ if self.search_input.editing {
+ if pressed {
+ match &event.logical_key {
+ Key::Named(NamedKey::Escape) => {
+ self.search_input.unfocus();
+ return None;
+ }
+ Key::Named(NamedKey::Enter) => {
+ // Enter opens the first result.
+ let first = self.search_panel.items.iter().find_map(|i| i.action.clone());
+ if let Some(a) = first {
+ self.search_input.unfocus();
+ self.run_action(a);
+ }
+ return None;
+ }
+ _ => {}
+ }
+ }
+ self.ui_context.propagate_event(&ev, self.search_input.id());
+ self.refresh_search(false);
+ return None;
}
match self.mode {
Mode::Source => {
+ if pressed && self.completion_key(&event.logical_key) {
+ return None;
+ }
self.ui_context.propagate_event(&ev, self.editor.id());
+ self.update_completion();
}
Mode::Reading if pressed => {
let m = self.metrics();
diff --git a/src/mcp.rs b/src/mcp.rs
new file mode 100644
index 0000000..f23d340
--- /dev/null
+++ b/src/mcp.rs
@@ -0,0 +1,210 @@
+//! MCP tools, so agents can read and write the vault through the running
+//! app (cce-ui's tools-only server, as cce-designer uses it):
+//!
+//! ```sh
+//! claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp
+//! ```
+//!
+//! Only the single instance serves it (a forwarding launch exits first).
+//! `CCE_NOTES_MCP_PORT` moves the port, for a second instance in a shadow.
+//! Calls run on the app's event loop, so they see the same index as the
+//! window, and writes go through the same paths (a note open and dirty in
+//! the window raises the usual conflict rather than being overwritten).
+
+use cce_ui::mcp::{McpTool, McpToolCall};
+use cce_vault::index::stem;
+use cce_vault::FileKind;
+use serde_json::{json, Value};
+
+use crate::{Message, NotesApp};
+
+const DEFAULT_PORT: u16 = 3002;
+
+pub fn start(sender: calloop::channel::Sender<Message>) {
+ let port = std::env::var("CCE_NOTES_MCP_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
+ cce_ui::mcp::start_mcp_server("cce-notes", port, tools(), sender, Message::Mcp);
+}
+
+fn tools() -> Vec<McpTool> {
+ let tool = |name: &str, description: &str, input_schema: Value| McpTool {
+ name: name.into(),
+ description: description.into(),
+ input_schema,
+ };
+ let note_arg = json!({ "type": "string", "description": "A note name, vault path, or path without .md" });
+ vec![
+ tool(
+ "search",
+ "Full-text search over the vault's notes (every word must match), or `#tag` / `tag:name` for the notes carrying a tag. Returns matching notes with up to 5 matching lines each (1-based line numbers).",
+ json!({ "type": "object", "properties": {
+ "query": { "type": "string" },
+ "limit": { "type": "integer", "description": "Most notes to return (default 20)" }
+ }, "required": ["query"] }),
+ ),
+ tool(
+ "find_notes",
+ "Fuzzy-match note names, aliases and paths, best first.",
+ json!({ "type": "object", "properties": {
+ "query": { "type": "string" },
+ "limit": { "type": "integer", "description": "Default 10" }
+ }, "required": ["query"] }),
+ ),
+ tool(
+ "read_note",
+ "A note's full Markdown text, with its vault path.",
+ json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
+ ),
+ tool(
+ "backlinks",
+ "Notes linking to a note (with the linking lines, 1-based), and unlinked mentions of its name.",
+ json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
+ ),
+ tool(
+ "open_note",
+ "Show a note in the cce-notes window, optionally at a 1-based line.",
+ json!({ "type": "object", "properties": {
+ "note": note_arg,
+ "line": { "type": "integer" }
+ }, "required": ["note"] }),
+ ),
+ tool(
+ "append_daily",
+ "Append a paragraph to a day's daily note (today by default), creating the note from the vault's daily template if needed. Returns the note's path.",
+ json!({ "type": "object", "properties": {
+ "text": { "type": "string" },
+ "date": { "type": "string", "description": "YYYY-MM-DD; default today" }
+ }, "required": ["text"] }),
+ ),
+ tool(
+ "current_note",
+ "What the window shows: the open note's path, the mode (reading or source) and whether it has unsaved edits.",
+ json!({ "type": "object", "properties": {} }),
+ ),
+ ]
+}
+
+fn str_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
+ args.get(key).and_then(Value::as_str).ok_or_else(|| format!("missing string argument `{key}`"))
+}
+
+fn limit_arg(args: &Value, default: usize) -> usize {
+ args.get("limit").and_then(Value::as_u64).map(|n| n.clamp(1, 200) as usize).unwrap_or(default)
+}
+
+impl NotesApp {
+ pub(crate) fn mcp_call(&mut self, call: McpToolCall) {
+ let result = self.mcp_dispatch(&call.name, &call.arguments);
+ let _ = call.reply.send(result);
+ }
+
+ fn resolve_note(&self, note: &str) -> Result<String, String> {
+ let ix = self.index.as_ref().ok_or("no vault is configured")?;
+ let path = ix.lookup(note).ok_or_else(|| format!("no note named `{note}`"))?;
+ match ix.entry(&path).map(|e| e.kind) {
+ Some(FileKind::Note) => Ok(path),
+ _ => Err(format!("`{path}` is not a note")),
+ }
+ }
+
+ fn mcp_dispatch(&mut self, name: &str, args: &Value) -> Result<Value, String> {
+ match name {
+ "current_note" => Ok(json!({
+ "path": self.current,
+ "mode": if self.mode == crate::Mode::Source { "source" } else { "reading" },
+ "unsaved": self.dirty(),
+ "conflict": self.conflict,
+ })),
+ "open_note" => {
+ let path = self.resolve_note(str_arg(args, "note")?)?;
+ let line = args.get("line").and_then(Value::as_u64).map(|l| (l as usize).saturating_sub(1));
+ self.open_path(&path, line, true);
+ if self.current.as_deref() != Some(path.as_str()) {
+ return Err(self.status.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| "could not open".into()));
+ }
+ self.needs_rebuild = true;
+ Ok(json!({ "opened": path }))
+ }
+ "append_daily" => {
+ let text = str_arg(args, "text")?.to_string();
+ let date = match args.get("date").and_then(Value::as_str) {
+ Some(d) => Some(chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| format!("not a date: {d}"))?),
+ None => None,
+ };
+ let ix = self.index.as_mut().ok_or("no vault is configured")?;
+ let date = date.unwrap_or_else(|| chrono::Local::now().date_naive());
+ let (path, _) = ix.daily(date, true).map_err(|e| e.to_string())?;
+ ix.append(&path, &text).map_err(|e| e.to_string())?;
+ // The window, if it shows that note, catches up as it does
+ // for any outside edit.
+ self.vault_changed(vec![ix_abs(self, &path)]);
+ Ok(json!({ "appended_to": path }))
+ }
+ _ => {
+ let ix = self.index.as_ref().ok_or("no vault is configured")?;
+ match name {
+ "search" => {
+ let q = str_arg(args, "query")?;
+ let limit = limit_arg(args, 20);
+ if let Some(tag) = crate::side::tag_query(q) {
+ let mut notes = ix.tagged(tag);
+ notes.truncate(limit);
+ return Ok(json!({ "tag": tag, "notes": notes }));
+ }
+ let hits: Vec<Value> = ix
+ .search(q, limit)
+ .into_iter()
+ .map(|h| {
+ json!({
+ "path": h.path,
+ "matching_lines": h.total,
+ "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
+ })
+ })
+ .collect();
+ Ok(json!(hits))
+ }
+ "find_notes" => {
+ let q = str_arg(args, "query")?;
+ let found: Vec<Value> = ix
+ .find(q, limit_arg(args, 10))
+ .into_iter()
+ .map(|m| json!({ "path": m.path, "matched": m.matched }))
+ .collect();
+ Ok(json!(found))
+ }
+ "read_note" => {
+ let path = self.resolve_note(str_arg(args, "note")?)?;
+ let text = ix.read_text(&path).map_err(|e| e.to_string())?;
+ Ok(json!({ "path": path, "text": text }))
+ }
+ "backlinks" => {
+ let path = self.resolve_note(str_arg(args, "note")?)?;
+ let mut linked = Vec::new();
+ let mut texts: std::collections::HashMap<&str, String> = Default::default();
+ for b in ix.backlinks(&path) {
+ let text = texts.entry(b.source).or_insert_with(|| ix.read_text(b.source).unwrap_or_default());
+ let line_text = text.lines().nth(b.link.line).unwrap_or("").trim().to_string();
+ linked.push(json!({ "source": b.source, "line": b.link.line + 1, "text": line_text }));
+ }
+ let unlinked: Vec<Value> = ix
+ .unlinked_mentions(&path)
+ .into_iter()
+ .map(|h| {
+ json!({
+ "source": h.path,
+ "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
+ })
+ })
+ .collect();
+ Ok(json!({ "note": path, "name": stem(&path), "linked": linked, "unlinked": unlinked }))
+ }
+ other => Err(format!("unknown tool `{other}`")),
+ }
+ }
+ }
+ }
+}
+
+fn ix_abs(app: &NotesApp, path: &str) -> std::path::PathBuf {
+ app.index.as_ref().map(|ix| ix.abs(path)).unwrap_or_default()
+}
diff --git a/src/panel.rs b/src/panel.rs
new file mode 100644
index 0000000..e4a0f29
--- /dev/null
+++ b/src/panel.rs
@@ -0,0 +1,163 @@
+//! A scrollable list of rows for the side panes: section headers, note
+//! titles and indented line snippets, each optionally clickable. The
+//! backlinks and outline pane on the right and the search pane on the left
+//! all draw through this, so they hover, scroll and click alike.
+
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{PaintCtx, TextAttrs};
+use cce_ui::widget::{Bounds, MouseScrollDelta, ScrollMotion};
+
+use crate::reading::srgb_u8;
+
+pub const ROW_H: f32 = 22.0;
+const PAD: f32 = 6.0;
+const INDENT: f32 = 14.0;
+
+/// What a click on a row does.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Action {
+ /// Open a note, optionally at a 0-based source line.
+ Open { path: String, line: Option<usize> },
+ /// Bring a 0-based line of the open note into view.
+ Line(usize),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Kind {
+ /// A section label ("Linked mentions (3)").
+ Header,
+ /// A note's name.
+ Title,
+ /// A line of text under a title, or an outline heading.
+ Line,
+ /// An explanatory line ("No backlinks").
+ Note,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Item {
+ pub depth: usize,
+ pub text: String,
+ pub kind: Kind,
+ pub action: Option<Action>,
+}
+
+impl Item {
+ pub fn header(text: impl Into<String>) -> Item {
+ Item { depth: 0, text: text.into(), kind: Kind::Header, action: None }
+ }
+ pub fn note(text: impl Into<String>) -> Item {
+ Item { depth: 0, text: text.into(), kind: Kind::Note, action: None }
+ }
+ pub fn title(text: impl Into<String>, action: Action) -> Item {
+ Item { depth: 0, text: text.into(), kind: Kind::Title, action: Some(action) }
+ }
+ pub fn line(depth: usize, text: impl Into<String>, action: Action) -> Item {
+ Item { depth, text: text.into(), kind: Kind::Line, action: Some(action) }
+ }
+}
+
+#[derive(Default)]
+pub struct Panel {
+ pub items: Vec<Item>,
+ pub scroll: f32,
+ motion: ScrollMotion,
+ pub hover: Option<usize>,
+}
+
+impl Panel {
+ /// Replace the rows. The scroll stays when the rows are an update of
+ /// the same content (a refresh), and resets when `reset` says the
+ /// content is for something else (another note, another query).
+ pub fn set(&mut self, items: Vec<Item>, reset: bool) {
+ self.items = items;
+ self.hover = None;
+ if reset {
+ self.scroll = 0.0;
+ self.motion = ScrollMotion::new();
+ }
+ }
+
+ fn max_scroll(&self, area: Rect) -> f32 {
+ (self.items.len() as f32 * ROW_H + 2.0 * PAD - area.height).max(0.0)
+ }
+
+ pub fn item_at(&self, area: Rect, x: f32, y: f32) -> Option<usize> {
+ if x < area.x || x > area.x + area.width || y < area.y || y > area.y + area.height {
+ return None;
+ }
+ let i = ((y - area.y - PAD + self.scroll) / ROW_H).floor();
+ if i < 0.0 {
+ return None;
+ }
+ let i = i as usize;
+ (i < self.items.len() && self.items[i].action.is_some()).then_some(i)
+ }
+
+ /// Update the hovered row; true when it changed.
+ pub fn hover_at(&mut self, area: Rect, x: f32, y: f32) -> bool {
+ let h = self.item_at(area, x, y);
+ let changed = h != self.hover;
+ self.hover = h;
+ changed
+ }
+
+ pub fn click(&self, area: Rect, x: f32, y: f32) -> Option<Action> {
+ self.item_at(area, x, y).and_then(|i| self.items[i].action.clone())
+ }
+
+ pub fn wheel(&mut self, delta: &MouseScrollDelta, area: Rect) -> bool {
+ let max = self.max_scroll(area);
+ self.motion.reconcile(0.0, self.scroll);
+ let moved = self.motion.apply(delta, (ROW_H, ROW_H), Bounds::max(0.0), Bounds::max(max));
+ self.scroll = self.motion.y.pos();
+ moved
+ }
+
+ /// Advance a wheel glide; true while it moves.
+ pub fn tick(&mut self, dt: f32, area: Rect) -> bool {
+ if !self.motion.is_animating() {
+ return false;
+ }
+ let max = self.max_scroll(area);
+ self.motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
+ self.scroll = self.motion.y.pos();
+ true
+ }
+
+ pub fn clamp(&mut self, area: Rect) {
+ let max = self.max_scroll(area);
+ if self.scroll > max {
+ self.scroll = max;
+ self.motion.y.jump_to(max);
+ }
+ }
+
+ pub fn paint(&self, pc: &mut PaintCtx, area: Rect) {
+ let (family, size) = cce_ui::layout::tree_font_parsed();
+ let small = (size * 0.9).round();
+ let body = "sans-serif".to_string();
+ pc.clip(area, |pc| {
+ for (i, item) in self.items.iter().enumerate() {
+ let y = area.y + PAD + i as f32 * ROW_H - self.scroll;
+ if y + ROW_H < area.y || y > area.y + area.height {
+ continue;
+ }
+ let r = Rect { x: area.x + 4.0, y, width: area.width - 8.0, height: ROW_H };
+ if self.hover == Some(i) {
+ pc.rounded_rect(r, 4.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+ }
+ let x = r.x + 6.0 + item.depth as f32 * INDENT;
+ let bounds = Some([x, r.y.max(area.y), r.x + r.width - 4.0, (r.y + r.height).min(area.y + area.height)]);
+ let (font, fsize, color, attrs) = match item.kind {
+ Kind::Header => (family.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs::default()),
+ Kind::Title => (family.clone(), size, cce_ui::colors::TEXT_FG, TextAttrs::default()),
+ Kind::Line => (body.clone(), small, cce_ui::colors::TEXT_FG, TextAttrs::default()),
+ Kind::Note => (body.clone(), small, cce_ui::colors::TEXT_DIM, TextAttrs { italic: true, weight: None }),
+ };
+ let ty = cce_ui::layout::align_text_y(r.y, r.height, fsize, 0.0);
+ pc.text_attrs(item.text.clone(), x, ty, fsize, srgb_u8(color), Some(font), bounds, attrs);
+ }
+ });
+ }
+}
diff --git a/src/reading.rs b/src/reading.rs
index 336811c..e182c2b 100644
--- a/src/reading.rs
+++ b/src/reading.rs
@@ -89,7 +89,8 @@ impl Draw {
pub struct Layout {
pub draws: Vec<Draw>,
pub hits: Vec<(Rect, Hit)>,
- /// Each block's first source line and the y it starts at, in order.
+ /// Each block's (and list item's) first source line and the y it
+ /// starts at, in source order.
pub lines: Vec<(usize, f32)>,
pub height: f32,
}
@@ -278,6 +279,10 @@ impl<'a> Layouter<'a> {
if i > 0 {
y += 2.0;
}
+ // Items are where a search hit or a task usually points.
+ if self.out.lines.last().is_none_or(|(l, _)| *l < item.line) {
+ self.out.lines.push((item.line, y));
+ }
let body_x = x + INDENT;
let mid = y + lh / 2.0;
match (item.task, start) {
@@ -803,7 +808,7 @@ mod tests {
_ => None,
}).collect();
assert_eq!(tasks, [(2, ' '), (3, 'x')]);
- assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 5]);
+ assert_eq!(l.lines.iter().map(|(l, _)| *l).collect::<Vec<_>>(), [0, 2, 3, 5]);
assert!(l.y_of_line(5) > l.y_of_line(2));
}
diff --git a/src/side.rs b/src/side.rs
new file mode 100644
index 0000000..84dadf8
--- /dev/null
+++ b/src/side.rs
@@ -0,0 +1,180 @@
+//! What the side panes list, built from the index: a note's backlinks and
+//! unlinked mentions, its outline, and full-text or tag search results.
+//! Pure functions over `Index`, so they test without a window.
+
+use std::collections::BTreeMap;
+
+use cce_vault::index::stem;
+use cce_vault::Index;
+
+use crate::panel::{Action, Item};
+
+/// Search results kept per query; the pane is for finding, not listing.
+const SEARCH_LIMIT: usize = 50;
+
+/// Linked mentions (grouped by note, each with its lines), then unlinked
+/// mentions — Obsidian's backlinks pane.
+pub fn backlinks(ix: &Index, path: &str) -> Vec<Item> {
+ let mut out = Vec::new();
+ let mut by_source: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
+ for b in ix.backlinks(path) {
+ if b.source == path {
+ continue;
+ }
+ by_source.entry(b.source).or_default().push(b.link.line);
+ }
+ let count: usize = by_source.len();
+ out.push(Item::header(format!("Linked mentions ({count})")));
+ if by_source.is_empty() {
+ out.push(Item::note("No backlinks"));
+ }
+ for (source, mut lines) in by_source {
+ lines.sort_unstable();
+ lines.dedup();
+ out.push(Item::title(stem(source), Action::Open { path: source.to_string(), line: lines.first().copied() }));
+ let text = ix.read_text(source).unwrap_or_default();
+ let all: Vec<&str> = text.lines().collect();
+ for line in lines {
+ // A canvas link has no line of its own worth showing.
+ let Some(t) = all.get(line).map(|t| t.trim()).filter(|t| !t.is_empty()) else { continue };
+ out.push(Item::line(1, t.to_string(), Action::Open { path: source.to_string(), line: Some(line) }));
+ }
+ }
+ let mentions = ix.unlinked_mentions(path);
+ out.push(Item::header(format!("Unlinked mentions ({})", mentions.len())));
+ for hits in mentions {
+ let first = hits.lines.first().map(|l| l.line);
+ out.push(Item::title(stem(&hits.path), Action::Open { path: hits.path.clone(), line: first }));
+ for l in hits.lines {
+ out.push(Item::line(1, l.text, Action::Open { path: hits.path.clone(), line: Some(l.line) }));
+ }
+ }
+ out
+}
+
+/// The note's headings, indented by level.
+pub fn outline(ix: &Index, path: &str) -> Vec<Item> {
+ let Some(note) = ix.note(path) else { return vec![Item::note("Not a note")] };
+ if note.headings.is_empty() {
+ return vec![Item::note("No headings")];
+ }
+ let top = note.headings.iter().map(|h| h.level).min().unwrap_or(1);
+ note.headings
+ .iter()
+ .map(|h| Item::line((h.level - top) as usize, h.text.clone(), Action::Line(h.line)))
+ .collect()
+}
+
+/// A search query's results: `#tag` or `tag:tag` lists the notes carrying
+/// it (nested tags included); anything else is full text, every word
+/// required, grouped by note with the matching lines.
+pub fn search(ix: &Index, query: &str) -> Vec<Item> {
+ let q = query.trim();
+ if q.is_empty() {
+ return Vec::new();
+ }
+ if let Some(tag) = tag_query(q) {
+ let mut notes = ix.tagged(tag);
+ notes.sort_by_key(|p| p.to_lowercase());
+ let mut out = vec![Item::header(format!("#{tag} — {}", plural(notes.len(), "note")))];
+ out.extend(notes.into_iter().map(|p| Item::title(stem(p), Action::Open { path: p.to_string(), line: None })));
+ return out;
+ }
+ let hits = ix.search(q, SEARCH_LIMIT);
+ let mut out = vec![Item::header(plural(hits.len(), "note"))];
+ for h in hits {
+ let first = h.lines.first().map(|l| l.line);
+ let title = if h.total > h.lines.len() {
+ format!("{} ({} lines)", stem(&h.path), h.total)
+ } else {
+ stem(&h.path).to_string()
+ };
+ out.push(Item::title(title, Action::Open { path: h.path.clone(), line: first }));
+ for l in h.lines {
+ out.push(Item::line(1, l.text, Action::Open { path: h.path.clone(), line: Some(l.line) }));
+ }
+ }
+ out
+}
+
+/// The tag a query asks for, if it is a tag query.
+pub fn tag_query(q: &str) -> Option<&str> {
+ let t = q.strip_prefix("tag:").or_else(|| q.strip_prefix('#'))?.trim().trim_start_matches('#');
+ (!t.is_empty() && !t.contains(char::is_whitespace)).then_some(t)
+}
+
+pub fn plural(n: usize, what: &str) -> String {
+ format!("{n} {what}{}", if n == 1 { "" } else { "s" })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::panel::Kind;
+
+ fn vault(files: &[(&str, &str)]) -> (tempfile::TempDir, Index) {
+ let dir = tempfile::tempdir().unwrap();
+ for (p, t) in files {
+ let abs = dir.path().join(p);
+ std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
+ std::fs::write(abs, t).unwrap();
+ }
+ let ix = Index::open(dir.path(), false).unwrap();
+ (dir, ix)
+ }
+
+ fn texts(items: &[Item]) -> Vec<String> {
+ items.iter().map(|i| format!("{}{}", " ".repeat(i.depth), i.text)).collect()
+ }
+
+ #[test]
+ fn backlinks_group_by_note_with_lines_then_mentions() {
+ let (_d, ix) = vault(&[
+ ("Target.md", "# T\n"),
+ ("a/One.md", "intro\nsee [[Target]] here\nand [[Target#T]] again\n"),
+ ("Two.md", "about Target, unlinked\n"),
+ ]);
+ let items = backlinks(&ix, "Target.md");
+ assert_eq!(
+ texts(&items),
+ [
+ "Linked mentions (1)",
+ "One",
+ " see [[Target]] here",
+ " and [[Target#T]] again",
+ "Unlinked mentions (1)",
+ "Two",
+ " about Target, unlinked",
+ ]
+ );
+ assert_eq!(items[2].action, Some(Action::Open { path: "a/One.md".into(), line: Some(1) }));
+ assert_eq!(items[0].kind, Kind::Header);
+ }
+
+ #[test]
+ fn outline_indents_from_the_top_level() {
+ let (_d, ix) = vault(&[("N.md", "## A\ntext\n### B\n## C\n")]);
+ let items = outline(&ix, "N.md");
+ assert_eq!(texts(&items), ["A", " B", "C"]);
+ assert_eq!(items[1].action, Some(Action::Line(2)));
+ let (_d, ix) = vault(&[("E.md", "no headings\n")]);
+ assert_eq!(outline(&ix, "E.md")[0].kind, Kind::Note);
+ }
+
+ #[test]
+ fn search_text_and_tags() {
+ let (_d, ix) = vault(&[
+ ("A.md", "apple pie\nbanana\n#fruit/red\n"),
+ ("B.md", "apple tart #fruit\n"),
+ ("C.md", "nothing\n"),
+ ]);
+ let items = search(&ix, "apple");
+ assert_eq!(items[0].text, "2 notes");
+ assert!(texts(&items).contains(&" apple pie".to_string()));
+ let tags = search(&ix, "#fruit");
+ assert_eq!(texts(&tags), ["#fruit — 2 notes", "A", "B"]);
+ assert_eq!(texts(&search(&ix, "tag:fruit/red")), ["#fruit/red — 1 note", "A"]);
+ assert!(search(&ix, " ").is_empty());
+ assert_eq!(tag_query("#a b"), None);
+ }
+}