Skip to content

feat: unlinked mentions with one-click linking + local graph - #66

Draft
kodinkod wants to merge 3 commits into
mainfrom
claude/mentions-local-graph
Draft

kodinkod wants to merge 3 commits into
mainfrom
claude/mentions-local-graph

Conversation

@kodinkod

@kodinkod kodinkod commented Oct 2, 2026

Copy link
Copy Markdown
Member

Why

These are the last two items of roadmap phase 2 ("Obsidian habits") that #63 didn't cover: unlinked mentions and a local graph.

What

1. Unlinked mentions (feat(backlinks))

  • Where: a collapsible section in the right panel's backlinks tab. It is collapsed by default and only fetches while open.
  • What it lists: other notes that mention the active note's title, file stem or aliases as plain text, without linking to it.
  • Candidates: the FTS index from feat(search): full-text search over note contents (SQLite FTS5, ⌘⇧F) #62 is queried with one quoted phrase per name, limited to the body column. The existing quoting is reused, so no raw text ever reaches MATCH. At most 500 candidates are taken.
  • Verification (core/mentions.rs, pure functions) on the actual file text:
    • Matches are whole-word and case-insensitive, with Unicode word boundaries (Cyrillic works) and ё = е. Punctuation names like C++ work, and the longest overlapping name wins.
    • Never offered inside: wikilinks and embeds, markdown links and images, inline code, code fences, frontmatter, URLs and #tags.
  • Notes that already link to the target are left out, matched by path, stem, title or alias, with embeds counting.
  • Link: mention_link re-reads the file and refuses if the occurrence isn't still at that line and column with the same text. It writes [[Target]], or [[Target|as written]] when the text differs from the link name.
  • Link all: mention_link_all links every mention in one source note.
  • After either action: both update the FTS index. The UI flushes pending edits first, refuses if the note is still unsaved, then calls reloadNote.
  • Change to existing code: reloadNote now bumps vaultVersion even for notes that aren't open, so backlinks refresh after a write into a closed note.

2. Local graph (feat(graph))

  • Backend: graph_local(path, depth 1–3, includeTags) builds the same graph as the global view, then runs a pure BFS (local_subgraph) over link edges in both directions. Links between neighbours are kept, and tags are end points only.
  • Frontend:
    • A compact d3-force view (LocalGraph.tsx) in a new right-panel Graph tab. It follows the active note and has a depth picker and a tags toggle, both persisted.
    • The palette command "Open local graph" opens a large overlay.
    • Clicking a note opens it and re-centres the graph; clicking a tag opens tag search.
  • No default hotkey: ⌘⇧G is CodeMirror's "find previous", and no other key was clearly free. The command can still be bound in Settings → Hotkeys.

Docs

The README has entries for both features, and docs/roadmap.md phase 2 marks them ✅.

Limitations

  • Encrypted notes are never scanned or rewritten as sources, and they aren't in the graph (the view says so).
  • Indented (4-space) code blocks aren't excluded from mentions, and inline code is only recognised within a single line.
  • Each local-graph fetch rebuilds the whole graph, debounced 1.5 s after saves, the same cost as backlinks today. Moving this onto the index is the remaining P2 work.
  • The local graph has no pan or zoom; it fits the view instead.

Tests

  • Rust: 18 in core/mentions.rs, 7 end-to-end in commands/mentions.rs (tempfile plus an in-memory FTS index, including stale-occurrence refusal and path safety), 2 for FTS phrase queries and 6 for graph.rs local subgraphs. 349 in total.
  • Vitest: local-graph.test.ts (8), note-name.test.ts (3) and one new vault.reload case. 157 in total.

Checks (rebased on current main, including #64)

  • npm run lint: 0 errors (5 pre-existing warnings)
  • npm run build: ok
  • npm test: 157 passed
  • cargo fmt --check: ok
  • cargo clippy -D warnings: ok
  • cargo test: 349 passed

Not exercised in a live app: there is no display in the container. The right-panel layout (four tabs now) and the Link / Link all buttons are worth clicking through.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VeZRNKkbb6GoBgqmhLyqN8


Generated by Claude Code

claude added 2 commits October 2, 2026 22:03
A collapsible "Unlinked mentions" section under the backlinks lists notes
that name the active note (title, file stem or a frontmatter alias) in
plain text without linking it, each hit with its line number and the
phrase highlighted in context.

Candidates come from the FTS5 index: one quoted body phrase per name,
built by a dedicated `body_phrases_expr` that reuses the user-query
quoting, so no name is ever interpolated into MATCH. Every candidate is
then verified on the real file text in `core::mentions`: whole words,
case-insensitive, ё = е, Unicode word boundaries (Cyrillic works), the
longest name wins, and nothing inside wikilinks/embeds, markdown links,
inline code, code fences, frontmatter, URLs or #tags. Notes that already
link to the target (by path, stem, title or alias, embeds included) are
left out, as are names under 3 characters and aliases that are another
note's real name.

"Link" rewrites one occurrence into [[Target]] or [[Target|as written]]
via `mention_link`, which re-reads the file and refuses when the
occurrence is no longer at that line/column with the same text; "Link
all" does a whole note. Both update the FTS index; the panel flushes the
source's autosave first and reloads it after, and `reloadNote` now bumps
`vaultVersion` even for an uncached note so backlinks refresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeZRNKkbb6GoBgqmhLyqN8
A force-directed view of the active note's neighbourhood: notes reached
over wikilinks in either direction, 1–3 hops out (default 1), tags as
optional leaves. Available as a compact "Graph" tab in the right panel
that follows the active note, and large via the palette command "Open
local graph" (`graph.local`, palette-only: Mod+Shift+G is the editor's
find-previous). Clicking a note node opens it and the graph re-centres;
clicking a tag opens tag search. Depth and the tag toggle persist.

Backend: `graph_local` slices the global `build_graph` output with a pure
BFS (`local_subgraph`), so a link appears here exactly when the global
graph draws it — paths, stems, titles and aliases resolve the same way.
Edges between two neighbours are kept so clusters stay visible.

Frontend: the payload is shaped by pure helpers in `lib/local-graph.ts`
(centre pinned at the origin, radius by depth, a↔b merged into one line,
view-box fit) and rendered by a small d3-force SVG in
`components/graph/LocalGraph.tsx`, separate from the full-screen
GraphView. `isNotePath` moves into `lib/note-name.ts`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeZRNKkbb6GoBgqmhLyqN8
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Resolve against #65/#67: full-text search now lives in the ⌘K box, so
its overlay state is gone from the UI store and App.tsx; keep the local
graph overlay state next to the new focus-mode flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VeZRNKkbb6GoBgqmhLyqN8
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants