Skip to content

Improve standalone Markdown windows and Settings access - #200

Open
Norkep wants to merge 13 commits into
erictli:mainfrom
Norkep:agent/standalone-window-settings
Open

Improve standalone Markdown windows and Settings access#200
Norkep wants to merge 13 commits into
erictli:mainfrom
Norkep:agent/standalone-window-settings

Conversation

@Norkep

@Norkep Norkep commented Aug 3, 2026

Copy link
Copy Markdown

Summary

  • Open Markdown files outside the configured notes folder in a dedicated standalone editor window.
  • Keep the normal full-editor window hidden when a standalone file is the only requested document.
  • Add Settings access from standalone Markdown windows through Cmd/Ctrl+, and the native application menu.
  • Serialize saves and wait for dirty content before closing a window.
  • Detect external file revisions and offer explicit conflict resolution instead of overwriting silently.
  • Persist draft checkpoints and recovery snapshots for unsafe close/crash paths.
  • Reload standalone files safely after external changes without losing local dirty content.

Stack

This is PR 3 of the Scratch 1.0.1 backport stack. It depends on #198 and #199.

The branch currently includes PR 1 and PR 2 commits. Merge the stack in order; this PR diff will shrink to the standalone-window commit after its dependencies land.

Verification

  • npm test -- --run: 22 files, 77 tests passed.
  • npm run build: passed.
  • cargo test --manifest-path src-tauri/Cargo.toml --quiet: 15 tests passed.
  • cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings: passed.
  • git diff --check HEAD^..HEAD: passed.

Tests cover serialized writes, close interception, dirty-draft checkpoints, recovery snapshots, stale-revision conflicts, explicit resolution, external reload decisions, standalone recreation, window shortcuts, and required call sites.

macOS runtime acceptance

An isolated built app was launched with an external Markdown file and verified during development:

  • exactly one standalone editor window became visible;
  • Cmd+, opened the Settings window;
  • edits were saved to the external file on disk;
  • typing followed immediately by Cmd+W flushed the dirty content before closing.

Scope

Multiple workspaces/windows, selection formatting, tables, image drag/drop, and block drag/drop remain outside this change.

No zero-bug guarantee is possible. Risk is reduced through donor-identical persistence modules, focused regression tests, a full build, Rust tests, Clippy, scope auditing, and real macOS window/save validation.

Summary by CodeRabbit

  • New Features

    • Added a dedicated Preferences window with editor appearance controls.
    • Added configurable note sorting and collapsible folder sections.
    • Added draft recovery checkpoints and recovery notices for interrupted editing sessions.
    • Added save-conflict detection with options to keep local changes, accept remote content, or recreate deleted files.
    • Added title-bar options for displaying filenames and modification dates.
    • Added safer window closing and expanded keyboard shortcuts.
  • Bug Fixes

    • Improved persistence reliability and protection against concurrent or external file changes.
  • Tests

    • Added comprehensive automated coverage for settings, persistence, recovery, conflicts, navigation, and window behavior.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The pull request adds revision-aware atomic persistence, durable draft checkpoints, conflict recovery, safe window closing, a dedicated Preferences window, editor display settings, sidebar sorting and folder collapsing, shared shortcuts, and Vitest test infrastructure.

Workspace and persistence

Layer / File(s) Summary
Persistence and backend contracts
src-tauri/src/persistence.rs, src-tauri/src/draft_checkpoint.rs, src-tauri/src/sha256.rs, src-tauri/src/lib.rs
Adds SHA-256 revisions, atomic saves, typed conflicts, secure path validation, recovery snapshots, draft-checkpoint commands, and native window commands.
Draft recovery and conflict flow
src/components/editor/Editor.tsx, src/components/preview/PreviewApp.tsx, src/lib/draftCheckpoint.ts, src/lib/conflictResolution.ts, src/lib/windowClose.ts
Adds generation-aware saves, checkpoint scheduling, recovery reconciliation, conflict resolution, reload guards, and close-after-save orchestration.
Service and validation coverage
src/services/*, src/context/NotesContext.tsx, src/lib/*test*
Adds frontend IPC wrappers, recovery notices, serialized writers, persistence controllers, and tests for save, recovery, conflict, and close behavior.

Settings and windows

Layer / File(s) Summary
Preferences window and lifecycle
src/App.tsx, src/services/windowLifecycle.ts, src/lib/windowMode.ts, src/lib/windowShortcuts.ts, src-tauri/capabilities/default.json
Adds Preferences-mode routing, native Preferences commands, shared shortcut handling, intercepted close requests, and capability access for the Preferences window.
Editor appearance settings
src/context/ThemeContext.tsx, src/components/settings/EditorSettingsSection.tsx, src/lib/titleBarNoteInfo.ts, src/lib/editorToolbar.ts, src/lib/editorWidthResize.ts
Adds persisted controls for editor resizing, toolbar visibility, and title-bar filename or modification-date display.
Settings synchronization and tests
src/context/GitContext.tsx, src/components/settings/SettingsPage.tsx, src/components/settings/*test*, src/lib/*test*
Adds folder-scoped Git settings events, optional settings back navigation, platform-specific window tests, and unit coverage for preferences and shortcuts.

Sidebar and editor layout

Layer / File(s) Summary
Sidebar sorting and folder navigation
src/components/layout/Sidebar.tsx, src/components/layout/SidebarControls.tsx, src/components/layout/SidebarFolderSection.tsx, src/components/notes/NoteList.tsx, src/components/notes/FolderTreeView.tsx, src/lib/folderTree.ts
Adds persisted newest-first or oldest-first sorting, a collapsible Folders section, section-aware keyboard navigation, and deterministic note ordering.
Editor width behavior and tests
src/components/editor/EditorWidthHandle.tsx, src/components/editor/EditorWidthHandle.test.tsx, src/components/layout/*test*, src/components/notes/*test*
Uses rendered ProseMirror width for resize calculations, supports disabled resize handles, and adds component coverage for sidebar and editor behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • erictli/scratch#196: Shares the editor, persistence, window lifecycle, settings, draft-checkpoint, and sidebar changes.
  • erictli/scratch#197: Shares the persistence, recovery, editor, settings, and window lifecycle implementation.
  • erictli/scratch#199: Directly overlaps with editor display and title-bar settings changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the pull request's main focus on standalone Markdown windows and Settings access.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/editor/Editor.tsx (1)

855-880: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the auto-save debounce with the documented interval.

The WYSIWYG auto-save timer uses 500 ms. The source-mode handler at Line 2362 uses 300 ms. The coding guidelines require a 300 ms auto-save debounce. Use one constant for both paths.

As per coding guidelines: "Debounce user-triggered operations: auto-save 300ms, search 150ms, file watcher 500ms, git status 1000ms".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 855 - 880, Update the
auto-save debounce in the saveTimeoutRef timer within the editor save flow to
use the shared 300 ms debounce constant, and reuse that same constant in the
source-mode handler near the existing 300 ms timer. Ensure both auto-save paths
remain aligned without introducing separate interval literals.

Source: Coding guidelines

🧹 Nitpick comments (15)
src-tauri/src/lib.rs (3)

1894-1902: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Drop the unsynchronized pre-read before save_if_revision.

This block reads the file outside the per-path lock only to obtain a ContentRevision value. persistence::save_if_revision repeats the read under the lock and performs the authoritative comparison, so the pre-check adds one full file read per save and cannot change the outcome. It also means the conflict snapshot returned from this early branch can already be stale.

Consider exposing a constructor such as ContentRevision::from_hex(&str) in persistence, then calling save_if_revision once and mapping its typed result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 1894 - 1902, Remove the unsynchronized
persistence::read_snapshot pre-check and expected revision match. Add or reuse a
persistence::ContentRevision constructor such as from_hex for expected_revision,
call persistence::save_if_revision exactly once so comparison occurs under the
per-path lock, and map its typed result into the existing FileSaveResult
outcomes, including conflict snapshots.

2108-2131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Plan retention for draft checkpoints.

Checkpoints are keyed by (window_label, note_id) and removed only by an explicit clear_draft_checkpoint for that exact key. Preview window labels derive from a hash of the file path, so every distinct file that a user ever opens in a standalone window can leave one checkpoint file behind. A crash, a forced quit, or a window that never clears its key leaves the record permanently. Each record stores the full note text.

list_draft_checkpoints also filters to the current window label, so checkpoints from labels that no longer exist are never surfaced and never cleaned. Consider adding an age-based sweep at startup, or a prune_draft_checkpoints command that drops records older than a fixed window.

Also applies to: 2169-2187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 2108 - 2131, Implement age-based retention
for draft checkpoints by adding a startup sweep or a prune_draft_checkpoints
command that removes records older than a fixed retention window, including
checkpoints whose window labels are no longer active. Integrate the cleanup with
the existing draft_checkpoint storage flow near write_draft_checkpoint and
list_draft_checkpoints, while preserving explicit key-based clearing behavior.

3939-3943: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Avoid indexing app.config().app.windows[0].

windows[0] panics if the window list in tauri.conf.json is empty. A panic inside the open_preferences_window command aborts the process instead of returning a user-facing error. The same indexing is now used at Line 3998 for the preview window. Use .first() and map the absence to an error.

🛡️ Proposed fix
-    let runtime_config = runtime_window_config_from_template(
-        &app.config().app.windows[0],
-        "preferences",
-        WebviewUrl::App("index.html?mode=preferences".into()),
-    );
+    let template = app
+        .config()
+        .app
+        .windows
+        .first()
+        .cloned()
+        .ok_or_else(|| "No window template is configured".to_string())?;
+    let runtime_config = runtime_window_config_from_template(
+        &template,
+        "preferences",
+        WebviewUrl::App("index.html?mode=preferences".into()),
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 3939 - 3943, Update the window
configuration lookups in open_preferences_window, including the preferences and
preview window paths near runtime_config, to use first() instead of indexing
windows[0]. Convert a missing window configuration into the command’s existing
user-facing error result rather than allowing a panic.
src-tauri/src/persistence.rs (1)

270-362: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the duplicated SHA-256 and atomic file helpers into a shared module. src-tauri/src/persistence.rs and src-tauri/src/draft_checkpoint.rs both define sha256, sha256_compress, hex_sha256, TemporaryPath, create_temporary_file, sync_parent_directory, and atomic-write helpers, but only persistence.rs includes tests for the SHA-256 behavior.

Prefer sha2::Sha256 over the hand-written hash if dependencies are allowed. Otherwise, centralize the existing helpers in one module and delete the duplicated code from draft_checkpoint.rs, keeping Windows replace-file behavior if needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/persistence.rs` around lines 270 - 362, In
src-tauri/src/persistence.rs lines 270-362 and src-tauri/src/draft_checkpoint.rs
lines 314-410, move the shared sha256, sha256_compress, hex_sha256,
TemporaryPath, create_temporary_file, sync_parent_directory, and atomic-write
helpers into one shared module, then update both callers to use it and remove
duplicate definitions. Prefer sha2::Sha256 if the dependency is available;
otherwise retain the existing implementation and its SHA-256 tests, preserving
Windows replace-file behavior.
src/context/ThemeContext.tsx (1)

547-598: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Surface a user-facing message when the new setters fail to persist.

setEditorWidthResizeEnabled, setEditorToolbarVisible, and updateTitleBarNoteInfo only call console.error when updateSettingsPatch fails. The user does not see any indication that their preference change was not saved.

As per coding guidelines, src/**/*.{ts,tsx} must "Implement error handling with user-friendly messages." Surface a toast or inline error (the app already renders <Toaster /> at the root) instead of only logging to the console.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 547 - 598, Update
setEditorWidthResizeEnabled, setEditorToolbarVisible, and updateTitleBarNoteInfo
so failures from updateSettingsPatch also trigger the app’s existing user-facing
toast mechanism, using a clear, friendly message for the relevant preference;
retain console.error logging if consistent with surrounding error handling and
preserve the current state updates.

Source: Coding guidelines

src/services/draftCheckpoint.ts (1)

23-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the parameter to noteId because windowLabel is ignored.

clearDraftCheckpoint accepts a full DraftCheckpointKey but forwards only noteId. The backend derives the window identity. The current signature invites callers to pass a meaningless windowLabel; src/components/preview/PreviewApp.tsx at line 269 already passes windowLabel: "". Accept noteId: string to make the contract explicit, and align it with getDraftCheckpoint.

