Skip to content

Add editor shortcuts, mentions, and callouts - #132

Open
definitelynotguru wants to merge 1 commit into
mainfrom
feat/quick-win-batch
Open

Add editor shortcuts, mentions, and callouts#132
definitelynotguru wants to merge 1 commit into
mainfrom
feat/quick-win-batch

Conversation

@definitelynotguru

@definitelynotguru definitelynotguru commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • add keyboard shortcuts for creating, searching, saving, previewing, and pinning notes
  • surface unlinked note-title mentions and convert them atomically without overwriting concurrent edits
  • render Obsidian-compatible Markdown callouts with custom titles and nested content
  • serialize note saves and local updates used by the new interactions

Validation

  • npm test (168 passed, 8 integration tests skipped without a database)
  • npm run lint (0 errors, 12 existing warnings)
  • npm run format:check
  • npm run build
  • npm run knip
  • npm audit (0 vulnerabilities)

Closes #51
Closes #61
Closes #85

PR Agent Description

PR Type

Enhancement, Bug fix, Tests

Description

  • The change adds unlinked mention detection and Link mention flow that converts the first plain-text title occurrence to [[wiki-link]] outside existing links.
  • The API PATCH /notes/[id] accepts link_mention, runs linkFirstUnlinkedMention in a FOR UPDATE transaction, and returns linked_mention to signal conversion.
  • The change renders [!NOTE/WARNING/TIP/...] blockquotes as callouts with icons and custom titles and leaves normal blockquotes unchanged.
  • The change adds useKeyboardShortcuts for Ctrl/Cmd+N/K/Enter/E/Shift+P and wires save-and-close, preview toggle, pin, new note, and search focus.
  • The note detail page adds pin/unpin with local IndexedDB and remote PATCH handling and updates back button to flush then navigate.
  • The change hardens useDebouncedNoteSave to serialize concurrent flushes via inFlight promise chain and adds updateLocalNote atomic read-modify-write.
  • The change adds info callout colors, backlink row layout, and new tests for wiki-links, callouts, backlinks, and keyboard save serialization; misses avoid extra writes.

Review map

  1. web/src/app/api/v1/notes/[id]/route.ts: Atomic link_mention PATCH with FOR UPDATE and linked_mention contract
  2. web/src/lib/wiki-links.ts: Core unlinked mention detection with protected link ranges
  3. web/src/hooks/useDebouncedNoteSave.ts: Serialized flush via inFlight chain to prevent overlapping saves
  4. web/src/app/notes/[id]/page.tsx: Pin, save-and-close, and mention link integration for local and remote notes
  5. web/src/lib/local-notes.ts: New IndexedDB atomic updateLocalNote helper used by pin and autosave

- Add keyboard shortcuts for note creation, search, save, preview, and pinning
- Surface and atomically convert unlinked note mentions
- Render typed Markdown callouts with custom titles and nested content
@zeus-review

zeus-review Bot commented Aug 30, 2026

Copy link
Copy Markdown

PR Agent Review

Note

  • The PR adds unlinked-mention linking, callout rendering, global shortcuts, and pin controls to the web app.
  • The server adds an atomic PATCH path that links the first plain mention inside a FOR UPDATE transaction.
  • The client adds debounced saves, IndexedDB updates, and backlink UI to support offline and online edits.
  • The change matters because it speeds daily note work and keeps link state consistent across sync.
  • Risk remains in shortcut scope, code-block handling, and request size limits that touch sync contracts.
