Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 3c81e42167 fix(notes): dedupe quota failure toasts during autosave
Debounced draft flushes can hit QuotaExceededError repeatedly; rate-limit
the error toast so users are not spammed every 300ms.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:55:30 +00:00
Cursor Agentand陈大猫 f03c9f4e49 fix(notes): cancel draft flush during IME title stash
Prior idle commits could still debounce-flush mid-composition and rewrite
the controlled title. Stash now clears the timer, and external supersede
in the adopt effect live-stashes the authoritative title.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:27:43 +00:00
Cursor Agentand陈大猫 12e8f58496 fix(notes): clear stashed title when IME composition is superseded
compositionEnd now live-stashes the adopted external title so pagehide
and note-switch flushes cannot persist rejected composed text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:24:19 +00:00
Cursor Agentand陈大猫 ff13a2da66 fix(notes): stash IME title drafts in refs without controlled rewrite
Composition updates now live-stash into draftTitleRef for teardown and
note-switch flushes, while onCommit still waits for idle IME. Blur syncs
superseded external titles before flush so stale drafts cannot overwrite.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:21:09 +00:00
Cursor Agentand陈大猫 039e636784 fix(notes): commit title draft on blur for IME composition
IME-only title entry never called onCommit until compositionend; blur
could flush an empty draftTitle and then adopt the stale external value.
Commit the local draft on blur before parent flushNoteDraft.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:16:04 +00:00
Cursor Agentand陈大猫 f8f4ded120 fix(notes): protect title IME, surface persist failures, harden paste
Note titles rewrote controlled value during CJK composition (Sogou Wubi).
Notes localStorage writes ignored QuotaExceededError so restarts lost
content. Markdown paste intercept could preventDefault with a no-op insert
(fixes #2783).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-07 09:08:40 +00:00
11 changed files with 449 additions and 15 deletions

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ export type NotesSnapshot = {
};
export type NotesActions = {
updateNotes: (notes: VaultNote[]) => void;
updateNotes: (notes: VaultNote[]) => boolean | void;
updateNoteGroups: (groups: string[]) => void;
};
+23 -4
View File
@@ -69,8 +69,10 @@ import {
publishNotesSnapshot,
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
import {
decryptGroupConfigs,
decryptHosts,
@@ -286,6 +288,7 @@ export const useVaultState = () => {
const hostsRef = useRef<Host[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
@@ -598,13 +601,29 @@ export const useVaultState = () => {
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const cleaned = normalizeVaultNotes(data);
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleaned);
// Publish synchronously so Notes→AI handoff does not read a stale catalog
// while React state is still committing (useLayoutEffect republishes too).
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
if (now - notesPersistFailureNotifiedAtRef.current > 10_000) {
notesPersistFailureNotifiedAtRef.current = now;
notify.error(
"Notes could not be saved. Free some local storage space and try again.",
"Notes",
);
}
return false;
}
return true;
}, []);
const updateNoteGroups = useCallback((data: unknown) => {
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys.ts";
import { commitVaultNotesWrite } from "./vaultNotesPersistence.ts";
test("commitVaultNotesWrite reports failure when the storage write returns false", () => {
const writes: Array<{ key: string; value: unknown }> = [];
const result = commitVaultNotesWrite({
data: [{
id: "note-1",
title: "Draft",
content: "body",
createdAt: 1,
updatedAt: 2,
}],
write: (key, value) => {
writes.push({ key, value });
return false;
},
});
assert.equal(result.persisted, false);
assert.equal(writes.length, 1);
assert.equal(writes[0]?.key, STORAGE_KEY_NOTES);
assert.equal(result.notes[0]?.title, "Draft");
});
test("commitVaultNotesWrite returns persisted notes when the write succeeds", () => {
const result = commitVaultNotesWrite({
data: [{
id: "note-1",
title: "Saved",
content: "ok",
createdAt: 1,
updatedAt: 2,
}],
write: () => true,
});
assert.equal(result.persisted, true);
assert.equal(result.notes[0]?.title, "Saved");
});
test("useVaultState updateNotes surfaces storage write failures", () => {
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
assert.match(source, /commitVaultNotesWrite/);
assert.match(source, /notify\.error/);
assert.match(source, /!persisted|persisted === false/);
assert.match(source, /return false/);
assert.match(source, /notesPersistFailureNotifiedAtRef/);
assert.match(source, /10_000/);
});
@@ -0,0 +1,21 @@
import { normalizeVaultNotes } from "../../domain/notes";
import type { VaultNote } from "../../domain/models";
import { STORAGE_KEY_NOTES } from "../../infrastructure/config/storageKeys";
export type VaultNotesWriteResult = {
notes: VaultNote[];
persisted: boolean;
};
/**
* Normalize and attempt to persist vault notes. Callers must check `persisted`
* before treating the update as durable (QuotaExceededError returns false).
*/
export function commitVaultNotesWrite(input: {
data: Partial<VaultNote>[];
write: (key: string, value: VaultNote[]) => boolean;
}): VaultNotesWriteResult {
const notes = normalizeVaultNotes(input.data);
const persisted = input.write(STORAGE_KEY_NOTES, notes);
return { notes, persisted };
}
@@ -209,7 +209,10 @@ test("pasting inside code blocks keeps CodeMirror in control", () => {
assert.match(source, /element\?\.closest/);
assert.match(source, /\.cm-editor/);
assert.match(source, /_codeMirrorWrapper_/);
assert.match(source, /if \(isNotePasteInsideCodeBlock\(event\.target\)\) return;/);
assert.match(
source,
/pasteInsideCodeBlock:\s*isNotePasteInsideCodeBlock\(event\.target\)/,
);
});
test("note code block editor colors follow the app theme", () => {
+47 -4
View File
@@ -29,6 +29,7 @@ import { ExternalLink } from "lucide-react";
import {
$createRangeSelection,
$getNearestNodeFromDOMNode,
$getSelection,
$isTextNode,
$setSelection,
getNearestEditorFromDOMNode,
@@ -223,6 +224,41 @@ export const isNotePasteInsideCodeBlock = (target: EventTarget | null): boolean
return Boolean(element?.closest(".cm-editor, [class*=\"_codeMirrorWrapper_\"]"));
};
/** True when Lexical currently has a selection that insertMarkdown can target. */
export const hasActiveLexicalTextSelection = (target: EventTarget | null): boolean => {
if (typeof Element === "undefined" || typeof Node === "undefined") return false;
const element = target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
if (!element) return false;
const lexicalEditor = getNearestEditorFromDOMNode(element);
if (!lexicalEditor) return false;
let hasSelection = false;
lexicalEditor.getEditorState().read(() => {
hasSelection = $getSelection() !== null;
});
return hasSelection;
};
/**
* Decide whether markdown paste should call preventDefault + insertMarkdown.
* When selection is missing, return false so the browser can paste plain text
* instead of swallowing the clipboard with a no-op insert.
*/
export const shouldInterceptNoteMarkdownPaste = (input: {
editorMode: NoteEditorMode;
pasteInsideCodeBlock: boolean;
clipboardText: string;
canInsertMarkdownAtSelection: boolean;
}): boolean => {
if (input.editorMode !== "edit") return false;
if (input.pasteInsideCodeBlock) return false;
if (!shouldInsertClipboardTextAsMarkdown(input.clipboardText)) return false;
return input.canInsertMarkdownAtSelection;
};
export const resolveHostPickerPopupPosition = ({
anchorRect,
containerRect,
@@ -846,12 +882,19 @@ export function InlineMarkdownEditor({
}, []);
const handlePasteCapture = useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
if (editorMode !== "edit") return;
if (isNotePasteInsideCodeBlock(event.target)) return;
const markdown = event.clipboardData.getData("text/plain");
if (!shouldInsertClipboardTextAsMarkdown(markdown)) return;
const editor = editorRef.current;
if (
!shouldInterceptNoteMarkdownPaste({
editorMode,
pasteInsideCodeBlock: isNotePasteInsideCodeBlock(event.target),
clipboardText: markdown,
canInsertMarkdownAtSelection: Boolean(editor)
&& hasActiveLexicalTextSelection(event.target),
})
) {
return;
}
if (!editor) return;
event.preventDefault();
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./NoteTitleInput.tsx", import.meta.url), "utf8");
const managerSource = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
test("NoteTitleInput keeps a local draft and only commits when IME composition is idle", () => {
assert.match(source, /shouldCommitImeControlledChange/);
assert.match(source, /shouldAdoptExternalImeControlledValue/);
assert.match(source, /onCompositionStart=\{/);
assert.match(source, /onCompositionEnd=\{/);
assert.match(source, /value=\{draft\}/);
assert.doesNotMatch(
source,
/onChange=\{\(event\) => onCommit\(event\.target\.value\)\}/,
);
});
test("NoteTitleInput stashes live drafts during composition without idle-only commits", () => {
assert.match(source, /onLiveDraft\?\.\(next\)/);
assert.match(source, /onLiveDraft\?: \(title: string\) => void/);
});
test("NoteTitleInput commits the local draft on blur before parent flush", () => {
assert.match(source, /onBlur=\{\(event\) => \{/);
assert.match(source, /onCommit\(next\)/);
assert.match(source, /onCommit\(value\)/);
assert.match(source, /onBlur\?\.\(\)/);
});
test("NoteTitleInput clears live-stashed title when composition is externally superseded", () => {
assert.match(
source,
/supersededRef\.current = true;[\s\S]*?setDraft\(value\);[\s\S]*?onLiveDraft\?\.\(value\)/,
);
assert.match(source, /adoptedExternal/);
assert.match(source, /onLiveDraftRef\.current\?\.\(adoptedExternal\)/);
});
test("NotesManager cancels debounced flush while stashing IME title drafts", () => {
assert.match(
managerSource,
/clearDraftTimer\(\);[\s\S]*?draftNoteIdRef\.current = note\.id;[\s\S]*?draftTitleRef\.current = title/,
);
});
test("NoteTitleInput resets IME guards when the active note changes", () => {
assert.match(source, /noteId/);
assert.match(
source,
/composingRef\.current = false;[\s\S]*?supersededRef\.current = false;[\s\S]*?setDraft\(value\)/,
);
});
test("NotesManager title rows use NoteTitleInput instead of raw controlled saves", () => {
assert.match(managerSource, /NoteTitleInput/);
assert.match(managerSource, /data-note-title-row/);
assert.match(managerSource, /onCommit=\{\(title\) => saveNoteTitleDraft/);
assert.doesNotMatch(
managerSource,
/data-note-title-row[\s\S]{0,500}<input[\s\S]{0,250}onChange=\{\(event\) => saveNoteTitleDraft/,
);
});
test("NotesManager title rows stash live IME drafts into refs", () => {
assert.match(managerSource, /stashNoteTitleDraft/);
assert.match(managerSource, /onLiveDraft=\{\(title\) => stashNoteTitleDraft/);
assert.match(managerSource, /draftTitleRef\.current = title/);
});
+156
View File
@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from "react";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "../../domain/imeControlledInput";
type NoteTitleInputProps = {
noteId: string;
value: string;
placeholder?: string;
className?: string;
/** Commit into parent draft state (may rewrite controlled value). Idle IME only. */
onCommit: (title: string) => void;
/**
* Stash title for crash/teardown flush without updating controlled React state.
* Called during IME composition so pagehide/note-switch can persist without
* fighting the composition buffer.
*/
onLiveDraft?: (title: string) => void;
onBlur?: () => void;
};
/**
* Controlled note-title field that does not push parent updates during CJK IME
* composition. Immediate `value={external}` writes mid-composition break Windows
* IMEs such as Sogou Wubi (candidate dismiss / no committed text).
*/
export const NoteTitleInput: React.FC<NoteTitleInputProps> = ({
noteId,
value,
placeholder,
className,
onCommit,
onLiveDraft,
onBlur,
}) => {
const [draft, setDraft] = useState(value);
const composingRef = useRef(false);
const valueAtComposeStartRef = useRef(value);
const supersededRef = useRef(false);
const noteIdRef = useRef(noteId);
const onLiveDraftRef = useRef(onLiveDraft);
onLiveDraftRef.current = onLiveDraft;
useEffect(() => {
if (noteIdRef.current !== noteId) {
noteIdRef.current = noteId;
composingRef.current = false;
supersededRef.current = false;
setDraft(value);
return;
}
let adoptedExternal: string | null = null;
setDraft((draftValue) => {
const composing = composingRef.current;
const shouldAdopt = shouldAdoptExternalImeControlledValue({
isComposingSession: composing,
draftValue,
externalValue: value,
valueAtComposeStart: composing ? valueAtComposeStartRef.current : undefined,
});
if (shouldAdopt && composing && value !== valueAtComposeStartRef.current) {
supersededRef.current = true;
adoptedExternal = value;
}
return shouldAdopt ? value : draftValue;
});
if (adoptedExternal !== null) {
onLiveDraftRef.current?.(adoptedExternal);
}
}, [noteId, value]);
const commit = (next: string) => {
composingRef.current = false;
supersededRef.current = false;
setDraft(next);
onCommit(next);
};
return (
<input
data-note-title-input="true"
className={className}
value={draft}
placeholder={placeholder}
onBlur={(event) => {
// Blur finalizes IME. Sync parent before flushNoteDraft so composition-only
// titles and superseded external adoptions both land in draftTitleRef.
composingRef.current = false;
if (supersededRef.current) {
supersededRef.current = false;
setDraft(value);
onCommit(value);
} else {
const next = event.currentTarget.value;
setDraft(next);
onCommit(next);
}
onBlur?.();
}}
onChange={(event) => {
const superseded = resolveSupersededImeInputEvent({
compositionExternallySuperseded: supersededRef.current,
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
});
if (superseded.ignoreEventValue) {
if (superseded.clearSupersedeLatch) {
supersededRef.current = false;
}
setDraft(value);
return;
}
const next = event.target.value;
setDraft(next);
// Always stash for teardown/note-switch flush; do not fight IME via onCommit.
onLiveDraft?.(next);
if (
shouldCommitImeControlledChange({
isComposingSession: composingRef.current,
nativeEventIsComposing: event.nativeEvent.isComposing,
compositionExternallySuperseded: supersededRef.current,
})
) {
onCommit(next);
}
}}
onCompositionStart={() => {
composingRef.current = true;
supersededRef.current = false;
valueAtComposeStartRef.current = value;
}}
onCompositionEnd={(event) => {
composingRef.current = false;
if (value !== valueAtComposeStartRef.current || supersededRef.current) {
supersededRef.current = true;
setDraft(value);
// Drop any live-stashed composed text so teardown flush cannot persist
// the rejected IME draft over the authoritative external title.
onLiveDraft?.(value);
window.setTimeout(() => {
if (supersededRef.current && !composingRef.current) {
supersededRef.current = false;
}
}, 0);
return;
}
commit(event.currentTarget.value);
}}
/>
);
};
+21 -4
View File
@@ -77,6 +77,7 @@ import {
markVaultInsideDropIndicator,
} from "../vault/vaultReorderDrag";
import type { NoteEditorMode } from "./InlineMarkdownEditor";
import { NoteTitleInput } from "./NoteTitleInput";
const InlineMarkdownEditor = lazy(() =>
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
@@ -630,6 +631,18 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
updateNoteDraft(note.id, { title });
};
/** Ref-only title stash for IME composition — avoids controlled value rewrite. */
const stashNoteTitleDraft = (note: VaultNote, title: string) => {
if (draftNoteIdRef.current && draftNoteIdRef.current !== note.id) {
flushNoteDraft();
}
// Cancel any idle-commit debounce so a prior ASCII commit cannot flush
// mid-composition and rewrite the controlled title.
clearDraftTimer();
draftNoteIdRef.current = note.id;
draftTitleRef.current = title;
};
const saveNoteContentDraft = (note: VaultNote, content: string) => {
updateNoteDraft(note.id, { content });
};
@@ -1666,11 +1679,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<>
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<input
<NoteTitleInput
noteId={selectedNoteView.id}
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={selectedNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNoteTitleDraft(selectedNoteView, event.target.value)}
onCommit={(title) => saveNoteTitleDraft(selectedNoteView, title)}
onLiveDraft={(title) => stashNoteTitleDraft(selectedNoteView, title)}
onBlur={() => flushNoteDraft()}
/>
</div>
@@ -1755,11 +1770,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
<div className="flex min-h-0 flex-1 flex-col bg-background">
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
<div className="min-w-0 flex-1">
<input
<NoteTitleInput
noteId={overlayNoteView.id}
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
value={overlayNoteView.title}
placeholder={t("notes.title.placeholder")}
onChange={(event) => saveNoteTitleDraft(overlayNoteView, event.target.value)}
onCommit={(title) => saveNoteTitleDraft(overlayNoteView, title)}
onLiveDraft={(title) => stashNoteTitleDraft(overlayNoteView, title)}
onBlur={() => flushNoteDraft()}
/>
</div>
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { shouldInterceptNoteMarkdownPaste } from "./InlineMarkdownEditor.tsx";
test("markdown paste intercept requires a live Lexical selection", () => {
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: true,
}),
true,
);
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "edit",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: false,
}),
false,
);
assert.equal(
shouldInterceptNoteMarkdownPaste({
editorMode: "preview",
pasteInsideCodeBlock: false,
clipboardText: "# Heading\n\n- item",
canInsertMarkdownAtSelection: true,
}),
false,
);
});
test("InlineMarkdownEditor only preventDefaults markdown paste after a successful intercept guard", () => {
const source = readFileSync(new URL("./InlineMarkdownEditor.tsx", import.meta.url), "utf8");
assert.match(source, /shouldInterceptNoteMarkdownPaste/);
assert.match(source, /hasActiveLexicalTextSelection/);
assert.match(
source,
/shouldInterceptNoteMarkdownPaste\([\s\S]*?\)[\s\S]*?event\.preventDefault\(\)/,
);
assert.match(
source,
/if \(\s*!shouldInterceptNoteMarkdownPaste\([\s\S]*?\)\s*\{\s*return;\s*\}/,
);
});
+1 -1
View File
@@ -424,7 +424,7 @@ export interface VaultAgentApiDeps {
unreadable: boolean;
}>;
removeKeyPassphrases: (keyPaths: string[]) => Promise<void> | void;
updateNotes: (notes: VaultNote[]) => void;
updateNotes: (notes: VaultNote[]) => boolean | void;
updateSnippets: (snippets: Snippet[]) => void;
startTunnel: (
rule: PortForwardingRule,