♻️ Proposed change
-export async function clearDraftCheckpoint(
-  key: DraftCheckpointKey,
-): Promise<void> {
-  return invoke("clear_draft_checkpoint", { noteId: key.noteId });
+export async function clearDraftCheckpoint(noteId: string): Promise<void> {
+  return invoke("clear_draft_checkpoint", { noteId });
 }

Update the callers, for example in src/components/preview/PreviewApp.tsx:

await draftCheckpointService.clearDraftCheckpoint(filePath);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/draftCheckpoint.ts` around lines 23 - 27, Change
clearDraftCheckpoint to accept a noteId: string instead of DraftCheckpointKey,
while continuing to invoke clear_draft_checkpoint with that noteId. Update all
callers, including PreviewApp, to pass the note ID directly and remove unused
windowLabel object arguments, matching the getDraftCheckpoint contract.

Source: Coding guidelines

src/lib/standaloneRecreation.ts (1)

14-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider preserving the concurrent file state in the thrown error.

The conflict branch discards result.current, which holds the content and revision written by the other writer. The caller in src/components/preview/PreviewApp.tsx at line 250 can therefore only show a generic message and cannot offer a follow-up merge or accept action. Attach the current state to the error so the conflict UI can use it.

♻️ Proposed change
+export class StandaloneRecreationConflictError extends Error {
+  constructor(
+    readonly current: { content: string; revision: string } | null,
+  ) {
+    super("The source path was recreated elsewhere; conflict preserved");
+    this.name = "StandaloneRecreationConflictError";
+  }
+}
+
 export async function recreateDeletedStandaloneDraft(
   path: string,
   content: string,
   recreateFile: RecreateFile,
 ): Promise<FileContent> {
   const result = await recreateFile(path, content);
   if (result.status === "conflict") {
-    throw new Error(
-      "The source path was recreated elsewhere; conflict preserved",
-    );
+    throw new StandaloneRecreationConflictError(result.current);
   }
   return result.file;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/standaloneRecreation.ts` around lines 14 - 18, Update the conflict
branch in standalone recreation to attach result.current, including the
concurrent content and revision, to the thrown error. Preserve the existing
conflict message while exposing the current state so PreviewApp can render merge
or accept actions.
src/components/editor/Editor.tsx (4)

944-960: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Assign queueCheckpointCaptureRef.current inside an effect.

Line 944 writes a ref during render. React can discard a render, so the ref can hold a closure from work that never committed. Move the assignment into an effect that depends on persistCurrentCrashCheckpoint. The lazy scheduler initialization at Line 645 is a supported pattern and needs no change.

♻️ Proposed change
-  queueCheckpointCaptureRef.current = () => {
-    const now = Date.now();
-    checkpointCaptureStartedAtRef.current ??= now;
-    if (checkpointCaptureTimerRef.current) {
-      clearTimeout(checkpointCaptureTimerRef.current);
-    }
-    const delay = nextCheckpointCaptureDelay(
-      now - checkpointCaptureStartedAtRef.current,
-      250,
-      750,
-    );
-    checkpointCaptureTimerRef.current = window.setTimeout(() => {
-      checkpointCaptureTimerRef.current = null;
-      checkpointCaptureStartedAtRef.current = null;
-      void persistCurrentCrashCheckpoint();
-    }, delay);
-  };
+  useEffect(() => {
+    queueCheckpointCaptureRef.current = () => {
+      const now = Date.now();
+      checkpointCaptureStartedAtRef.current ??= now;
+      if (checkpointCaptureTimerRef.current) {
+        clearTimeout(checkpointCaptureTimerRef.current);
+      }
+      const delay = nextCheckpointCaptureDelay(
+        now - checkpointCaptureStartedAtRef.current,
+        250,
+        750,
+      );
+      checkpointCaptureTimerRef.current = window.setTimeout(() => {
+        checkpointCaptureTimerRef.current = null;
+        checkpointCaptureStartedAtRef.current = null;
+        void persistCurrentCrashCheckpoint();
+      }, delay);
+    };
+  }, [persistCurrentCrashCheckpoint]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 944 - 960, Move the assignment
to queueCheckpointCaptureRef.current into an effect dependent on
persistCurrentCrashCheckpoint, keeping the existing debounce and timer behavior
unchanged. Do not modify the lazy scheduler initialization or other checkpoint
logic.

Source: Linters/SAST tools


1794-1805: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Dispose the checkpoint scheduler on unmount.

The cleanup clears the capture timer but never calls checkpointScheduler.dispose(). dispose() in src/lib/draftCheckpoint.ts flushes the pending checkpoint. Without it, a checkpoint marked within the last debounce window is discarded when the editor unmounts. React cleanup cannot await the promise, so call dispose() and report failures through onError.

♻️ Proposed change
       if (checkpointCaptureTimerRef.current) {
         clearTimeout(checkpointCaptureTimerRef.current);
         checkpointCaptureStartedAtRef.current = null;
       }
+      void checkpointScheduler.dispose().catch((error) => {
+        console.error("Failed to flush crash checkpoint on unmount:", error);
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 1794 - 1805, Update the
useEffect cleanup in Editor.tsx to call checkpointScheduler.dispose() during
unmount, ensuring pending checkpoints are flushed; handle its asynchronous
failure without awaiting React cleanup by routing the rejection through onError.

962-975: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Route the visibility flush through the scheduler API.

This effect calls persistCurrentCrashCheckpoint directly. checkpointScheduler.handleVisibilityChange in src/lib/draftCheckpoint.ts exists for this case and has test coverage, but no caller. Either call it here after markDirty, or remove it from the scheduler contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 962 - 975, Update the
visibilitychange effect in Editor.tsx to route the flush through
checkpointScheduler.handleVisibilityChange instead of calling
persistCurrentCrashCheckpoint directly; ensure the checkpoint is marked dirty
before invoking the scheduler API, and preserve the existing timer cleanup and
listener lifecycle.

595-604: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Memoize titleBarNoteInfo.

Editor re-renders on every selection change because of setSelectionKey. getTitleBarNoteInfoText then calls formatDateTime again on each render. Wrap the computation in useMemo keyed on the visibility flags and the note fields.

As per coding guidelines: "Use useCallback and useMemo for performance-critical paths".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 595 - 604, Memoize the
titleBarNoteInfo computation in Editor using useMemo, including currentNote,
titleBarModifiedDateVisible, titleBarFilenameVisible, and formatDateTime-related
note fields in its dependencies so selection-only re-renders reuse the cached
value while changes to visibility or note data recompute it.

Source: Coding guidelines

src/lib/draftCheckpoint.ts (1)

176-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the type import to the top of the file.

The Note type import sits after every declaration. TypeScript hoists it, so the code compiles, but the placement hides the dependency and breaks the file convention.

♻️ Proposed move

At the top of the file:

+import type { Note } from "../types/note";
+
 export interface DraftCheckpointKey {

At the end of the file:

-import type { Note } from "../types/note";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/draftCheckpoint.ts` at line 176, Move the Note type import to the top
import section of draftCheckpoint.ts, before all declarations, while preserving
its type-only import form and removing the trailing import.
src/lib/conflictResolution.test.ts (1)

61-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for a clean draft.

runConflictResolution skips persistRecovery when draft.dirty is false. No test covers that branch. Add one case that asserts persistRecovery is not called and the strategy action still runs.

💚 Proposed additional test
+  it("skips recovery for a clean draft", async () => {
+    const persistRecovery = vi.fn(async () => "/recovery/Plan.md");
+    const acceptRemote = vi.fn(async () => undefined);
+
+    await runConflictResolution(
+      "useRemote",
+      { draft: { content: remote.content, dirty: false }, remote },
+      {
+        persistRecovery,
+        overwriteRemote: async () => undefined,
+        recreateDeleted: async () => undefined,
+        acceptRemote,
+      },
+    );
+
+    expect(persistRecovery).not.toHaveBeenCalled();
+    expect(acceptRemote).toHaveBeenCalledWith(remote);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/conflictResolution.test.ts` around lines 61 - 74, Add a clean-draft
test alongside the existing dirty-draft case for runConflictResolution, setting
draft.dirty to false and mocking persistRecovery. Assert persistRecovery is not
called, and verify the selected strategy action, such as overwriteRemote, still
executes successfully.
src/lib/windowCloseCallsites.test.ts (1)

16-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Broaden the forbidden close patterns.

The assertions match only the literal identifier appWindow. src/App.tsx closes the window with getCurrentWindow().close() at the Cmd/Ctrl+W handler, so a future switch to getCurrentWindow().destroy() would pass this test. Match .destroy( instead of the appWindow-prefixed form to make the guard resistant to renamed locals.

♻️ Proposed change
       expect(source).toContain("closeWindowAfterSave");
-      expect(source).not.toContain("appWindow.close()");
-      expect(source).not.toContain("appWindow.destroy()");
+      expect(source).not.toMatch(/\.destroy\s*\(/);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/windowCloseCallsites.test.ts` around lines 16 - 18, Update the
forbidden-pattern assertions in the window-close source test to reject any
`.destroy(` invocation, not only `appWindow.destroy()`, while preserving the
existing `appWindow.close()` guard and `closeWindowAfterSave` requirement.
src/lib/useWindowShortcuts.ts (1)

14-17: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving the ref writes out of the render body.

Lines 16-17 write ref.current during render. React can discard or replay a render, so the write can leak from UI that never commits. The same pattern exists elsewhere in this repository, so this is not a new defect. An effect keeps render pure and satisfies the no-ref-current-in-render rule.

♻️ Proposed change
   const interfaceZoomRef = useRef(interfaceZoom);
   const openPreferencesRef = useRef(onOpenPreferences);
-  interfaceZoomRef.current = interfaceZoom;
-  openPreferencesRef.current = onOpenPreferences;
+  useEffect(() => {
+    interfaceZoomRef.current = interfaceZoom;
+    openPreferencesRef.current = onOpenPreferences;
+  }, [interfaceZoom, onOpenPreferences]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/useWindowShortcuts.ts` around lines 14 - 17, Move the assignments to
interfaceZoomRef.current and openPreferencesRef.current out of the render body
in useWindowShortcuts and into an appropriate effect, preserving the refs’
latest values while keeping render pure and satisfying no-ref-current-in-render.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/draft_checkpoint.rs`:
- Around line 120-128: Update list_checkpoints so individual checkpoint entries
that fail path access, file reading, JSON deserialization, or
ensure_identity_matches are skipped rather than propagated as errors. Continue
collecting valid checkpoints and preserve the existing extension filtering and
directory-iteration behavior.

In `@src-tauri/src/lib.rs`:
- Around line 3867-3881: Update the menu-building logic around the
application_menu_name lookup so the Preferences item is inserted into the
default Windows/Linux submenu rather than only a product-name submenu. Use
platform-aware selection of the appropriate File or Help menu, while preserving
the existing macOS product-name behavior and the CmdOrCtrl+, menu path.

In `@src-tauri/src/persistence.rs`:
- Around line 184-203: Update atomic_create_new to retry destination creation
with OpenOptions write/create_new after fs::hard_link returns Unsupported or
PermissionDenied, preserving the existing create-only behavior; propagate other
hard-link errors unchanged and retain temporary-file cleanup and
parent-directory syncing.

In `@src/App.tsx`:
- Around line 110-132: Update the close handling around runSafeWindowClose in
App.tsx so its resolved { recoveredTo, saveError } result is processed on every
close path. When saveError is present, persist a recovery notice using the
existing startup-message mechanism and include recoveredTo; retain rejection
handling for failures without a recovery result. Apply the equivalent result
handling in PreviewApp.tsx, and do not rely on a transient Sonner toast that
disappears when the window closes.

In `@src/components/editor/Editor.tsx`:
- Around line 2495-2501: Remove the role="status" attribute from the conflict
trigger button near the “Resolve Conflict” label, preserving the button’s native
semantics. Leave the separate non-interactive status indicator unchanged.

In `@src/components/layout/Sidebar.tsx`:
- Around line 185-209: The handleNoteSortOrderChange callback currently performs
a stale read-modify-write through getSettings and updateSettings. Replace this
flow with the supported atomic sidebarSortOrder update mechanism, passing only
the changed field if updateSettings preserves other settings, while retaining
the existing optimistic state update and rollback/error behavior.

In `@src/components/layout/SidebarFolderSection.tsx`:
- Around line 51-71: Update the disclosure content rendering in the component
containing the Folders button so the wrapper with id={contentId} is always
present in the DOM, while toggling its visibility based on expanded instead of
conditionally unmounting it. Preserve the existing children and aria-expanded
behavior so aria-controls consistently references an existing element.

In `@src/components/preview/PreviewApp.tsx`:
- Around line 103-109: Update the file-loading useEffect around readFileDirect
and getDraftCheckpoint to track disposal with a cancellation flag and return
cleanup that sets it when filePath changes or the component unmounts. Before
writing loaded content, revision state, or clearing the checkpoint, check that
the effect is still active so stale asynchronous results cannot update state.
- Around line 110-127: Update the revision assignment in the recovery flow
around revisionRef.current and setRevision so recovered checkpoint content uses
checkpoint.metadata.baseRevision as its save base, while unchanged or absent
checkpoints continue using result.revision. Ensure both revision state values
remain consistent so the next save detects external changes through the backend
instead of overwriting them.
- Around line 91-94: Handle rejection of the promise returned by
appWindow.onCloseRequested in the disposed/unlisten registration flow by adding
a catch handler that logs the registration failure and informs the user,
preventing an unhandled rejection and preserving draft-flush feedback.

In `@src/context/ThemeContext.tsx`:
- Around line 235-238: Serialize the read-merge-write operations in
updateSettingsPatch by routing each invocation through a shared promise queue,
so the next call waits for the previous getSettings and updateSettings sequence
to finish. Ensure the queue continues processing after a failed update, and
preserve the existing patch merge behavior for all callers including
setEditorWidthResizeEnabled, setEditorToolbarVisible, and
updateTitleBarNoteInfo.

In `@src/lib/windowShortcutCallsites.test.ts`:
- Around line 24-31: Validate that preferencesStart and preferencesEnd are found
before calling source.slice in the test, asserting each marker index is
non-negative. Keep the existing PreferencesApp and App marker lookup and
subsequent assertions unchanged so renames fail explicitly rather than producing
an empty slice.

---

Outside diff comments:
In `@src/components/editor/Editor.tsx`:
- Around line 855-880: Update the auto-save debounce in the saveTimeoutRef timer
within the editor save flow to use the shared 300 ms debounce constant, and
reuse that same constant in the source-mode handler near the existing 300 ms
timer. Ensure both auto-save paths remain aligned without introducing separate
interval literals.

---

Nitpick comments:
In `@src-tauri/src/lib.rs`:
- Around line 1894-1902: Remove the unsynchronized persistence::read_snapshot
pre-check and expected revision match. Add or reuse a
persistence::ContentRevision constructor such as from_hex for expected_revision,
call persistence::save_if_revision exactly once so comparison occurs under the
per-path lock, and map its typed result into the existing FileSaveResult
outcomes, including conflict snapshots.
- Around line 2108-2131: Implement age-based retention for draft checkpoints by
adding a startup sweep or a prune_draft_checkpoints command that removes records
older than a fixed retention window, including checkpoints whose window labels
are no longer active. Integrate the cleanup with the existing draft_checkpoint
storage flow near write_draft_checkpoint and list_draft_checkpoints, while
preserving explicit key-based clearing behavior.
- Around line 3939-3943: Update the window configuration lookups in
open_preferences_window, including the preferences and preview window paths near
runtime_config, to use first() instead of indexing windows[0]. Convert a missing
window configuration into the command’s existing user-facing error result rather
than allowing a panic.

In `@src-tauri/src/persistence.rs`:
- Around line 270-362: In src-tauri/src/persistence.rs lines 270-362 and
src-tauri/src/draft_checkpoint.rs lines 314-410, move the shared sha256,
sha256_compress, hex_sha256, TemporaryPath, create_temporary_file,
sync_parent_directory, and atomic-write helpers into one shared module, then
update both callers to use it and remove duplicate definitions. Prefer
sha2::Sha256 if the dependency is available; otherwise retain the existing
implementation and its SHA-256 tests, preserving Windows replace-file behavior.

In `@src/components/editor/Editor.tsx`:
- Around line 944-960: Move the assignment to queueCheckpointCaptureRef.current
into an effect dependent on persistCurrentCrashCheckpoint, keeping the existing
debounce and timer behavior unchanged. Do not modify the lazy scheduler
initialization or other checkpoint logic.
- Around line 1794-1805: Update the useEffect cleanup in Editor.tsx to call
checkpointScheduler.dispose() during unmount, ensuring pending checkpoints are
flushed; handle its asynchronous failure without awaiting React cleanup by
routing the rejection through onError.
- Around line 962-975: Update the visibilitychange effect in Editor.tsx to route
the flush through checkpointScheduler.handleVisibilityChange instead of calling
persistCurrentCrashCheckpoint directly; ensure the checkpoint is marked dirty
before invoking the scheduler API, and preserve the existing timer cleanup and
listener lifecycle.
- Around line 595-604: Memoize the titleBarNoteInfo computation in Editor using
useMemo, including currentNote, titleBarModifiedDateVisible,
titleBarFilenameVisible, and formatDateTime-related note fields in its
dependencies so selection-only re-renders reuse the cached value while changes
to visibility or note data recompute it.

In `@src/context/ThemeContext.tsx`:
- Around line 547-598: Update setEditorWidthResizeEnabled,
setEditorToolbarVisible, and updateTitleBarNoteInfo so failures from
updateSettingsPatch also trigger the app’s existing user-facing toast mechanism,
using a clear, friendly message for the relevant preference; retain
console.error logging if consistent with surrounding error handling and preserve
the current state updates.

In `@src/lib/conflictResolution.test.ts`:
- Around line 61-74: Add a clean-draft test alongside the existing dirty-draft
case for runConflictResolution, setting draft.dirty to false and mocking
persistRecovery. Assert persistRecovery is not called, and verify the selected
strategy action, such as overwriteRemote, still executes successfully.

In `@src/lib/draftCheckpoint.ts`:
- Line 176: Move the Note type import to the top import section of
draftCheckpoint.ts, before all declarations, while preserving its type-only
import form and removing the trailing import.

In `@src/lib/standaloneRecreation.ts`:
- Around line 14-18: Update the conflict branch in standalone recreation to
attach result.current, including the concurrent content and revision, to the
thrown error. Preserve the existing conflict message while exposing the current
state so PreviewApp can render merge or accept actions.

In `@src/lib/useWindowShortcuts.ts`:
- Around line 14-17: Move the assignments to interfaceZoomRef.current and
openPreferencesRef.current out of the render body in useWindowShortcuts and into
an appropriate effect, preserving the refs’ latest values while keeping render
pure and satisfying no-ref-current-in-render.

In `@src/lib/windowCloseCallsites.test.ts`:
- Around line 16-18: Update the forbidden-pattern assertions in the window-close
source test to reject any `.destroy(` invocation, not only
`appWindow.destroy()`, while preserving the existing `appWindow.close()` guard
and `closeWindowAfterSave` requirement.

In `@src/services/draftCheckpoint.ts`:
- Around line 23-27: Change clearDraftCheckpoint to accept a noteId: string
instead of DraftCheckpointKey, while continuing to invoke clear_draft_checkpoint
with that noteId. Update all callers, including PreviewApp, to pass the note ID
directly and remove unused windowLabel object arguments, matching the
getDraftCheckpoint contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d5169c9b-67d3-49f6-9aa8-df86ce4a1f28

📥 Commits

Reviewing files that changed from the base of the PR and between 9126a5a and 1cb30a5.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (57)
  • package.json
  • src-tauri/capabilities/default.json
  • src-tauri/src/draft_checkpoint.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/persistence.rs
  • src/App.tsx
  • src/components/editor/Editor.tsx
  • src/components/editor/EditorWidthHandle.test.tsx
  • src/components/editor/EditorWidthHandle.tsx
  • src/components/layout/Sidebar.tsx
  • src/components/layout/SidebarControls.test.tsx
  • src/components/layout/SidebarControls.tsx
  • src/components/layout/SidebarFolderSection.test.tsx
  • src/components/layout/SidebarFolderSection.tsx
  • src/components/notes/FolderTreeView.test.tsx
  • src/components/notes/FolderTreeView.tsx
  • src/components/notes/NoteList.tsx
  • src/components/preview/PreviewApp.tsx
  • src/components/settings/EditorSettingsSection.test.tsx
  • src/components/settings/EditorSettingsSection.tsx
  • src/components/settings/SettingsPage.test.tsx
  • src/components/settings/SettingsPage.tsx
  • src/context/ThemeContext.tsx
  • src/lib/conflictResolution.test.ts
  • src/lib/conflictResolution.ts
  • src/lib/draftCheckpoint.test.ts
  • src/lib/draftCheckpoint.ts
  • src/lib/editorToolbar.test.ts
  • src/lib/editorToolbar.ts
  • src/lib/editorWidthResize.test.ts
  • src/lib/editorWidthResize.ts
  • src/lib/folderTree.test.ts
  • src/lib/folderTree.ts
  • src/lib/serializedWriter.test.ts
  • src/lib/serializedWriter.ts
  • src/lib/standaloneRecreation.test.ts
  • src/lib/standaloneRecreation.ts
  • src/lib/standaloneReload.test.ts
  • src/lib/standaloneReload.ts
  • src/lib/titleBarNoteInfo.test.ts
  • src/lib/titleBarNoteInfo.ts
  • src/lib/useWindowShortcuts.ts
  • src/lib/windowClose.test.ts
  • src/lib/windowClose.ts
  • src/lib/windowCloseCallsites.test.ts
  • src/lib/windowShortcutCallsites.test.ts
  • src/lib/windowShortcuts.test.ts
  • src/lib/windowShortcuts.ts
  • src/services/draftCheckpoint.test.ts
  • src/services/draftCheckpoint.ts
  • src/services/files.test.ts
  • src/services/files.ts
  • src/services/notes.ts
  • src/services/windowLifecycle.test.ts
  • src/services/windowLifecycle.ts
  • src/types/note.ts
  • vitest.config.ts

Comment thread src-tauri/src/draft_checkpoint.rs
Comment thread src-tauri/src/lib.rs Outdated
Comment thread src-tauri/src/persistence.rs
Comment thread src/App.tsx Outdated
Comment thread src/components/editor/Editor.tsx
Comment thread src/components/preview/PreviewApp.tsx Outdated
Comment thread src/components/preview/PreviewApp.tsx
Comment thread src/components/preview/PreviewApp.tsx
Comment on lines +235 to +238
const updateSettingsPatch = useCallback(async (patch: Partial<Settings>) => {
const settings = await getSettings();
await updateSettings({ ...settings, ...patch });
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize updateSettingsPatch writes to prevent lost settings updates.

updateSettingsPatch reads settings with getSettings(), then writes the merged result with updateSettings(). If two calls overlap (for example, a user quickly toggles two preferences that each call this helper, such as setEditorWidthResizeEnabled and setEditorToolbarVisible), both calls can read the same stale settings object. The second write then persists over the first call's patch, and the first change is silently lost.

The reset flow already recognizes this hazard: resetEditorFontSettings documents a "single atomic save to avoid race conditions." Extend that same safeguard to updateSettingsPatch, since it now backs three separate setters (setEditorWidthResizeEnabled, setEditorToolbarVisible, updateTitleBarNoteInfo) that can each fire independently.

🔒 Proposed fix: serialize writes through a queue
+  const settingsWriteQueueRef = useRef(Promise.resolve());
   const updateSettingsPatch = useCallback(async (patch: Partial<Settings>) => {
-    const settings = await getSettings();
-    await updateSettings({ ...settings, ...patch });
+    const next = settingsWriteQueueRef.current.then(async () => {
+      const settings = await getSettings();
+      await updateSettings({ ...settings, ...patch });
+    });
+    settingsWriteQueueRef.current = next.catch(() => {});
+    return next;
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const updateSettingsPatch = useCallback(async (patch: Partial<Settings>) => {
const settings = await getSettings();
await updateSettings({ ...settings, ...patch });
}, []);
const settingsWriteQueueRef = useRef(Promise.resolve());
const updateSettingsPatch = useCallback(async (patch: Partial<Settings>) => {
const next = settingsWriteQueueRef.current.then(async () => {
const settings = await getSettings();
await updateSettings({ ...settings, ...patch });
});
settingsWriteQueueRef.current = next.catch(() => {});
return next;
}, []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 235 - 238, Serialize the
read-merge-write operations in updateSettingsPatch by routing each invocation
through a shared promise queue, so the next call waits for the previous
getSettings and updateSettings sequence to finish. Ensure the queue continues
processing after a failed update, and preserve the existing patch merge behavior
for all callers including setEditorWidthResizeEnabled, setEditorToolbarVisible,
and updateTitleBarNoteInfo.

Comment thread src/lib/windowShortcutCallsites.test.ts

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/context/ThemeContext.tsx (2)

468-499: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Add the missing try { in resetEditorFontSettings.

The catch block at Line 494 has no matching try. The function body goes straight from the state setters to await updateGlobalSettings({...}) at Line 480, then to } catch (error) {. The file does not parse. Biome reports parse errors at Lines 494-499 for this reason.

🐛 Proposed fix: open the `try` block before the persist call
     setCustomColorsLightState({});
     setCustomColorsDarkState({});
-    await updateGlobalSettings({
+    try {
+      await updateGlobalSettings({
         editorFont: defaultEditorFontSettings,
         textDirection: "auto",
         editorWidth: "normal",
         interfaceZoom: 1.0,
         customEditorWidthPx: null,
         editorWidthResizeEnabled: null,
         editorToolbarVisible: null,
         titleBarModifiedDateVisible: null,
         titleBarFilenameVisible: null,
         sidebarWidthPx: null,
         customColorsLight: null,
         customColorsDark: null,
       });
     } catch (error) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 468 - 499, In
resetEditorFontSettings, add the missing try block before the await
updateGlobalSettings persistence call so the existing catch handler correctly
matches it. Keep the current state updates, error logging, toast, and
loadSettingsFromBackend recovery behavior unchanged.

Source: Linters/SAST tools


719-723: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Move the useMemo call above the early return.

Line 719 returns null when isInitialized is false. Line 723 then calls useMemo. React skips that hook on the first render and calls it on the second render, after isInitialized becomes true. The hook count changes between renders, and React throws "Rendered more hooks than during the previous render". The provider crashed path is reached on every mount, because isInitialized always starts as false.

The previous inline context object was not a hook, so the early return was safe. Introducing useMemo requires moving the guard after all hook calls.

🐛 Proposed fix: compute the memo first, then guard
-  // Don't render until initialized to prevent flash
-  if (!isInitialized) {
-    return null;
-  }
-
   const contextValue = useMemo<ThemeContextType>(

Then place the guard directly before the return:

+  // Don't render until initialized to prevent flash
+  if (!isInitialized) {
+    return null;
+  }
+
   return (
     <ThemeContext.Provider value={contextValue}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 719 - 723, Move the useMemo call
that creates contextValue above the isInitialized early-return guard in the
theme provider, then keep the guard immediately before the final return. Ensure
all hooks execute on every render while preserving the existing null result
until initialization completes.
src/components/notes/NoteList.tsx (1)

326-329: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not re-sort search results by modified date.

displayItems holds backend search results when a query is active (Lines 314-324). Those results arrive ranked by relevance score. sortedDisplayItems then re-sorts every item by modified date, so the ranking is discarded and the flat list at Line 428 renders search hits in date order. Before this change the flat list rendered displayItems directly and preserved relevance order.

Apply the sidebar sort order only when no search is active.

🐛 Proposed fix
   const sortedDisplayItems = useMemo(
-    () => sortNotesByModified(displayItems, sortOrder),
-    [displayItems, sortOrder],
+    () =>
+      searchQuery.trim()
+        ? displayItems
+        : sortNotesByModified(displayItems, sortOrder),
+    [displayItems, searchQuery, sortOrder],
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/notes/NoteList.tsx` around lines 326 - 329, Update the
sortedDisplayItems useMemo in NoteList so sortNotesByModified is applied only
when no search query is active; while searching, return displayItems unchanged
to preserve backend relevance ranking, and retain the existing sortOrder
behavior for non-search results.
🧹 Nitpick comments (11)
src/services/notes.test.ts (2)

147-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the duplicateNote test out of the scoped settings writes block.

The block name describes settings writes. The test at lines 169-186 verifies the duplicate_note command, which is not a settings write. Place it in its own describe block so the suite name matches the behavior under test.

Also applies to: 169-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/notes.test.ts` at line 147, Move the duplicateNote test covering
the duplicate_note command out of the “scoped settings writes” describe block,
and place it in a separate describe block named for duplicate-note behavior.
Keep the test logic unchanged and ensure the surrounding suite names accurately
match the behaviors they contain.

115-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the negative assertions to lock the single-command contract.

Each not.toHaveBeenCalledWith assertion pairs a command name with one exact payload. The assertion passes if the service calls the forbidden command with any other payload. It also passes if the service calls a third, unexpected command.

Assert the call count instead. Each of these services must issue exactly one command.

♻️ Proposed assertion change (shown for `openWorkspaceWindow`)
     expect(invokeMock).toHaveBeenCalledWith("open_workspace_window", {
       path: "/notes/client",
     });
-    expect(invokeMock).not.toHaveBeenCalledWith("set_notes_folder", {
-      path: "/notes/client",
-    });
+    expect(invokeMock).toHaveBeenCalledTimes(1);

Also applies to: 128-130, 141-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/notes.test.ts` around lines 115 - 117, Strengthen the negative
assertions in the affected service tests, including the cases around invokeMock
at the shown locations, to verify exactly one command call rather than only
excluding one command/payload pair. Assert invokeMock’s total call count is one
while preserving the existing expected command assertion, covering
openWorkspaceWindow and the other noted cases.
src/components/layout/WorkspaceMenu.tsx (1)

40-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move focus into the menu when it opens, and close it when focus leaves.

The component restores focus on Escape, which is correct. Two gaps remain for keyboard users:

  • When open becomes true, focus stays on the trigger. A keyboard user must Tab through the trigger before reaching the first workspace item.
  • The effect only listens for pointerdown. If the user Tabs past the last menu item, the menu stays open while focus sits outside it.

Add a focus-in effect and a focusout dismissal.

♻️ Proposed focus handling
   useEffect(() => {
     if (!open) return;
 
+    rootRef.current
+      ?.querySelector<HTMLElement>('[role="menuitemradio"], [role="menuitem"]')
+      ?.focus();
+  }, [open]);
+
+  useEffect(() => {
+    if (!open) return;
+
     const handlePointerDown = (event: PointerEvent) => {
+    const handleFocusOut = (event: FocusEvent) => {
+      const next = event.relatedTarget as Node | null;
+      if (next && !rootRef.current?.contains(next)) {
+        setActionMenuPath(null);
+        setOpen(false);
+      }
+    };
+
     document.addEventListener("pointerdown", handlePointerDown);
     document.addEventListener("keydown", handleKeyDown);
+    document.addEventListener("focusout", handleFocusOut);
     return () => {
       document.removeEventListener("pointerdown", handlePointerDown);
       document.removeEventListener("keydown", handleKeyDown);
+      document.removeEventListener("focusout", handleFocusOut);
     };
   }, [actionMenuPath, open]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/WorkspaceMenu.tsx` around lines 40 - 78, Update the
focus handling around the existing open/actionMenuPath effect: when open becomes
true, move focus to the first workspace menu item instead of leaving it on the
trigger, and add a focusout listener that closes the menu when focus moves
outside the menu root. Preserve the existing Escape behavior and action-submenu
focus restoration, and clean up the new listener on effect teardown.
src/lib/useWindowShortcuts.ts (1)

35-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider the updater form of setInterfaceZoom to avoid listener churn.

interfaceZoom is in the dependency array, so the keydown listener is removed and re-added after every zoom step. setInterfaceZoom in src/context/ThemeContext.tsx (Lines 522-533) already accepts an updater and applies the same clamp and rounding. Using the updater removes the duplicated math and the interfaceZoom dependency.

The toast label needs the resolved value, so compute it inside the updater.

♻️ Proposed refactor
       const delta = action === "zoom-in" ? 0.05 : -0.05;
-      const next = Math.round(
-        Math.min(Math.max(interfaceZoom + delta, 0.7), 1.5) * 20,
-      ) / 20;
-      setInterfaceZoom(next);
-      toast(`Zoom ${Math.round(next * 100)}%`, {
-        id: "zoom",
-        duration: 1500,
-      });
+      setInterfaceZoom((prev) => {
+        const next = Math.round(Math.min(Math.max(prev + delta, 0.7), 1.5) * 20) / 20;
+        toast(`Zoom ${Math.round(next * 100)}%`, { id: "zoom", duration: 1500 });
+        return next;
+      });
     };
 
     window.addEventListener("keydown", handleKeyDown);
     return () => window.removeEventListener("keydown", handleKeyDown);
-  }, [interfaceZoom, onOpenPreferences, setInterfaceZoom]);
+  }, [onOpenPreferences, setInterfaceZoom]);

Note: calling toast inside the updater runs during render scheduling. If that is a concern, keep the toast outside and read the value from a ref instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/useWindowShortcuts.ts` around lines 35 - 48, Update the zoom handling
in the keydown handler to call setInterfaceZoom with an updater, relying on its
existing clamp and rounding logic instead of duplicating the calculation and
removing interfaceZoom from the effect dependency array. Compute the resolved
zoom value inside the updater and use it for the toast label, while preserving
the current zoom-step behavior and bounds.
src/components/settings/SettingsPage.test.tsx (1)

30-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unmount the roots created by mountSettingsPage.

mountSettingsPage returns root, but the tests at Lines 45-65 never call root.unmount(). The afterEach hook at Lines 26-28 detaches the container nodes without unmounting, so the React roots stay alive across tests. Return an unmount helper, or unmount in afterEach by tracking the created roots.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/SettingsPage.test.tsx` around lines 30 - 42, Ensure
every React root created by mountSettingsPage is unmounted during test cleanup.
Update the helper and/or the existing afterEach hook to track returned roots and
call root.unmount() before removing their containers, covering all tests that
mount SettingsPage.
src/lib/useWindowSessionPersistence.ts (1)

171-179: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Cancel the writer after the final flush.

The unmount effect flushes pending patches but never calls writer.cancel(). If a write fails during or after unmount, createWindowSessionPatchWriter schedules a retry timer that keeps firing for a window that no longer exists. Cancel the writer after the flush settles so no retry timer survives unmount.

♻️ Proposed cleanup
   useEffect(() => {
     return () => {
       void writer
         .flush()
         .catch((error) => {
           console.warn("Final window session update failed", error);
+        })
+        .finally(() => {
+          writer.cancel();
         });
     };
   }, [writer]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/useWindowSessionPersistence.ts` around lines 171 - 179, Update the
unmount cleanup effect around writer.flush to call writer.cancel after the final
flush settles, including when flush rejects. Preserve the existing warning for
flush failures and ensure cancellation always runs so retry timers from
createWindowSessionPatchWriter cannot survive unmount.
src/lib/windowSession.test.ts (1)

118-150: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add tests for flush, cancel, and the write-failure retry.

The suite covers only the debounce and coalesce path. createWindowSessionPatchWriter also re-queues the failed patch and schedules a retry, drops pending work after cancel(), and serializes against an in-flight write. These branches carry the session-loss risk and are untested. Add cases that reject the first write and assert the retry sends the merged patch, and that cancel() prevents any further write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/windowSession.test.ts` around lines 118 - 150, Extend the
createWindowSessionPatchWriter test suite with cases covering flush, cancel, and
write-failure retry: reject the first write and verify the merged patch is
retried, verify flush writes pending work immediately while respecting any
in-flight write, and verify cancel drops queued work and prevents subsequent
writes. Use the existing fake-timer setup and writer API, keeping assertions
focused on write order and patch contents.
src/lib/workspaceSwitch.test.ts (1)

5-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that loadWorkspace receives the backend-resolved path.

runWorkspaceSwitch passes the value returned by switchBackendWorkspace to loadWorkspace, not requestedPath. This test returns the same string from the mock, so a regression that passes requestedPath through would still pass. Return a different path from switchBackendWorkspace and assert the argument of loadWorkspace.

💚 Proposed test addition
   it("flushes the current draft before rebinding and loading the next workspace", async () => {
it("loads the workspace path resolved by the backend", async () => {
  const loadWorkspace = vi.fn(async () => {});

  const path = await runWorkspaceSwitch("/notes/requested", {
    flushCurrentDraft: vi.fn(async () => {}),
    switchBackendWorkspace: vi.fn(async () => "/notes/resolved"),
    loadWorkspace,
  });

  expect(path).toBe("/notes/resolved");
  expect(loadWorkspace).toHaveBeenCalledWith("/notes/resolved");
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/workspaceSwitch.test.ts` around lines 5 - 23, Add coverage for
backend-resolved paths in the runWorkspaceSwitch test: make
switchBackendWorkspace return a path different from the requested path, capture
the loadWorkspace mock, and assert both the returned value and that
loadWorkspace receives the resolved path.
src-tauri/src/hashing.rs (2)

203-231: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test vectors that exercise the multi-block and long-padding branches.

The current vectors are b"" (0 bytes) and b"abc" (3 bytes). Both take the remainder.len() < 56 branch at Line 105 and never enter the chunks_exact(64) loop at Line 98. The two-block padding path and the message-schedule carry across blocks stay untested. A regression in either path would produce silently wrong revisions and checkpoint file names.

Add published vectors for inputs of 56–63 bytes and for inputs longer than 64 bytes.

💚 Proposed additional test vectors
     #[test]
     fn sha256_hex_deterministic() {
         assert_eq!(sha256_hex(b"same"), sha256_hex(b"same"));
         assert_ne!(sha256_hex(b"same"), sha256_hex(b"changed"));
     }
+
+    #[test]
+    fn sha256_hex_two_block_padding() {
+        // 56 bytes: forces the 128-byte padding branch.
+        assert_eq!(
+            sha256_hex(&[b'a'; 56]),
+            "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a"
+        );
+    }
+
+    #[test]
+    fn sha256_hex_multi_block_input() {
+        // 448 bits + full blocks: exercises chunks_exact(64).
+        assert_eq!(
+            sha256_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
+            "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
+        );
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/hashing.rs` around lines 203 - 231, Add published SHA-256 test
vectors in the hashing tests for inputs of 56–63 bytes, including the two-block
padding boundary, and for an input longer than 64 bytes to exercise multi-block
processing and message-schedule carry. Extend the existing sha256_hex tests with
fixed expected digests, while preserving the current empty, abc, and determinism
coverage.

166-197: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

From<String> and From<&str> let any string become a ContentRevision.

ContentRevision documents itself as a "Stable SHA-256 identifier". The two From impls accept arbitrary text with no length or alphabet check. A caller can build a revision that no content can ever produce, and save_if_revision in src-tauri/src/persistence.rs then compares it against real digests. The comparison always fails and reports a conflict. Consider a checked constructor that validates 64 lowercase hexadecimal characters, and keep the unchecked conversions private to the crate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/hashing.rs` around lines 166 - 197, Restrict ContentRevision
creation to valid SHA-256 identifiers: add a checked constructor that accepts
exactly 64 lowercase hexadecimal characters and rejects all other strings, then
make the existing From<String> and From<&str> conversions crate-private or
otherwise prevent external callers from using them unchecked. Preserve
content_revision’s generated digest behavior and ensure save_if_revision
comparisons only receive validated revisions.
src-tauri/src/persistence.rs (1)

113-134: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

lock_key can produce two keys for one file.

lock_key canonicalizes the nearest existing ancestor and then appends the untouched suffix. If two callers reach the same file through different symlinked parents, or through paths that differ only in . or .. components inside the suffix, they get different keys and therefore different mutexes. The in-process serialization in save_if_revision is then lost for that file. Canonicalizing the full parent chain, or canonicalizing the destination itself when it exists, removes the gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/persistence.rs` around lines 113 - 134, Update lock_key to
return one normalized key for equivalent paths by canonicalizing the full
destination when it exists, or otherwise canonicalizing the nearest existing
ancestor and normalizing the remaining suffix so "."/".." and symlinked parents
cannot produce distinct keys. Preserve the existing fallback behavior when
canonicalization fails, and ensure save_if_revision continues using this unified
key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/draft_checkpoint.rs`:
- Around line 409-425: Update the mismatch_content JSON in
list_checkpoints_skips_identity_mismatch to use the camelCase fields expected by
DraftCheckpointKey and DraftCheckpointMetadata, including windowLabel, noteId,
sourcePath, baseRevision, and updatedAt. Keep the filename identity mismatch
intact so deserialization succeeds and ensure_identity_matches performs the
rejection.

In `@src-tauri/src/lib.rs`:
- Around line 1917-1933: Complete the workspace-scoped migration in
src-tauri/src/lib.rs: at lines 1917-1933 restore configure_main_workspace’s
parameters and return type, then initialize normalized_path and session via
prepare_notes_folder and WorkspaceSession::initialize_prepared before the
existing config block; at lines 1910-1914 remove the stale load_settings
assignment; at lines 3809-3826 close update_workspace_settings after Ok(()) and
remove its nested duplicate get_settings; at lines 3888-3911 remove the stale
second half of update_git_enabled that updates state.settings; and at lines
6259-6276 remove the obsolete startup settings/search_index computation and
unterminated binding.
- Around line 3126-3138: Update both collision-retry branches in duplicate_note
to build subsequent candidate IDs from the sanitized identifier rather than raw
copy_title. Preserve the existing folder_prefix formatting and counter behavior,
including the branch around abs_path_from_id and the later conflict branch, so
retries remain valid sanitized IDs.

In `@src-tauri/src/persistence.rs`:
- Around line 204-216: Update the fallback write flow around write_result,
flush_result, sync_result, and sync_parent_directory so write, flush, or
sync_all failures remove the destination file before returning the error. Defer
the parent-directory sync error until after reporting the write sequence errors,
ensuring the most specific write failure takes precedence while preserving
cleanup of the temporary file.

In `@src/App.tsx`:
- Around line 198-209: Update openSettings in AppContent to reuse the existing
flushCurrentDraft logic used by toggleSettings instead of referencing the
undeclared persistenceControllerRef. Preserve the current error toast, early
return on flush failure, and settings view transition.

In `@src/components/settings/SettingsPage.test.tsx`:
- Around line 69-101: Update the “omits drag regions when isWindows is true”
test to wrap rendering, assertions, and unmount cleanup in a try block, then
restore navigator.userAgent in a finally block so the original value is restored
even when an assertion fails.

In `@src/context/GitContext.tsx`:
- Around line 343-352: Update the GitProvider effect containing
handleSettingsChanged to also subscribe through the cross-window
listen<SettingsChangedEvent>("settings-changed", ...) bridge, incrementing
settingsRevision only for Git-relevant scope changes; retain the existing DOM
listener for in-WebView events and clean up both subscriptions on unmount.

In `@src/lib/windowClose.ts`:
- Around line 17-25: Update the error-handling flow around flushDraft and
persistRecovery so a recovery rejection is caught and the original saveError is
rethrown with the recovery failure attached as its cause. Replace the ambiguous
undefined recovery result with a distinct outcome for “nothing to recover,”
preserving the existing no-target behavior while allowing clean drafts to close
after a flush failure; update the callers consuming this result, including
App.tsx and PreviewApp.tsx, to honor the distinction.

---

Outside diff comments:
In `@src/components/notes/NoteList.tsx`:
- Around line 326-329: Update the sortedDisplayItems useMemo in NoteList so
sortNotesByModified is applied only when no search query is active; while
searching, return displayItems unchanged to preserve backend relevance ranking,
and retain the existing sortOrder behavior for non-search results.

In `@src/context/ThemeContext.tsx`:
- Around line 468-499: In resetEditorFontSettings, add the missing try block
before the await updateGlobalSettings persistence call so the existing catch
handler correctly matches it. Keep the current state updates, error logging,
toast, and loadSettingsFromBackend recovery behavior unchanged.
- Around line 719-723: Move the useMemo call that creates contextValue above the
isInitialized early-return guard in the theme provider, then keep the guard
immediately before the final return. Ensure all hooks execute on every render
while preserving the existing null result until initialization completes.

---

Nitpick comments:
In `@src-tauri/src/hashing.rs`:
- Around line 203-231: Add published SHA-256 test vectors in the hashing tests
for inputs of 56–63 bytes, including the two-block padding boundary, and for an
input longer than 64 bytes to exercise multi-block processing and
message-schedule carry. Extend the existing sha256_hex tests with fixed expected
digests, while preserving the current empty, abc, and determinism coverage.
- Around line 166-197: Restrict ContentRevision creation to valid SHA-256
identifiers: add a checked constructor that accepts exactly 64 lowercase
hexadecimal characters and rejects all other strings, then make the existing
From<String> and From<&str> conversions crate-private or otherwise prevent
external callers from using them unchecked. Preserve content_revision’s
generated digest behavior and ensure save_if_revision comparisons only receive
validated revisions.

In `@src-tauri/src/persistence.rs`:
- Around line 113-134: Update lock_key to return one normalized key for
equivalent paths by canonicalizing the full destination when it exists, or
otherwise canonicalizing the nearest existing ancestor and normalizing the
remaining suffix so "."/".." and symlinked parents cannot produce distinct keys.
Preserve the existing fallback behavior when canonicalization fails, and ensure
save_if_revision continues using this unified key.

In `@src/components/layout/WorkspaceMenu.tsx`:
- Around line 40-78: Update the focus handling around the existing
open/actionMenuPath effect: when open becomes true, move focus to the first
workspace menu item instead of leaving it on the trigger, and add a focusout
listener that closes the menu when focus moves outside the menu root. Preserve
the existing Escape behavior and action-submenu focus restoration, and clean up
the new listener on effect teardown.

In `@src/components/settings/SettingsPage.test.tsx`:
- Around line 30-42: Ensure every React root created by mountSettingsPage is
unmounted during test cleanup. Update the helper and/or the existing afterEach
hook to track returned roots and call root.unmount() before removing their
containers, covering all tests that mount SettingsPage.

In `@src/lib/useWindowSessionPersistence.ts`:
- Around line 171-179: Update the unmount cleanup effect around writer.flush to
call writer.cancel after the final flush settles, including when flush rejects.
Preserve the existing warning for flush failures and ensure cancellation always
runs so retry timers from createWindowSessionPatchWriter cannot survive unmount.

In `@src/lib/useWindowShortcuts.ts`:
- Around line 35-48: Update the zoom handling in the keydown handler to call
setInterfaceZoom with an updater, relying on its existing clamp and rounding
logic instead of duplicating the calculation and removing interfaceZoom from the
effect dependency array. Compute the resolved zoom value inside the updater and
use it for the toast label, while preserving the current zoom-step behavior and
bounds.

In `@src/lib/windowSession.test.ts`:
- Around line 118-150: Extend the createWindowSessionPatchWriter test suite with
cases covering flush, cancel, and write-failure retry: reject the first write
and verify the merged patch is retried, verify flush writes pending work
immediately while respecting any in-flight write, and verify cancel drops queued
work and prevents subsequent writes. Use the existing fake-timer setup and
writer API, keeping assertions focused on write order and patch contents.

In `@src/lib/workspaceSwitch.test.ts`:
- Around line 5-23: Add coverage for backend-resolved paths in the
runWorkspaceSwitch test: make switchBackendWorkspace return a path different
from the requested path, capture the loadWorkspace mock, and assert both the
returned value and that loadWorkspace receives the resolved path.

In `@src/services/notes.test.ts`:
- Line 147: Move the duplicateNote test covering the duplicate_note command out
of the “scoped settings writes” describe block, and place it in a separate
describe block named for duplicate-note behavior. Keep the test logic unchanged
and ensure the surrounding suite names accurately match the behaviors they
contain.
- Around line 115-117: Strengthen the negative assertions in the affected
service tests, including the cases around invokeMock at the shown locations, to
verify exactly one command call rather than only excluding one command/payload
pair. Assert invokeMock’s total call count is one while preserving the existing
expected command assertion, covering openWorkspaceWindow and the other noted
cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6efd3b8-8dcb-4358-91b0-8106fd7fd728

📥 Commits

Reviewing files that changed from the base of the PR and between 1cb30a5 and 5e0494e.

📒 Files selected for processing (72)
  • src-tauri/capabilities/default.json
  • src-tauri/src/draft_checkpoint.rs
  • src-tauri/src/hashing.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/note_persistence_tests.rs
  • src-tauri/src/persistence.rs
  • src-tauri/src/watcher_debounce.rs
  • src/App.tsx
  • src/components/editor/Editor.tsx
  • src/components/editor/EditorWidthHandle.test.tsx
  • src/components/editor/EditorWidthHandle.tsx
  • src/components/layout/Sidebar.error.test.ts
  • src/components/layout/Sidebar.tsx
  • src/components/layout/SidebarControls.tsx
  • src/components/layout/WorkspaceMenu.test.tsx
  • src/components/layout/WorkspaceMenu.tsx
  • src/components/notes/NoteList.tsx
  • src/components/preview/PreviewApp.tsx
  • src/components/settings/EditorSettingsSection.test.tsx
  • src/components/settings/EditorSettingsSection.tsx
  • src/components/settings/SettingsPage.test.tsx
  • src/components/settings/SettingsPage.tsx
  • src/context/GitContext.tsx
  • src/context/NotesContext.tsx
  • src/context/ThemeContext.tsx
  • src/lib/conflictResolution.test.ts
  • src/lib/conflictResolution.ts
  • src/lib/documentMutationSafety.test.ts
  • src/lib/documentMutationSafety.ts
  • src/lib/draftCheckpoint.test.ts
  • src/lib/draftCheckpoint.ts
  • src/lib/editorToolbar.test.ts
  • src/lib/editorToolbar.ts
  • src/lib/editorWidthResize.test.ts
  • src/lib/editorWidthResize.ts
  • src/lib/noteSync.test.ts
  • src/lib/noteSync.ts
  • src/lib/serializedWriter.test.ts
  • src/lib/serializedWriter.ts
  • src/lib/settingsScope.test.ts
  • src/lib/settingsScope.ts
  • src/lib/standaloneRecreation.test.ts
  • src/lib/standaloneRecreation.ts
  • src/lib/standaloneReload.test.ts
  • src/lib/standaloneReload.ts
  • src/lib/titleBarNoteInfo.test.ts
  • src/lib/titleBarNoteInfo.ts
  • src/lib/useWindowSessionPersistence.ts
  • src/lib/useWindowShortcuts.ts
  • src/lib/windowClose.test.ts
  • src/lib/windowClose.ts
  • src/lib/windowCloseCallsites.test.ts
  • src/lib/windowSession.test.ts
  • src/lib/windowSession.ts
  • src/lib/windowShortcutCallsites.test.ts
  • src/lib/windowShortcuts.test.ts
  • src/lib/windowShortcuts.ts
  • src/lib/workspace.test.ts
  • src/lib/workspace.ts
  • src/lib/workspaceSwitch.test.ts
  • src/lib/workspaceSwitch.ts
  • src/services/draftCheckpoint.test.ts
  • src/services/draftCheckpoint.ts
  • src/services/files.test.ts
  • src/services/files.ts
  • src/services/notes.test.ts
  • src/services/notes.ts
  • src/services/windowLifecycle.test.ts
  • src/services/windowLifecycle.ts
  • src/services/windowSession.test.ts
  • src/services/windowSession.ts
  • src/types/note.ts
💤 Files with no reviewable changes (1)
  • src/components/layout/SidebarControls.tsx
🚧 Files skipped from review as they are similar to previous changes (33)
  • src/lib/standaloneRecreation.ts
  • src/services/draftCheckpoint.test.ts
  • src/lib/editorToolbar.ts
  • src/lib/standaloneReload.test.ts
  • src/components/editor/EditorWidthHandle.test.tsx
  • src/lib/standaloneReload.ts
  • src/lib/windowShortcuts.test.ts
  • src/lib/titleBarNoteInfo.ts
  • src/lib/titleBarNoteInfo.test.ts
  • src/lib/conflictResolution.ts
  • src/lib/windowCloseCallsites.test.ts
  • src/lib/standaloneRecreation.test.ts
  • src/services/files.test.ts
  • src/lib/draftCheckpoint.ts
  • src/lib/serializedWriter.ts
  • src/services/windowLifecycle.test.ts
  • src/lib/windowShortcuts.ts
  • src/lib/serializedWriter.test.ts
  • src/components/settings/SettingsPage.tsx
  • src/components/editor/EditorWidthHandle.tsx
  • src/lib/windowShortcutCallsites.test.ts
  • src/components/settings/EditorSettingsSection.test.tsx
  • src/lib/windowClose.test.ts
  • src/lib/editorToolbar.test.ts
  • src/services/files.ts
  • src/lib/editorWidthResize.ts
  • src/lib/editorWidthResize.test.ts
  • src/lib/draftCheckpoint.test.ts
  • src/services/draftCheckpoint.ts
  • src/lib/conflictResolution.test.ts
  • src/components/settings/EditorSettingsSection.tsx
  • src/components/preview/PreviewApp.tsx
  • src/components/editor/Editor.tsx

Comment thread src-tauri/src/draft_checkpoint.rs
Comment thread src-tauri/src/lib.rs Outdated
Comment thread src-tauri/src/lib.rs Outdated
Comment on lines 3126 to 3138
let mut final_id = sanitized.clone();
let mut counter = 1;
while abs_path_from_id(&folder_path, &final_id)
.map(|p| p.exists())
.unwrap_or(false)
{
if has_counter {
final_id = sanitized.replace("{counter}", &counter.to_string());
final_id = if let Some(ref prefix) = folder_prefix {
format!("{}/{}-{}", prefix, copy_title, counter)
} else {
final_id = format!("{}-{}", base_id, counter);
}
format!("{}-{}", copy_title, counter)
};
counter += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

duplicate_note builds collision names from the unsanitized title.

The first candidate uses sanitized (line 3126), but both collision paths on lines 3132 to 3136 and 3178 to 3182 build the next candidate from copy_title. copy_title is the raw "<title> (Copy)" string and has not passed sanitize_filename, so a note title that contains / or a reserved character produces an unintended nested ID or makes abs_path_from_id fail. create_note correctly retries from the sanitized base_id on line 3036.

Retry from the sanitized identifier.

🐛 Proposed fix
+    let sanitized_leaf = sanitize_filename(&copy_title);
     let mut final_id = sanitized.clone();
     let mut counter = 1;
     while abs_path_from_id(&folder_path, &final_id)
         .map(|p| p.exists())
         .unwrap_or(false)
     {
         final_id = if let Some(ref prefix) = folder_prefix {
-            format!("{}/{}-{}", prefix, copy_title, counter)
+            format!("{}/{}-{}", prefix, sanitized_leaf, counter)
         } else {
-            format!("{}-{}", copy_title, counter)
+            format!("{}-{}", sanitized_leaf, counter)
         };
         counter += 1;
     }

Apply the same substitution in the conflict branch on lines 3178 to 3182.

Also applies to: 3174-3184

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src-tauri/src/lib.rs` around lines 3126 - 3138, Update both collision-retry
branches in duplicate_note to build subsequent candidate IDs from the sanitized
identifier rather than raw copy_title. Preserve the existing folder_prefix
formatting and counter behavior, including the branch around abs_path_from_id
and the later conflict branch, so retries remain valid sanitized IDs.

Comment thread src-tauri/src/persistence.rs Outdated
Comment thread src/App.tsx
Comment on lines +198 to +209
const openSettings = useCallback(async () => {
if (view === "settings") return;
try {
await persistenceControllerRef.current?.flush();
} catch (error) {
toast.error(`Settings not opened: ${error}`);
return;
}
setView("settings");
}, [view]);

useWindowShortcuts({ onOpenPreferences: openSettings });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

openSettings calls an undefined ref.

persistenceControllerRef is not declared in AppContent. The component declares only currentNoteRef, editorRef, and closeInProgressRef. This reference fails to compile under TypeScript, and at runtime it would throw before the view changes. openSettings is the handler passed to useWindowShortcuts on line 209, so Cmd/Ctrl+, is the affected path.

toggleSettings already performs the same flush through flushCurrentDraft. Reuse that flush and drop the duplicate logic.

🐛 Proposed fix
   const openSettings = useCallback(async () => {
     if (view === "settings") return;
     try {
-      await persistenceControllerRef.current?.flush();
+      await flushCurrentDraft();
     } catch (error) {
       toast.error(`Settings not opened: ${error}`);
       return;
     }
     setView("settings");
-  }, [view]);
+  }, [flushCurrentDraft, view]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const openSettings = useCallback(async () => {
if (view === "settings") return;
try {
await persistenceControllerRef.current?.flush();
} catch (error) {
toast.error(`Settings not opened: ${error}`);
return;
}
setView("settings");
}, [view]);
useWindowShortcuts({ onOpenPreferences: openSettings });
const openSettings = useCallback(async () => {
if (view === "settings") return;
try {
await flushCurrentDraft();
} catch (error) {
toast.error(`Settings not opened: ${error}`);
return;
}
setView("settings");
}, [flushCurrentDraft, view]);
useWindowShortcuts({ onOpenPreferences: openSettings });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/App.tsx` around lines 198 - 209, Update openSettings in AppContent to
reuse the existing flushCurrentDraft logic used by toggleSettings instead of
referencing the undeclared persistenceControllerRef. Preserve the current error
toast, early return on flush failure, and settings view transition.

Comment on lines +69 to +101
it("omits drag regions when isWindows is true", async () => {
const originalUA = globalThis.navigator?.userAgent;
Object.defineProperty(globalThis.navigator || {}, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});

vi.resetModules();
const { SettingsPage: WindowsSettingsPage } = await import("./SettingsPage");

const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
act(() =>
root.render(
<TooltipProvider>
<WindowsSettingsPage onBack={undefined} />
</TooltipProvider>,
),
);

expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength(
0,
);
act(() => root.unmount());

if (originalUA !== undefined) {
Object.defineProperty(globalThis.navigator, "userAgent", {
value: originalUA,
configurable: true,
});
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore navigator.userAgent in a finally block.

The restore at Lines 95-100 runs only when every assertion passes. If the assertion at Line 90 fails, the fake Windows user agent stays installed for the rest of the worker, and later tests that read platform flags get the wrong value. Wrap the render and assertions in try and move the restore into finally.

💚 Proposed fix
     vi.resetModules();
     const { SettingsPage: WindowsSettingsPage } = await import("./SettingsPage");
 
-    const container = document.createElement("div");
-    document.body.append(container);
-    const root = createRoot(container);
-    act(() =>
-      root.render(
-        <TooltipProvider>
-          <WindowsSettingsPage onBack={undefined} />
-        </TooltipProvider>,
-      ),
-    );
-
-    expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength(
-      0,
-    );
-    act(() => root.unmount());
-
-    if (originalUA !== undefined) {
-      Object.defineProperty(globalThis.navigator, "userAgent", {
-        value: originalUA,
-        configurable: true,
-      });
-    }
+    const container = document.createElement("div");
+    document.body.append(container);
+    const root = createRoot(container);
+    try {
+      act(() =>
+        root.render(
+          <TooltipProvider>
+            <WindowsSettingsPage onBack={undefined} />
+          </TooltipProvider>,
+        ),
+      );
+      expect(
+        container.querySelectorAll("[data-tauri-drag-region]"),
+      ).toHaveLength(0);
+    } finally {
+      act(() => root.unmount());
+      if (originalUA !== undefined) {
+        Object.defineProperty(globalThis.navigator, "userAgent", {
+          value: originalUA,
+          configurable: true,
+        });
+      }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("omits drag regions when isWindows is true", async () => {
const originalUA = globalThis.navigator?.userAgent;
Object.defineProperty(globalThis.navigator || {}, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
vi.resetModules();
const { SettingsPage: WindowsSettingsPage } = await import("./SettingsPage");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
act(() =>
root.render(
<TooltipProvider>
<WindowsSettingsPage onBack={undefined} />
</TooltipProvider>,
),
);
expect(container.querySelectorAll("[data-tauri-drag-region]")).toHaveLength(
0,
);
act(() => root.unmount());
if (originalUA !== undefined) {
Object.defineProperty(globalThis.navigator, "userAgent", {
value: originalUA,
configurable: true,
});
}
});
it("omits drag regions when isWindows is true", async () => {
const originalUA = globalThis.navigator?.userAgent;
Object.defineProperty(globalThis.navigator || {}, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
vi.resetModules();
const { SettingsPage: WindowsSettingsPage } = await import("./SettingsPage");
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
act(() =>
root.render(
<TooltipProvider>
<WindowsSettingsPage onBack={undefined} />
</TooltipProvider>,
),
);
expect(
container.querySelectorAll("[data-tauri-drag-region]"),
).toHaveLength(0);
} finally {
act(() => root.unmount());
if (originalUA !== undefined) {
Object.defineProperty(globalThis.navigator, "userAgent", {
value: originalUA,
configurable: true,
});
}
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/SettingsPage.test.tsx` around lines 69 - 101, Update
the “omits drag regions when isWindows is true” test to wrap rendering,
assertions, and unmount cleanup in a try block, then restore navigator.userAgent
in a finally block so the original value is restored even when an assertion
fails.

Comment thread src/context/GitContext.tsx Outdated
Comment thread src/lib/windowClose.ts
@Norkep
Norkep force-pushed the agent/standalone-window-settings branch from 32e7ec5 to 29c0459 Compare August 4, 2026 10:17
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

♻️ Duplicate comments (2)
src/context/ThemeContext.tsx (1)

235-238: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make settings patches atomic in the Tauri backend.

Both sites read a complete settings snapshot and write a merged complete object. Concurrent changes from the editor and Preferences windows can start from the same snapshot. The final write can discard the other change.

Add a Tauri command that applies a partial settings patch under the backend settings lock. Use that command at both sites. A frontend queue alone does not protect independent WebViews.

  • src/context/ThemeContext.tsx#L235-L238: replace full-object read-modify-write with an atomic patch command.
  • src/components/layout/Sidebar.tsx#L192-L199: persist sidebarSortOrder through the same atomic patch command.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 235 - 238, The settings updates in
src/context/ThemeContext.tsx lines 235-238 and src/components/layout/Sidebar.tsx
lines 192-199 must use a shared Tauri atomic patch command instead of reading
and rewriting complete settings objects. Add the command to apply
Partial<Settings> while holding the backend settings lock, then update
updateSettingsPatch in ThemeContext and the sidebarSortOrder persistence in
Sidebar to invoke it.
src/components/editor/Editor.tsx (1)

2561-2567: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove role="status" from the conflict trigger button.

This element is a DropdownMenu.Trigger button. role="status" replaces the implicit button role, so assistive technology announces a live region instead of a menu control. Keyboard and screen-reader users then cannot identify the control that opens the conflict menu. The indicator at Line 2623 uses role="status" correctly because that element is not interactive.

♿ Proposed fix
                   <button
-                    role="status"
+                    aria-label="Resolve save conflict"
                     className="h-7 px-2 flex items-center gap-1 text-xs text-text-muted hover:bg-bg-emphasis rounded font-medium"
                   >
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 2561 - 2567, Remove the
role="status" attribute from the conflict menu trigger button containing the
Resolve Conflict label and RefreshCwIcon. Preserve the button’s implicit button
role and all existing styling and behavior; leave the separate non-interactive
status indicator unchanged.
🧹 Nitpick comments (13)
src/components/preview/PreviewApp.tsx (2)

416-429: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Memoize previewData.

This object literal is rebuilt on every render of PreviewApp. Editor receives it as previewMode, so the new identity defeats any memoization inside the editor subtree. Wrap it in useMemo keyed on its members. The coding guidelines require useMemo on performance-critical paths, and the editor is the hot path in this window.

♻️ Proposed change
-  const previewData: PreviewModeData = {
-    content,
-    title,
-    filePath,
-    modified,
-    revision,
-    hasExternalChanges,
-    hasSaveConflict,
-    reloadVersion,
-    save,
-    reload,
-    resolveConflict,
-    registerPersistenceController,
-  };
+  const previewData: PreviewModeData = useMemo(
+    () => ({
+      content,
+      title,
+      filePath,
+      modified,
+      revision,
+      hasExternalChanges,
+      hasSaveConflict,
+      reloadVersion,
+      save,
+      reload,
+      resolveConflict,
+      registerPersistenceController,
+    }),
+    [
+      content,
+      title,
+      filePath,
+      modified,
+      revision,
+      hasExternalChanges,
+      hasSaveConflict,
+      reloadVersion,
+      save,
+      reload,
+      resolveConflict,
+      registerPersistenceController,
+    ],
+  );

As per coding guidelines: "Use useCallback and useMemo for performance-critical paths".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/preview/PreviewApp.tsx` around lines 416 - 429, Memoize the
`previewData` object in `PreviewApp` with `useMemo`, including all listed
members in its dependency array, so its identity remains stable unless one of
those values changes before being passed to `Editor` as `previewMode`.

Source: Coding guidelines


140-165: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant isStale() checks between synchronous setters.

isStale() can only change value after an await or after cleanup runs. Lines 140 through 165 contain no await between the setters, so the repeated checks cannot flip. Keep the checks that follow await readFileDirect and await getDraftCheckpoint, and drop the rest. This reduces the noise in the effect without changing behavior.

♻️ Proposed simplification
         const recovered =
           checkpoint && checkpoint.markdown !== result.content
             ? checkpoint.markdown
             : result.content;
-        if (isStale()) return;
         setContent(recovered);
-        if (isStale()) return;
         setTitle(result.title);
-        if (isStale()) return;
         setModified(result.modified);
         const recoveryRevision = standaloneRecoveryBaseRevision(
           result.revision,
           result.content,
           checkpoint,
         );
         revisionRef.current = recoveryRevision;
-        if (isStale()) return;
         setRevision(recoveryRevision);
         if (checkpoint && checkpoint.markdown === result.content) {
           await draftCheckpointService
             .clearDraftCheckpoint(checkpoint.key)
             .catch(() => undefined);
         } else if (checkpoint) {
-          if (isStale()) return;
           setHasExternalChanges(true);
-          if (isStale()) return;
           setHasSaveConflict(true);
-          if (isStale()) return;
           toast.warning("Recovered an unsaved draft from an interrupted session");
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/preview/PreviewApp.tsx` around lines 140 - 165, Remove the
redundant isStale() checks between the synchronous setters in the recovery flow,
including those around setContent, setTitle, setModified, setRevision,
setHasExternalChanges, setHasSaveConflict, and the warning toast. Preserve the
checks that follow await readFileDirect and await getDraftCheckpoint, and keep
the existing recovery behavior unchanged.
src/lib/standaloneReload.test.ts (2)

70-100: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the no-checkpoint case.

standaloneRecoveryBaseRevision accepts checkpoint: DraftCheckpoint | null. The null case is the normal path: a standalone window opens a file with no recovered draft. It must return the disk revision. Add one assertion for it.

💚 Proposed test
   it("uses the disk revision when no divergent recovery is applied", () => {
     expect(
       standaloneRecoveryBaseRevision(
         "current-disk-revision",
         "same content",
         checkpoint("same content", "older-revision"),
       ),
     ).toBe("current-disk-revision");
   });
+
+  it("uses the disk revision when no checkpoint exists", () => {
+    expect(
+      standaloneRecoveryBaseRevision("current-disk-revision", "disk", null),
+    ).toBe("current-disk-revision");
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/standaloneReload.test.ts` around lines 70 - 100, Extend the
standaloneRecoveryBaseRevision test suite with a no-checkpoint case that passes
null for checkpoint and verifies the current disk revision is returned. Keep the
existing divergent-recovery and matching-content assertions unchanged.

35-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the null controller.

flushDirtyDraftBeforeReload accepts null. That path is reachable: PreviewApp passes persistenceControllerRef.current, which is null until the Editor registers its controller. A reload that arrives in that window must resolve without a flush and without a throw. No test covers it.

💚 Proposed test
   it("does not manufacture a write for a clean standalone note", async () => {
     const flush = vi.fn(async () => undefined);
 
     await flushDirtyDraftBeforeReload({
       flush,
       getDraft: () => ({ noteId: "/note.md", content: "disk", dirty: false }),
     });
 
     expect(flush).not.toHaveBeenCalled();
   });
+
+  it("resolves when no controller is registered yet", async () => {
+    await expect(flushDirtyDraftBeforeReload(null)).resolves.toBeUndefined();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/standaloneReload.test.ts` around lines 35 - 68, Add a test in the
existing flushDirtyDraftBeforeReload suite covering a null controller, verifying
the function resolves without throwing and does not invoke the flush callback
when the controller is unavailable. Use the same invocation shape as the
existing tests and preserve the current dirty/clean behavior coverage.
src/lib/recoveryNotice.test.ts (1)

33-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the record path with malformed stored data.

This test proves consumePendingRecoveryNotices tolerates malformed data. recordPendingRecoveryNotice shares the same readNotices helper but has no such guard, and that gap blocks window close. Add the symmetric test so the fix stays in place.

💚 Proposed test
   it("drops malformed stored notices without throwing", () => {
     const storage = createStorage();
     storage.setItem("scratch:pendingRecoveryNotices", "not-json");
 
     expect(consumePendingRecoveryNotices(storage)).toEqual([]);
   });
+
+  it("records a new notice over malformed stored data", () => {
+    const storage = createStorage();
+    storage.setItem("scratch:pendingRecoveryNotices", "not-json");
+
+    expect(() =>
+      recordPendingRecoveryNotice("/recovery/Plan.md", "disk full", storage),
+    ).not.toThrow();
+    expect(consumePendingRecoveryNotices(storage)).toHaveLength(1);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/recoveryNotice.test.ts` around lines 33 - 38, Add a symmetric test in
recoveryNotice.test.ts for recordPendingRecoveryNotice using malformed stored
data such as “not-json”; verify the call does not throw and preserves the
expected storage behavior, covering the readNotices path used during recording.
src/lib/draftRepresentation.ts (1)

23-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the discard-before-flush ordering.

The order at Lines 34-40 is load-bearing. discardFormatted and discardSource advance the save generation counters in Editor.tsx, so they must run before the flush to stop a stale in-flight save from clearing newer edits. Add a short comment that states this requirement, so a later reader does not reorder the calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/draftRepresentation.ts` around lines 23 - 42, Add a short explanatory
comment in flushPendingDraftRepresentation immediately before the discard/flush
branches, documenting that discardFormatted or discardSource must execute before
the corresponding flush because they advance save-generation counters and
prevent stale in-flight saves from clearing newer edits. Keep the existing
ordering and behavior unchanged.
src/lib/recoveryNotice.ts (1)

37-50: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider making notice recording non-fatal.

recordPendingRecoveryNotice throws when storage is unavailable (Line 42), and storage.setItem at Line 49 can throw when the quota is exceeded. Both throws propagate into the beforeClose step of runSafeWindowClose and keep the window open. A failed notice is bookkeeping; it should not block a close whose recovery snapshot already succeeded.

Wrap the recordPendingRecoveryNotice calls in App.tsx and PreviewApp.tsx in try/catch, or make this function report failure through a return value instead of a throw.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/recoveryNotice.ts` around lines 37 - 50, Make pending recovery notice
recording non-fatal by updating the callers of recordPendingRecoveryNotice in
App.tsx and PreviewApp.tsx to catch and suppress recording failures, including
unavailable storage and setItem errors, so runSafeWindowClose can complete after
a successful recovery snapshot.
src/lib/draftRepresentation.test.ts (1)

7-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the clean-draft case and move the flush tests to their own describe.

Two gaps:

  1. No test covers the null representation. That branch prevents a write when neither representation is dirty. It protects a clean note from a spurious save.
  2. The tests at Lines 27-57 exercise flushPendingDraftRepresentation, but they sit inside describe("choosePendingDraftRepresentation"). The report then attributes the failures to the wrong function.
💚 Proposed change
   it("falls back to the only dirty representation", () => {
     expect(choosePendingDraftRepresentation(false, true, false)).toBe(
       "source",
     );
     expect(choosePendingDraftRepresentation(true, false, true)).toBe(
       "formatted",
     );
   });
+
+  it("returns null when no representation is dirty", () => {
+    expect(choosePendingDraftRepresentation(false, false, false)).toBeNull();
+    expect(choosePendingDraftRepresentation(true, false, false)).toBeNull();
+  });
+});
+
+describe("flushPendingDraftRepresentation", () => {
+  it("performs no write when no representation is dirty", async () => {
+    const actions = {
+      discardSource: vi.fn(),
+      discardFormatted: vi.fn(),
+      flushSource: vi.fn(async () => undefined),
+      flushFormatted: vi.fn(async () => undefined),
+    };
+
+    await expect(
+      flushPendingDraftRepresentation(false, false, false, actions),
+    ).resolves.toBeNull();
+    expect(actions.flushSource).not.toHaveBeenCalled();
+    expect(actions.flushFormatted).not.toHaveBeenCalled();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/draftRepresentation.test.ts` around lines 7 - 58, Add a test for
choosePendingDraftRepresentation that passes clean source and formatted states
and asserts the null representation, covering the no-write case. Move the two
flushPendingDraftRepresentation tests into a separate describe block named for
that function, leaving the existing choosePendingDraftRepresentation tests
together.
src/components/editor/Editor.tsx (3)

982-1002: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consolidate the two unmount cleanups.

Two cleanup paths now clear the same timers. The layout-effect cleanup at Lines 994-998 clears checkpointCaptureTimerRef and nulls it. The effect cleanup at Lines 1868-1871 clears it again, and Lines 1862-1867 clear saveTimeoutRef and sourceTimeoutRef that flushAllPendingSaves already cleared. The duplication is harmless today, but it splits one lifecycle concern across two effects and makes the cleanup order load-bearing.

Move the timer and popup teardown into a single cleanup, or document why the split is required.

Also applies to: 1858-1870

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 982 - 1002, Consolidate
unmount cleanup so timer and popup teardown occurs in one lifecycle cleanup
instead of both the layout-effect cleanup and the effect cleanup. Update the
cleanup logic around flushAllPendingSavesRef, checkpointCaptureTimerRef,
checkpointSchedulerRef, saveTimeoutRef, and sourceTimeoutRef to remove duplicate
clearing while preserving pending-save flushing and crash-checkpoint
persistence.

2395-2431: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse flushSourceSave in the debounced source-save timer.

The timer body at Lines 2406-2428 repeats the save, generation check, dirty reset, and checkpoint clear that flushSourceSave already implements at Lines 893-911. The two copies already differ: this body saves the captured value and sets lastSaveRef directly, while flushSourceSave saves sourceContentRef.current through saveImmediately. The copies can drift further.

Call flushSourceSave from the timer and keep the error handling here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 2395 - 2431, Update the
debounced timer inside handleSourceChange to call the existing flushSourceSave
helper instead of duplicating saveNote, generation checks, dirty-state updates,
and checkpoint handling. Preserve the timer’s current error handling and ensure
flushSourceSave uses the latest source content through its established
saveImmediately path.

964-965: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the render-phase ref writes into an effect.

Lines 965, 980, and 1004 assign ref values during render. React can discard a render, so the refs can hold values from work that never commits. The unmount cleanup at Lines 987 and 991 reads these refs, so a discarded render can flush the wrong callback closure.

Assign these three refs inside a useEffect that runs after every commit.

♻️ Proposed change
-  const flushAllPendingSavesRef = useRef(flushAllPendingSaves);
-  flushAllPendingSavesRef.current = flushAllPendingSaves;
+  const flushAllPendingSavesRef = useRef(flushAllPendingSaves);
-  const persistCurrentCrashCheckpointRef = useRef(persistCurrentCrashCheckpoint);
-  persistCurrentCrashCheckpointRef.current = persistCurrentCrashCheckpoint;
+  const persistCurrentCrashCheckpointRef = useRef(persistCurrentCrashCheckpoint);
+
+  useEffect(() => {
+    flushAllPendingSavesRef.current = flushAllPendingSaves;
+    persistCurrentCrashCheckpointRef.current = persistCurrentCrashCheckpoint;
+    queueCheckpointCaptureRef.current = queueCheckpointCapture;
+  }, [flushAllPendingSaves, persistCurrentCrashCheckpoint, queueCheckpointCapture]);

Wrap the queueCheckpointCaptureRef.current = () => { ... } body in a useCallback named queueCheckpointCapture and assign it in the same effect.

Also applies to: 979-980, 1004-1020

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 964 - 965, Move the
render-phase assignments to flushAllPendingSavesRef, the checkpoint-related ref
at the referenced lines, and queueCheckpointCaptureRef into a useEffect that
runs after every commit. Create the queueCheckpointCapture callback with
useCallback as requested, then update all three refs within that effect so
unmount cleanup reads only committed callback closures.

Source: Linters/SAST tools

src/lib/draftCheckpoint.ts (1)

177-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Route draft checkpoint load fallback through reconcileDraftCheckpoint.

src/components/preview/PreviewApp.tsx re-implements the checkpoint recovery decision inline. That logic matches src/lib/draftCheckpoint.ts, so the load path should call reconcileDraftCheckpoint to keep recovery behavior consistent and avoid losing unsaved checkpoint text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/draftCheckpoint.ts` around lines 177 - 201, The PreviewApp
checkpoint-load path should stop duplicating recovery decisions and call
reconcileDraftCheckpoint from draftCheckpoint.ts instead. Use its returned note,
remote, recovered, and shouldClear values to preserve checkpoint text and
consistent cleanup behavior, removing the equivalent inline logic.
src/lib/standaloneReload.ts (1)

26-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the empty-string revision sentinel.

standaloneRecoveryBaseRevision() returns "" when a divergent checkpoint has no metadata.baseRevision. Document this intentionally as the “unknown base revision” branch: it prevents saveFileDirect() from using the disk revision and instead makes the save use the empty-content hash, so edits are reported as a conflict until a clean known base is available.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/standaloneReload.ts` around lines 26 - 35, Document the empty-string
sentinel in standaloneRecoveryBaseRevision(): when checkpoint.markdown differs
from diskContent and checkpoint.metadata.baseRevision is missing, keep returning
"" intentionally as the unknown base revision branch. Clarify in the
standaloneRecoveryBaseRevision flow that this value is meant to avoid
saveFileDirect() falling back to diskRevision so the save uses the empty-content
hash and surfaces the conflict until a known base is available.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/editor/Editor.tsx`:
- Around line 994-999: Update the cleanup block in Editor.tsx to handle the
promise returned by checkpointSchedulerRef.current?.dispose(), attaching a catch
that reports failures through the same console.error path used by the
scheduler’s onError handler. Preserve the existing timer cleanup and optional
scheduler disposal behavior.

In `@src/components/settings/SettingsPage.test.tsx`:
- Around line 47-62: Ensure every test root is unmounted even when assertions
fail by moving each root.unmount call into a finally block: update both cases in
src/components/settings/SettingsPage.test.tsx (lines 47-62 and 70-84) and the
case in src/components/settings/SettingsPage.windows.test.tsx (lines 46-58).
Preserve the existing test actions and assertions.

In `@src/context/GitContext.tsx`:
- Around line 77-101: Apply the dual-context pattern used by NotesContext: in
src/context/GitContext.tsx lines 77-101, split Git state data from Git action
callbacks into separate contexts and providers so state-only consumers do not
rerender on action changes; update corresponding consumers while preserving
existing behavior. In src/context/ThemeContext.tsx lines 741-749, likewise
expose theme preference state and setters through separate data and actions
contexts, updating consumers to use the appropriate context.

In `@src/context/NotesContext.test.tsx`:
- Around line 41-74: Update the test probe declarations for actions and error to
use explicit nullable unions with typed null assertions, preserving
ReturnType<typeof useNotesActions> | null and string | null rather than inferred
null-only types. Keep the assignments inside Probe and the existing saveNote
assertion flow unchanged.

In `@src/context/ThemeContext.tsx`:
- Around line 547-584: The preference setters in setEditorWidthResizeEnabled,
setEditorToolbarVisible, and updateTitleBarNoteInfo must roll back their local
state or reload persisted settings when updateSettingsPatch fails, instead of
leaving unsaved values displayed. Add a user-facing error toast in each failure
path using the existing notification mechanism and a clear friendly message,
while retaining the existing console error logging where appropriate.

In `@src/lib/draftCheckpoint.ts`:
- Around line 107-129: Reschedule the checkpoint timer when storage.write fails
in flush, after restoring the checkpoint to pending, so transient failures are
retried without requiring another caller action. Reuse the existing schedule
function and preserve the rejected promise propagation and onError handling.

In `@src/lib/recoveryNotice.ts`:
- Around line 22-35: Update readNotices to catch JSON.parse failures and return
an empty notice list for malformed stored data, keeping the existing array and
notice validation behavior unchanged. Centralize this tolerance in readNotices
so both recordPendingRecoveryNotice and consumePendingRecoveryNotices handle
invalid storage consistently.

In `@src/lib/useWindowShortcuts.ts`:
- Around line 14-15: Update the zoom flow in useWindowShortcuts so the clamped,
rounded value is the one actually committed by setInterfaceZoom instead of
current + delta, and keep the toast in sync with the committed interfaceZoom.
Remove the render-phase interfaceZoomRef write if it is only there to read the
latest value for the shortcut handler, and compute the next zoom inside the
state updater or from committed state so the bounds and 0.05 քայլ rounding are
preserved.

---

Duplicate comments:
In `@src/components/editor/Editor.tsx`:
- Around line 2561-2567: Remove the role="status" attribute from the conflict
menu trigger button containing the Resolve Conflict label and RefreshCwIcon.
Preserve the button’s implicit button role and all existing styling and
behavior; leave the separate non-interactive status indicator unchanged.

In `@src/context/ThemeContext.tsx`:
- Around line 235-238: The settings updates in src/context/ThemeContext.tsx
lines 235-238 and src/components/layout/Sidebar.tsx lines 192-199 must use a
shared Tauri atomic patch command instead of reading and rewriting complete
settings objects. Add the command to apply Partial<Settings> while holding the
backend settings lock, then update updateSettingsPatch in ThemeContext and the
sidebarSortOrder persistence in Sidebar to invoke it.

---

Nitpick comments:
In `@src/components/editor/Editor.tsx`:
- Around line 982-1002: Consolidate unmount cleanup so timer and popup teardown
occurs in one lifecycle cleanup instead of both the layout-effect cleanup and
the effect cleanup. Update the cleanup logic around flushAllPendingSavesRef,
checkpointCaptureTimerRef, checkpointSchedulerRef, saveTimeoutRef, and
sourceTimeoutRef to remove duplicate clearing while preserving pending-save
flushing and crash-checkpoint persistence.
- Around line 2395-2431: Update the debounced timer inside handleSourceChange to
call the existing flushSourceSave helper instead of duplicating saveNote,
generation checks, dirty-state updates, and checkpoint handling. Preserve the
timer’s current error handling and ensure flushSourceSave uses the latest source
content through its established saveImmediately path.
- Around line 964-965: Move the render-phase assignments to
flushAllPendingSavesRef, the checkpoint-related ref at the referenced lines, and
queueCheckpointCaptureRef into a useEffect that runs after every commit. Create
the queueCheckpointCapture callback with useCallback as requested, then update
all three refs within that effect so unmount cleanup reads only committed
callback closures.

In `@src/components/preview/PreviewApp.tsx`:
- Around line 416-429: Memoize the `previewData` object in `PreviewApp` with
`useMemo`, including all listed members in its dependency array, so its identity
remains stable unless one of those values changes before being passed to
`Editor` as `previewMode`.
- Around line 140-165: Remove the redundant isStale() checks between the
synchronous setters in the recovery flow, including those around setContent,
setTitle, setModified, setRevision, setHasExternalChanges, setHasSaveConflict,
and the warning toast. Preserve the checks that follow await readFileDirect and
await getDraftCheckpoint, and keep the existing recovery behavior unchanged.

In `@src/lib/draftCheckpoint.ts`:
- Around line 177-201: The PreviewApp checkpoint-load path should stop
duplicating recovery decisions and call reconcileDraftCheckpoint from
draftCheckpoint.ts instead. Use its returned note, remote, recovered, and
shouldClear values to preserve checkpoint text and consistent cleanup behavior,
removing the equivalent inline logic.

In `@src/lib/draftRepresentation.test.ts`:
- Around line 7-58: Add a test for choosePendingDraftRepresentation that passes
clean source and formatted states and asserts the null representation, covering
the no-write case. Move the two flushPendingDraftRepresentation tests into a
separate describe block named for that function, leaving the existing
choosePendingDraftRepresentation tests together.

In `@src/lib/draftRepresentation.ts`:
- Around line 23-42: Add a short explanatory comment in
flushPendingDraftRepresentation immediately before the discard/flush branches,
documenting that discardFormatted or discardSource must execute before the
corresponding flush because they advance save-generation counters and prevent
stale in-flight saves from clearing newer edits. Keep the existing ordering and
behavior unchanged.

In `@src/lib/recoveryNotice.test.ts`:
- Around line 33-38: Add a symmetric test in recoveryNotice.test.ts for
recordPendingRecoveryNotice using malformed stored data such as “not-json”;
verify the call does not throw and preserves the expected storage behavior,
covering the readNotices path used during recording.

In `@src/lib/recoveryNotice.ts`:
- Around line 37-50: Make pending recovery notice recording non-fatal by
updating the callers of recordPendingRecoveryNotice in App.tsx and
PreviewApp.tsx to catch and suppress recording failures, including unavailable
storage and setItem errors, so runSafeWindowClose can complete after a
successful recovery snapshot.

In `@src/lib/standaloneReload.test.ts`:
- Around line 70-100: Extend the standaloneRecoveryBaseRevision test suite with
a no-checkpoint case that passes null for checkpoint and verifies the current
disk revision is returned. Keep the existing divergent-recovery and
matching-content assertions unchanged.
- Around line 35-68: Add a test in the existing flushDirtyDraftBeforeReload
suite covering a null controller, verifying the function resolves without
throwing and does not invoke the flush callback when the controller is
unavailable. Use the same invocation shape as the existing tests and preserve
the current dirty/clean behavior coverage.

In `@src/lib/standaloneReload.ts`:
- Around line 26-35: Document the empty-string sentinel in
standaloneRecoveryBaseRevision(): when checkpoint.markdown differs from
diskContent and checkpoint.metadata.baseRevision is missing, keep returning ""
intentionally as the unknown base revision branch. Clarify in the
standaloneRecoveryBaseRevision flow that this value is meant to avoid
saveFileDirect() falling back to diskRevision so the save uses the empty-content
hash and surfaces the conflict until a known base is available.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e557ab9-d67f-4074-bb9b-2de808c4eff4

📥 Commits

Reviewing files that changed from the base of the PR and between 9126a5a and 56aa5ab.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (71)
  • package.json
  • src-tauri/capabilities/default.json
  • src-tauri/src/draft_checkpoint.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/persistence.rs
  • src-tauri/src/sha256.rs
  • src/App.tsx
  • src/components/editor/Editor.tsx
  • src/components/editor/EditorWidthHandle.test.tsx
  • src/components/editor/EditorWidthHandle.tsx
  • src/components/layout/Sidebar.tsx
  • src/components/layout/SidebarControls.test.tsx
  • src/components/layout/SidebarControls.tsx
  • src/components/layout/SidebarFolderSection.test.tsx
  • src/components/layout/SidebarFolderSection.tsx
  • src/components/notes/FolderTreeView.test.tsx
  • src/components/notes/FolderTreeView.tsx
  • src/components/notes/NoteList.tsx
  • src/components/preview/PreviewApp.tsx
  • src/components/settings/EditorSettingsSection.test.tsx
  • src/components/settings/EditorSettingsSection.tsx
  • src/components/settings/SettingsPage.test.tsx
  • src/components/settings/SettingsPage.tsx
  • src/components/settings/SettingsPage.windows.test.tsx
  • src/context/GitContext.tsx
  • src/context/NotesContext.test.tsx
  • src/context/NotesContext.tsx
  • src/context/ThemeContext.tsx
  • src/lib/conflictResolution.test.ts
  • src/lib/conflictResolution.ts
  • src/lib/draftCheckpoint.test.ts
  • src/lib/draftCheckpoint.ts
  • src/lib/draftRepresentation.test.ts
  • src/lib/draftRepresentation.ts
  • src/lib/editorToolbar.test.ts
  • src/lib/editorToolbar.ts
  • src/lib/editorWidthResize.test.ts
  • src/lib/editorWidthResize.ts
  • src/lib/folderTree.test.ts
  • src/lib/folderTree.ts
  • src/lib/recoveryNotice.test.ts
  • src/lib/recoveryNotice.ts
  • src/lib/serializedWriter.test.ts
  • src/lib/serializedWriter.ts
  • src/lib/settingsEvents.test.ts
  • src/lib/settingsEvents.ts
  • src/lib/standaloneRecreation.test.ts
  • src/lib/standaloneRecreation.ts
  • src/lib/standaloneReload.test.ts
  • src/lib/standaloneReload.ts
  • src/lib/titleBarNoteInfo.test.ts
  • src/lib/titleBarNoteInfo.ts
  • src/lib/useWindowShortcuts.test.tsx
  • src/lib/useWindowShortcuts.ts
  • src/lib/windowClose.test.ts
  • src/lib/windowClose.ts
  • src/lib/windowCloseCallsites.test.ts
  • src/lib/windowMode.test.ts
  • src/lib/windowMode.ts
  • src/lib/windowShortcutCallsites.test.ts
  • src/lib/windowShortcuts.test.ts
  • src/lib/windowShortcuts.ts
  • src/services/draftCheckpoint.test.ts
  • src/services/draftCheckpoint.ts
  • src/services/files.test.ts
  • src/services/files.ts
  • src/services/notes.ts
  • src/services/windowLifecycle.test.ts
  • src/services/windowLifecycle.ts
  • src/types/note.ts
  • vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (41)
  • src/lib/editorToolbar.test.ts
  • src/lib/windowCloseCallsites.test.ts
  • package.json
  • src/services/draftCheckpoint.test.ts
  • src/lib/windowShortcuts.ts
  • src/services/files.test.ts
  • src/lib/windowShortcutCallsites.test.ts
  • src/lib/standaloneRecreation.test.ts
  • src/lib/folderTree.test.ts
  • src/services/files.ts
  • vitest.config.ts
  • src/services/windowLifecycle.test.ts
  • src/lib/titleBarNoteInfo.ts
  • src/lib/editorWidthResize.ts
  • src/lib/titleBarNoteInfo.test.ts
  • src/lib/standaloneRecreation.ts
  • src/lib/editorWidthResize.test.ts
  • src/lib/conflictResolution.test.ts
  • src/components/notes/NoteList.tsx
  • src/lib/editorToolbar.ts
  • src/components/layout/SidebarControls.tsx
  • src/lib/windowShortcuts.test.ts
  • src/components/layout/SidebarFolderSection.test.tsx
  • src/components/settings/EditorSettingsSection.test.tsx
  • src/lib/conflictResolution.ts
  • src/lib/serializedWriter.ts
  • src/components/layout/SidebarControls.test.tsx
  • src/components/editor/EditorWidthHandle.test.tsx
  • src/lib/serializedWriter.test.ts
  • src/components/layout/SidebarFolderSection.tsx
  • src/components/notes/FolderTreeView.tsx
  • src/services/windowLifecycle.ts
  • src/lib/folderTree.ts
  • src/components/settings/SettingsPage.tsx
  • src/components/notes/FolderTreeView.test.tsx
  • src/services/draftCheckpoint.ts
  • src/lib/draftCheckpoint.test.ts
  • src/components/editor/EditorWidthHandle.tsx
  • src/App.tsx
  • src/components/settings/EditorSettingsSection.tsx
  • src-tauri/src/lib.rs

Comment on lines +994 to +999
if (checkpointCaptureTimerRef.current) {
clearTimeout(checkpointCaptureTimerRef.current);
checkpointCaptureTimerRef.current = null;
checkpointCaptureStartedAtRef.current = null;
}
}, 500);
}, [saveImmediately, getMarkdown, currentNote?.id]);
checkpointSchedulerRef.current?.dispose();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle the promise returned by dispose().

Line 999 calls checkpointSchedulerRef.current?.dispose() and discards the result. dispose returns flush() (src/lib/draftCheckpoint.ts Lines 154-157), and flush rejects when the final storage.write fails. The rejection is unhandled, so unmount produces an unhandled promise rejection instead of a reported error. The scheduler's onError handler at Lines 661-664 does not run, because it applies only to timer-driven flushes.

  • src/components/editor/Editor.tsx#L994-L999: attach a catch to the dispose() call and route the error to the same console.error used by the scheduler's onError.
🐛 Proposed fix
       if (checkpointCaptureTimerRef.current) {
         clearTimeout(checkpointCaptureTimerRef.current);
         checkpointCaptureTimerRef.current = null;
         checkpointCaptureStartedAtRef.current = null;
       }
-      checkpointSchedulerRef.current?.dispose();
+      void checkpointSchedulerRef.current?.dispose().catch((error) => {
+        console.error("Failed to persist final crash checkpoint:", error);
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (checkpointCaptureTimerRef.current) {
clearTimeout(checkpointCaptureTimerRef.current);
checkpointCaptureTimerRef.current = null;
checkpointCaptureStartedAtRef.current = null;
}
}, 500);
}, [saveImmediately, getMarkdown, currentNote?.id]);
checkpointSchedulerRef.current?.dispose();
if (checkpointCaptureTimerRef.current) {
clearTimeout(checkpointCaptureTimerRef.current);
checkpointCaptureTimerRef.current = null;
checkpointCaptureStartedAtRef.current = null;
}
void checkpointSchedulerRef.current?.dispose().catch((error) => {
console.error("Failed to persist final crash checkpoint:", error);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/editor/Editor.tsx` around lines 994 - 999, Update the cleanup
block in Editor.tsx to handle the promise returned by
checkpointSchedulerRef.current?.dispose(), attaching a catch that reports
failures through the same console.error path used by the scheduler’s onError
handler. Preserve the existing timer cleanup and optional scheduler disposal
behavior.

Comment on lines +47 to +62
act(() =>
root.render(
<TooltipProvider>
<SettingsPage onBack={onBack} />
</TooltipProvider>,
),
);

const backButton = container.querySelector<HTMLButtonElement>(
'button[aria-label^="Back"]',
);
expect(backButton).not.toBeNull();
act(() => backButton?.click());
expect(onBack).toHaveBeenCalledOnce();

act(() => root.unmount());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Always unmount test roots during cleanup.

A failed assertion skips root.unmount(). Removing the container does not remove SettingsPage window listeners. Use try/finally, or register each root for afterEach cleanup.

  • src/components/settings/SettingsPage.test.tsx#L47-L62: unmount the root in finally.
  • src/components/settings/SettingsPage.test.tsx#L70-L84: unmount the root in finally.
  • src/components/settings/SettingsPage.windows.test.tsx#L46-L58: unmount the root in finally.
📍 Affects 2 files
  • src/components/settings/SettingsPage.test.tsx#L47-L62 (this comment)
  • src/components/settings/SettingsPage.test.tsx#L70-L84
  • src/components/settings/SettingsPage.windows.test.tsx#L46-L58
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/settings/SettingsPage.test.tsx` around lines 47 - 62, Ensure
every test root is unmounted even when assertions fail by moving each
root.unmount call into a finally block: update both cases in
src/components/settings/SettingsPage.test.tsx (lines 47-62 and 70-84) and the
case in src/components/settings/SettingsPage.windows.test.tsx (lines 46-58).
Preserve the existing test actions and assertions.

Comment on lines +77 to +101
useEffect(() => {
let disposed = false;
let unlisten: (() => void) | undefined;

void listen<GitSettingsChangedEvent>("settings-changed", (event) => {
if (
disposed ||
!isGitSettingsEventForFolder(event.payload, notesFolderRef.current)
) {
return;
}
settingsReadRequestIdRef.current += 1;
if (typeof event.payload.gitEnabled === "boolean") {
setGitEnabledState(event.payload.gitEnabled);
}
}).then((removeListener) => {
if (disposed) removeListener();
else unlisten = removeListener;
});

return () => {
disposed = true;
unlisten?.();
};
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Split context data from context actions.

These providers expose changing state and action callbacks through one context value. A state update rerenders consumers that only need actions. Separate data and actions contexts, following the NotesContext pattern.

  • src/context/GitContext.tsx#L77-L101: expose Git state and Git actions through separate contexts.
  • src/context/ThemeContext.tsx#L741-L749: expose theme preference state and setters through separate contexts.

As per coding guidelines, “Use NotesContext with dual context pattern (data/actions separated) for performance optimization.”

📍 Affects 2 files
  • src/context/GitContext.tsx#L77-L101 (this comment)
  • src/context/ThemeContext.tsx#L741-L749
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/GitContext.tsx` around lines 77 - 101, Apply the dual-context
pattern used by NotesContext: in src/context/GitContext.tsx lines 77-101, split
Git state data from Git action callbacks into separate contexts and providers so
state-only consumers do not rerender on action changes; update corresponding
consumers while preserving existing behavior. In src/context/ThemeContext.tsx
lines 741-749, likewise expose theme preference state and setters through
separate data and actions contexts, updating consumers to use the appropriate
context.

Source: Coding guidelines

Comment on lines +41 to +74
let actions: ReturnType<typeof useNotesActions> | null = null;
let error: string | null = null;

function Probe() {
actions = useNotesActions();
error = useNotesData().error;
return null;
}

const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);

await act(async () => {
root.render(
<NotesProvider>
<Probe />
</NotesProvider>,
);
});

expect(actions).not.toBeNull();

await act(async () => {
await expect(
actions!.saveNote("# Draft\n\nLatest content", "draft.md"),
).rejects.toBe(failure);
});

expect(saveNoteMock).toHaveBeenCalledWith(
"draft.md",
"# Draft\n\nLatest content",
);
expect(error).toBe("disk full");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Run the repository typecheck to confirm whether the test file compiles.
set -u

echo "--- package.json scripts ---"
fd -t f -d 2 '^package.json$' --exec sh -c 'echo "== {}"; jq ".scripts, .devDependencies.typescript" {}'

echo "--- tsconfig include/exclude ---"
fd -t f 'tsconfig.*json' --exec sh -c 'echo "== {}"; cat {}'

echo "--- typecheck ---"
npx tsc --noEmit 2>&1 | head -40

Repository: erictli/scratch

Length of output: 6066


🏁 Script executed:

#!/bin/bash
set -u

echo "--- files ---"
git ls-files 'src/**/*.{ts,tsx}' | sed -n '1,120p'

echo "--- NotesContext.test.tsx lines 1-90 ---"
cat -n src/context/NotesContext.test.tsx | sed -n '1,90p'

echo "--- NotesContext.tsx relevant declarations ---"
rg -n "export .*NotesProvider|useNotesActions|saveNote|error" src/context/NotesContext.tsx || true
sed -n '1,220p' src/context/NotesContext.tsx

echo "--- TypeScript version/tool availability ---"
node - <<'JS'
try {
  const tsv = require('./node_modules/typescript/package.json').version;
  console.log('node_modules/typescript:', tsv);
} catch {
  console.log('node_modules/typescript: not present');
}
try {
  const ts = require('./node_modules/typescript');
  const source = `let actions: string | null = null; (() => { actions = "x"; })(); actions!.length;`;
  const result = ts.transpileModule(source, { compilerOptions: { strict: true, noImplicitAny: true, preserveConstEnums: false } });
  console.log('typescript available for probe');
  console.log(result.diagnostics.map(d => `${d.file?.fileName}:${d.start}:${d.code || 'ts'} ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`).join('\n'));
} catch (e) {
  console.log('typescript probe unavailable:', e && e.message);
}
JS

Repository: erictli/scratch

Length of output: 12331


🌐 Web query:

TypeScript control flow narrowing let variable assigned inside function initializer null unresolved assignment outside function

💡 Result:

TypeScript's control flow analysis (CFA) is intentionally limited regarding side effects occurring inside closures (functions) [1][2][3]. The compiler does not perform inter-procedural analysis to track how captured variables might be mutated by functions, as doing so would be computationally prohibitive and impact compile-time performance [1][3]. When you assign a value to a let variable inside a function initializer or a callback, the TypeScript compiler generally cannot guarantee that the assignment happens synchronously or at all before the variable is used elsewhere [4][5][2]. Consequently, TypeScript follows these key behaviors: 1. Narrowing Persistence Limitations: In most cases, narrowing applied to a let variable is lost when the variable is captured by a closure [5][2]. The compiler assumes that the closure might be invoked at any time or multiple times, potentially reassigning the variable and invalidating previous narrowing assumptions [5][3]. 2. Exception for Last Assignments (TypeScript 5.4+): As of TypeScript 5.4, the compiler preserves type refinements for let variables in closures, but only if the closure is created after the last assignment to that variable in the outer scope [4][2]. If the variable is reassigned after the closure is created, narrowing is reset because the compiler must account for the possibility of the variable's value changing [4][2]. 3. Unresolved Assignments: Because the compiler does not track mutations inside functions, it may fail to recognize that a variable has been assigned a value, leading to situations where the compiler continues to treat the variable as having its initial (often broader) type, or conversely, failing to narrow it correctly after a function call [6][3]. Recommended Workarounds: - Use type assertions or definite assignment assertions (!) if you are certain a variable will be assigned before it is used [7]. - Explicitly copy the value into a const-bound variable within the scope where you need the narrowed type [1][5]. - If you need a more specific type after a function call, manually assert the type (e.g., as T) rather than relying on the compiler to track cross-function mutations [6]. For authoritative details, refer to the TypeScript GitHub discussions on control flow analysis trade-offs [1] and the implementation notes for preserved narrowing in closures [4].

Citations:


Declare test probes explicitly instead of collapsing with null initializers.

actions and error are only assigned inside Probe while Probe is invoked inside act, but TypeScript only sees declarations initialized to null. This keeps both variables narrowed to null, making actions!.saveNote(...) uncallable. Use ReturnType<typeof useNotesActions> | null and string | null with null as ... so the declared union is preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/NotesContext.test.tsx` around lines 41 - 74, Update the test
probe declarations for actions and error to use explicit nullable unions with
typed null assertions, preserving ReturnType<typeof useNotesActions> | null and
string | null rather than inferred null-only types. Keep the assignments inside
Probe and the existing saveNote assertion flow unchanged.

Comment on lines +547 to +584
const setEditorWidthResizeEnabled = useCallback(
async (enabled: boolean) => {
setEditorWidthResizeEnabledState(enabled);
try {
await updateSettingsPatch({ editorWidthResizeEnabled: enabled });
} catch (error) {
console.error("Failed to save editor width resize setting:", error);
}
},
[updateSettingsPatch],
);

const setEditorToolbarVisible = useCallback(
async (visible: boolean) => {
setEditorToolbarVisibleState(visible);
try {
await updateSettingsPatch({ editorToolbarVisible: visible });
} catch (error) {
console.error("Failed to save editor toolbar setting:", error);
}
},
[updateSettingsPatch],
);

const updateTitleBarNoteInfo = useCallback(
(kind: TitleBarNoteInfoKind, visible: boolean) => {
const next = updateTitleBarNoteInfoVisibility(
titleBarNoteInfoVisibilityRef.current,
kind,
visible,
);
applyTitleBarNoteInfoVisibility(next);
void updateSettingsPatch({
titleBarModifiedDateVisible: next.modifiedDateVisible,
titleBarFilenameVisible: next.filenameVisible,
}).catch((error) => {
console.error("Failed to save title bar information setting:", error);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restore state and show a user-facing error after a failed save.

These setters apply the preference before persistence. If updateSettingsPatch fails, the UI keeps the new value although it will be lost after reload. Restore the previous value or reload settings, and show an error toast.

As per coding guidelines, “Implement error handling with user-friendly messages.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/context/ThemeContext.tsx` around lines 547 - 584, The preference setters
in setEditorWidthResizeEnabled, setEditorToolbarVisible, and
updateTitleBarNoteInfo must roll back their local state or reload persisted
settings when updateSettingsPatch fails, instead of leaving unsaved values
displayed. Add a user-facing error toast in each failure path using the existing
notification mechanism and a clear friendly message, while retaining the
existing console error logging where appropriate.

Source: Coding guidelines

Comment on lines +107 to +129
const flush = (): Promise<void> => {
cancelTimer();
const checkpoint = pending;
if (!checkpoint) return operationTail;
pending = undefined;

return enqueue(async () => {
try {
await storage.write(checkpoint);
} catch (error) {
pending ??= checkpoint;
throw error;
}
});
};

const schedule = () => {
cancelTimer();
timer = setTimeout(() => {
timer = undefined;
void flush().catch(onError);
}, delayMs);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A failed checkpoint write is never retried.

flush restores pending when storage.write rejects (Lines 116-119), but it does not reschedule the timer. Nothing then retries the write. The next attempt happens only when the caller invokes markDirty, flush, handleVisibilityChange, or dispose. Editor.tsx schedules markDirty from keystrokes only, and its onError handler just logs (Lines 661-664).

The failure mode: the user types, the checkpoint write fails once (for example a transient filesystem error), and the user stops typing. The crash checkpoint for that draft is then never written, so a crash loses the draft. That defeats the purpose of the checkpoint.

Reschedule the timer after a failed write.

🛡️ Proposed fix
   const flush = (): Promise<void> => {
     cancelTimer();
     const checkpoint = pending;
     if (!checkpoint) return operationTail;
     pending = undefined;
 
     return enqueue(async () => {
       try {
         await storage.write(checkpoint);
       } catch (error) {
         pending ??= checkpoint;
+        if (!disposed) schedule();
         throw error;
       }
     });
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const flush = (): Promise<void> => {
cancelTimer();
const checkpoint = pending;
if (!checkpoint) return operationTail;
pending = undefined;
return enqueue(async () => {
try {
await storage.write(checkpoint);
} catch (error) {
pending ??= checkpoint;
throw error;
}
});
};
const schedule = () => {
cancelTimer();
timer = setTimeout(() => {
timer = undefined;
void flush().catch(onError);
}, delayMs);
};
const flush = (): Promise<void> => {
cancelTimer();
const checkpoint = pending;
if (!checkpoint) return operationTail;
pending = undefined;
return enqueue(async () => {
try {
await storage.write(checkpoint);
} catch (error) {
pending ??= checkpoint;
if (!disposed) schedule();
throw error;
}
});
};
const schedule = () => {
cancelTimer();
timer = setTimeout(() => {
timer = undefined;
void flush().catch(onError);
}, delayMs);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/draftCheckpoint.ts` around lines 107 - 129, Reschedule the checkpoint
timer when storage.write fails in flush, after restoring the checkpoint to
pending, so transient failures are retried without requiring another caller
action. Reuse the existing schedule function and preserve the rejected promise
propagation and onError handling.

Comment thread src/lib/recoveryNotice.ts
Comment on lines +22 to +35
function readNotices(storage: RecoveryNoticeStorage): RecoveryNotice[] {
const stored = storage.getItem(STORAGE_KEY);
if (!stored) return [];
const parsed: unknown = JSON.parse(stored);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(notice): notice is RecoveryNotice =>
typeof notice === "object" &&
notice !== null &&
typeof notice.recoveredTo === "string" &&
typeof notice.saveError === "string" &&
typeof notice.createdAt === "string",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Malformed stored data makes recordPendingRecoveryNotice throw and blocks window close.

readNotices calls JSON.parse at Line 25 without error handling. consumePendingRecoveryNotices guards that call with try/catch (Lines 56-63), but recordPendingRecoveryNotice does not (Line 43). A malformed value under scratch:pendingRecoveryNotices therefore makes recordPendingRecoveryNotice throw.

The reachable consequence: App.tsx (Lines 124-128) and PreviewApp.tsx (Lines 92-96) call recordPendingRecoveryNotice from the beforeClose step of runSafeWindowClose. The throw rejects that flow. The catch handler resets closeInProgressRef and shows "Window kept open because the draft could not be saved". The recovery snapshot was already written at that point, so the message is wrong, and the user cannot close the window until the malformed key is cleared.

Move the tolerance into readNotices so both callers behave the same.

🐛 Proposed fix
 function readNotices(storage: RecoveryNoticeStorage): RecoveryNotice[] {
   const stored = storage.getItem(STORAGE_KEY);
   if (!stored) return [];
-  const parsed: unknown = JSON.parse(stored);
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(stored);
+  } catch {
+    return [];
+  }
   if (!Array.isArray(parsed)) return [];

Also applies to: 37-50

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/recoveryNotice.ts` around lines 22 - 35, Update readNotices to catch
JSON.parse failures and return an empty notice list for malformed stored data,
keeping the existing array and notice validation behavior unchanged. Centralize
this tolerance in readNotices so both recordPendingRecoveryNotice and
consumePendingRecoveryNotices handle invalid storage consistently.

Comment on lines +14 to +15
const interfaceZoomRef = useRef(interfaceZoom);
interfaceZoomRef.current = interfaceZoom;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

The zoom updater discards the clamped value, so the zoom state escapes its bounds.

Lines 40-42 compute next with a clamp to [0.7, 1.5] and a round to a 0.05 step. Line 44 then ignores next and commits current + delta. Three defects follow:

  • The clamp does not reach the state. Repeated zoom-in pushes interfaceZoom above 1.5, and repeated zoom-out pushes it below 0.7.
  • The rounding does not reach the state, so floating-point drift accumulates. Three zoom-in presses from 1 store 1.1500000000000001.
  • The toast on line 45 reports next, not the committed value. At a bound the toast repeats "Zoom 150%" while the state keeps growing. The first press in the opposite direction then produces no visible change.

interfaceZoomRef exists only to read the current zoom without adding interfaceZoom to the effect dependencies. That forces the render-phase ref write on line 15, which React Doctor flags. Compute the clamp and the round inside the state updater instead. The ref is then unnecessary, and the render stays pure.

The toast text must report the committed value. Move it into an effect on interfaceZoom, or derive the target value before the call as shown.

🐛 Proposed fix
-import { useEffect, useRef } from "react";
+import { useEffect } from "react";
 import { toast } from "sonner";
 import { useTheme } from "../context/ThemeContext";
 import { resolveWindowShortcut } from "./windowShortcuts";
@@
 export function useWindowShortcuts({
   onOpenPreferences,
 }: UseWindowShortcutsOptions): void {
-  const { interfaceZoom, setInterfaceZoom } = useTheme();
-  const interfaceZoomRef = useRef(interfaceZoom);
-  interfaceZoomRef.current = interfaceZoom;
+  const { setInterfaceZoom } = useTheme();
+
+  const clampZoom = (value: number) =>
+    Math.round(Math.min(Math.max(value, 0.7), 1.5) * 20) / 20;
@@
       if (action === "zoom-reset") {
-        interfaceZoomRef.current = 1;
         setInterfaceZoom(1);
         toast("Zoom 100%", { id: "zoom", duration: 1500 });
         return;
       }
 
       const delta = action === "zoom-in" ? 0.05 : -0.05;
-      const next = Math.round(
-        Math.min(Math.max(interfaceZoomRef.current + delta, 0.7), 1.5) * 20,
-      ) / 20;
-      interfaceZoomRef.current = next;
-      setInterfaceZoom((current) => current + delta);
-      toast(`Zoom ${Math.round(next * 100)}%`, {
-        id: "zoom",
-        duration: 1500,
-      });
+      setInterfaceZoom((current) => {
+        const next = clampZoom(current + delta);
+        toast(`Zoom ${Math.round(next * 100)}%`, {
+          id: "zoom",
+          duration: 1500,
+        });
+        return next;
+      });

Note: if setInterfaceZoom runs its updater more than once, as React Strict Mode does in development, the toast call inside the updater duplicates. In that case keep the updater pure and read the committed interfaceZoom in a separate effect to raise the toast.

Also applies to: 39-48

🧰 Tools
🪛 React Doctor (0.9.3)

[error] 15-15: This ref is mutated during render. React can replay or discard render work, so the mutation can leak from UI that never commits.

Move ref writes into an event handler or effect. Render must stay pure because React can replay or discard it. The predictable null-guarded lazy initialization pattern remains supported.

(no-ref-current-in-render)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/useWindowShortcuts.ts` around lines 14 - 15, Update the zoom flow in
useWindowShortcuts so the clamped, rounded value is the one actually committed
by setInterfaceZoom instead of current + delta, and keep the toast in sync with
the committed interfaceZoom. Remove the render-phase interfaceZoomRef write if
it is only there to read the latest value for the shortcut handler, and compute
the next zoom inside the state updater or from committed state so the bounds and
0.05 քայլ rounding are preserved.

Source: Linters/SAST tools

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.

1 participant