SizeM
P0 · c4PATCH link_mention early-return must not bump updatedAt
On the diff · web/src/app/api/v1/notes/[id]/route.ts · lines 87-115
Fix prompt on the inline thread.
P1 · c4Cover disabled, empty id, and timer clear in debounced save
On the diff · web/src/hooks/useDebouncedNoteSave.ts · lines 22-45
Fix prompt on the inline thread.
P1 · c4Global shortcuts fire inside inputs/textareas
On the diff · web/src/hooks/useKeyboardShortcuts.ts · lines 14-33
Fix prompt on the inline thread.
P1 · c5Ignore mentions inside inline and fenced code
On the diff · web/src/lib/wiki-links.ts · lines 40-65
Fix prompt on the inline thread.
P2 · c4Unbounded link_mention enables ReDoS/DoS in FOR UPDATE PATCH
On the diff · web/src/app/api/v1/notes/[id]/route.ts · lines 12-19
Fix prompt on the inline thread.
P2 · c4PATCH link_mention drops body param and accepts whitespace
On the diff · web/src/app/api/v1/notes/[id]/route.ts · lines 80-103
Fix prompt on the inline thread.
P2 · c4Collapse duplicated local vs remote persistence branches
On the diff · web/src/app/notes/[id]/page.tsx · lines 437-510
Fix prompt on the inline thread.
P2 · c4Unmount flush sets state on unmounted component and can lose saves
Summary only · web/src/hooks/useDebouncedNoteSave.ts · lines 66-76
Cleanup calls flush() without await and after unmount. flush() calls setStatus() on an unmounted component. The effect also does not flush localSaveTimer, so edits within 700ms can be lost on back navigation that does not use saveAndClose.
Expand Prompt to fix below (summary-only).
P2 · c5Stabilize useKeyboardShortcuts listener; remove per-render churn
On the diff · web/src/hooks/useKeyboardShortcuts.ts · lines 14-42
Fix prompt on the inline thread.
P2 · c4Cover missing id and abort for updateLocalNote
On the diff · web/src/lib/local-notes.ts · lines 109-135
Fix prompt on the inline thread.
Relevant testspartial
SecurityUnbounded link_mention allows large regex scans inside a FOR UPDATE transaction. It can hold row locks and cause CPU or database denial of service.
CI✅ All CI is passing
Prompt to fix — P2 · Unmount flush sets state on unmounted component and can lose saves
Guard setStatus with mounted ref or make unmount flush synchronous. Flush pending local timer on unmount or on visibilitychange.
Fix all findings (agent prompt)
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

Findings:

[P0] @web/src/app/api/v1/notes/[id]/route.ts lines 87-115
Add integration test for PATCH with link_mention that has no plain occurrence: mock FOR UPDATE select to return note with body '[[Project Atlas]]', call PATCH with {link_mention:'Project Atlas'}, assert linked_mention false, updated_at unchanged, and no UPDATE or revision capture.

[P1] @web/src/hooks/useDebouncedNoteSave.ts lines 22-45
Add jsdom tests with fake timers: disabled and empty id return false without fetch, flush clears timer, unmount clears timer, and rejected fetch sets error status with retry.

[P1] @web/src/hooks/useKeyboardShortcuts.ts lines 14-33
Guard onKeyDown: return early if event.target is INPUT, TEXTAREA, SELECT or contentEditable, except for shortcuts that must work in inputs. Stabilize deps with refs to avoid churn.

[P1] @web/src/lib/wiki-links.ts lines 40-65
Add ranges for inline code and fenced code to protectedRanges before searching. Add test: findUnlinkedMentionIndex('`Project Atlas`','Project Atlas') returns -1 and fenced block returns -1.

[P2] @web/src/app/api/v1/notes/[id]/route.ts lines 12-19
Add length caps for link_mention, title and body and enforce request size with readJsonBody before parse. Validate link_mention length before RegExp.

[P2] @web/src/app/api/v1/notes/[id]/route.ts lines 80-103
Reject requests that send link_mention together with body, or merge explicitly, and validate link_mention.trim().length > 0 with 400 on failure.

[P2] @web/src/app/notes/[id]/page.tsx lines 437-510
Extract a persistence helper that owns isLocal, timer clearing, and updateLocalNote vs apiFetch. Page then calls one method for pin, save, and link.

[P2] @web/src/hooks/useDebouncedNoteSave.ts lines 66-76
Guard setStatus with mounted ref or make unmount flush synchronous. Flush pending local timer on unmount or on visibilitychange.
[inline thread omitted — summary only]

[P2] @web/src/hooks/useKeyboardShortcuts.ts lines 14-42
Store handlers in refs and subscribe once. Update refs when callbacks change. Memoize page handlers with useCallback.

[P2] @web/src/lib/local-notes.ts lines 109-135
Add fake-indexeddb tests: updateLocalNote for missing id resolves undefined without put, and transaction abort/error rejects and closes db.

<ci_summary untrusted="true">
✅ All CI is passing
</ci_summary>

151800b ⋅ general ⋅ 8m 42s ⋅ meta/muse-spark-1.2-contributor

@zeus-review zeus-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Track this run on the progress stub in the PR conversation.

quality Here's what the quality found.

}
};

const togglePin = async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Collapse duplicated local vs remote persistence branches

web/src/app/notes/[id]/page.tsx · lines 437-510

The page repeats isLocal branches in togglePin, saveAndClose, and linkMention. Each branch clears the local timer and spreads title/body/updated_at. The same updateLocalNote pattern appears at line 184. The file now exceeds 900 lines and exceeds the 500-line warning.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P2] @web/src/app/notes/[id]/page.tsx lines 437-510
Extract a persistence helper that owns isLocal, timer clearing, and updateLocalNote vs apiFetch. Page then calls one method for pin, save, and link.

onTogglePreview?: ShortcutHandler;
onTogglePin?: ShortcutHandler;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Stabilize useKeyboardShortcuts listener; remove per-render churn

web/src/hooks/useKeyboardShortcuts.ts · lines 14-42

The effect depends on the shortcuts object (line 41). Callers build a new literal each render and handlers change each render. Each state update removes and adds the window keydown listener. This causes churn and risks missed keys and stale closures.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P2] @web/src/hooks/useKeyboardShortcuts.ts lines 14-42
Store handlers in refs and subscribe once. Update refs when callbacks change. Memoize page handlers with useCallback.

@zeus-review zeus-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Track this run on the progress stub in the PR conversation.

tests Here's what the tests found.

if (pinnedAt && Number.isNaN(pinnedAt.getTime())) {
if (requestedPinnedAt && Number.isNaN(requestedPinnedAt.getTime())) {
return errorResponse("Invalid pinned_at", 400);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 · PATCH link_mention early-return must not bump updatedAt

web/src/app/api/v1/notes/[id]/route.ts · lines 87-115

No test covers the early-return when link_mention finds no plain occurrence. The path at lines 98-111 returns the existing row without a write. Without a pin, a regression could bump updated_at or call captureRevisionIfChanged on a no-op, which pollutes sync last-writer-wins and revision history.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P0] @web/src/app/api/v1/notes/[id]/route.ts lines 87-115
Add integration test for PATCH with link_mention that has no plain occurrence: mock FOR UPDATE select to return note with body '[[Project Atlas]]', call PATCH with {link_mention:'Project Atlas'}, assert linked_mention false, updated_at unchanged, and no UPDATE or revision capture.

@@ -22,19 +23,31 @@ export function useDebouncedNoteSave(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 · Cover disabled, empty id, and timer clear in debounced save

web/src/hooks/useDebouncedNoteSave.ts · lines 22-45

Tests cover timer cancellation and in-flight chaining, but not the disabled or empty-id branches. flush should return false and not call apiFetch when enabled is false or noteId is empty, and timer should clear on manual flush and unmount. Error path that sets status to error is also not asserted.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P1] @web/src/hooks/useDebouncedNoteSave.ts lines 22-45
Add jsdom tests with fake timers: disabled and empty id return false without fetch, flush clears timer, unmount clears timer, and rejected fetch sets error status with retry.

Comment thread web/src/lib/wiki-links.ts
if (!title) return [];
return allNotes.filter(
(note) =>
note.id !== currentId &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 · Ignore mentions inside inline and fenced code

web/src/lib/wiki-links.ts · lines 40-65

protectedRanges only covers wiki links and markdown links. Inline code (code) and fenced blocks (```) are not protected. A title inside code then counts as an unlinked mention and Link mention wraps it with [[ ]], which corrupts code content.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P1] @web/src/lib/wiki-links.ts lines 40-65
Add ranges for inline code and fenced code to protectedRanges before searching. Add test: findUnlinkedMentionIndex('`Project Atlas`','Project Atlas') returns -1 and fenced block returns -1.

@@ -109,6 +109,31 @@ export async function putLocalNote(note: ApiNote): Promise<void> {
await runWrite((store) => store.put(note));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Cover missing id and abort for updateLocalNote

web/src/lib/local-notes.ts · lines 109-135

Existing test only covers successful update. The early return when id is missing (request.result falsy leaves updated undefined) and the abort/error paths are not covered. Missing coverage risks swallowed errors in offline pin and link flows.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P2] @web/src/lib/local-notes.ts lines 109-135
Add fake-indexeddb tests: updateLocalNote for missing id resolves undefined without put, and transaction abort/error rejects and closes db.

@zeus-review zeus-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Track this run on the progress stub in the PR conversation.

correctness Here's what the correctness found.

onTogglePreview?: ShortcutHandler;
onTogglePin?: ShortcutHandler;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 · Global shortcuts fire inside inputs/textareas

web/src/hooks/useKeyboardShortcuts.ts · lines 14-33

The window handler fires for every Ctrl/⌘ chord regardless of focus. When an input or textarea has focus, Ctrl+N still creates a note and Ctrl+Enter still triggers saveAndClose. This surprises the user and can navigate away during an edit.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P1] @web/src/hooks/useKeyboardShortcuts.ts lines 14-33
Guard onKeyDown: return early if event.target is INPUT, TEXTAREA, SELECT or contentEditable, except for shortcuts that must work in inputs. Stabilize deps with refs to avoid churn.

const nextBody = body.body ?? existing.body;
const pinnedAt =
const requestedPinnedAt =
body.pinned_at === undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · PATCH link_mention drops body param and accepts whitespace

web/src/app/api/v1/notes/[id]/route.ts · lines 80-103

PATCH sets nextBody to link_mention result when link_mention exists and ignores body. A request with both fields silently drops the body edit. Whitespace-only link_mention passes z.string().min(1) but trims to empty and returns 200 with linked_mention false instead of 400.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P2] @web/src/app/api/v1/notes/[id]/route.ts lines 80-103
Reject requests that send link_mention together with body, or merge explicitly, and validate link_mention.trim().length > 0 with 400 on failure.

@zeus-review zeus-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Track this run on the progress stub in the PR conversation.

security Here's what the security found.

@@ -11,12 +11,14 @@ import {
} from "@/lib/api-utils";
import { eq, and, isNull } from "drizzle-orm";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 · Unbounded link_mention enables ReDoS/DoS in FOR UPDATE PATCH

web/src/app/api/v1/notes/[id]/route.ts · lines 12-19

patchSchema adds link_mention as z.string().min(1) with no max. PATCH parses with await request.json() and bypasses readJsonBody 512KB limit. Server builds new RegExp(escapeRegExp(title), "gi") and scans the note body inside SELECT ... FOR UPDATE. An authenticated caller can send a large link_mention to force expensive regex and body scan while it holds the row lock.

Prompt to fix
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, and keep changes minimal.

Repository: definitelynotguru/Recall
Pull request: #132
Head SHA: 151800bd404439249b7a02acf743bfe4fafdac73

[P2] @web/src/app/api/v1/notes/[id]/route.ts lines 12-19
Add length caps for link_mention, title and body and enforce request size with readJsonBody before parse. Validate link_mention length before RegExp.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

K1: Keyboard Shortcuts (Web) D6: Callout / Admonition Blocks B2: Unlinked Mentions — Discover Hidden Note Connections

1 participant