mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 18:21:00 +00:00
* fix(notes): domain paste policy + Codex review follow-ups Move clipboard paste resolution to domain/notes (pure, no React) and address post-#2868 review items: structured text/plain wins over wrapper HTML, caret-safe insert for long pastes (no EOF append), https/protocol-relative image src, entity decode, quote-aware img attrs, code-region protection, nested HTML islands, hard-break preservation, height-only image CSS, and complete small-icon widths. * fix: address Codex review on PR #2869 * perf(notes): cut hover re-renders and heavy image :has() CSS Reduce perceived lag in large notes by (1) coalescing link-hover setState with equality checks and rAF, (2) replacing the combinatorial :has(img[width]) matrix with data-note-img-size markers plus lazy images, and (3) merging DOM decoration into one observer that debounces in edit mode so Lexical keystrokes do not re-walk anchors every frame. * perf(notes): render preview with Streamdown instead of MDXEditor Vault note preview no longer mounts Lexical/MDXEditor/CodeMirror for read-only viewing. Preview uses static Streamdown (same stack as chat) with HTML islands for centered heroes and sized images; edit mode keeps MDXEditor. This cuts large-note scroll and mode-switch cost while preserving host-link click handling and compact badge image layout. * fix(notes): render markdown inside center HTML for Streamdown preview Streamdown/remark does not parse Markdown nested in HTML blocks, so README-style <div align="center"> heroes showed raw # ** and badge syntax. Preprocess preview markdown: expand centered islands via marked GFM to HTML, and rewrite relative images so Streamdown no longer shows "Image blocked" chips under app CSP. * fix(notes): GitHub-style preview with react-markdown Replace Streamdown note preview (chat-oriented Image blocked chrome and poor README layout) with react-markdown + remark-gfm + rehype-raw/sanitize and github-markdown-css. Center HTML islands still expand via marked so pasted heroes render; relative images and empty links are dropped quietly instead of alt-as-title or blocked chips. * fix(notes): keep relative images; map Vite public/ to site root Stop dropping non-https note images. Preserve absolute and relative src as-is, except rewrite repo public/ assets to site-root paths (public/icon.png → /icon.png) so Vite/Electron serve them without /public/* warnings. Unwrap oversized center blocks so Features-style body stays left-aligned in preview like edit mode. * fix(notes): left-align preview body; center only README hero Force markdown-body text-align left and direct block headings/lists left. After the first hero thematic break, strip align=center / text-align:center so Catty/Features sections no longer inherit full-width centering. Tighten oversized-center heuristics (Catty Agent, lists, blockquotes) without treating the Netcatty logo title as a body section. * fix(notes): force preview body left-align (nuclear CSS + unwrap) Catty/Features still rendered centered because small align=center shells and inheritance beat partial CSS. Force every node left via * selector, re-enable center only under [align=center], unwrap all center shells after the hero --- band, and set inline textAlign left on the preview root. * fix(notes): stop /public asset requests; fix preview center inheritance Root cause of body still centering: CSS re-applied text-align:center to all descendants of [align=center], so one large shell centered Catty/lists. Keep * left; only the shell itself may center (inline badges). Root cause of Vite /public/* warnings: stored notes still had /public/... paths and MDX loaded them raw. normalizeNotePublicAssetPaths rewrites /public/ and public/ to site-root paths for both edit and preview display. * revert(notes): restore MDXEditor for preview (drop GitHub markdown path) GitHub-style react-markdown preview caused layout and asset-path issues that diverged from edit mode. Preview again uses MDXEditor with readOnly + mode key remount, matching the pre-Streamdown/GitHub path. Keep display-time public/ → / rewrite so Vite does not request /public/*. * perf(notes): reuse MDX editor when switching notes Remove key={noteId} remount that tore down Lexical/CodeMirror on every note change. Pass noteId and call setMarkdown on switch instead, after the existing draft flush, so selection stays snappy and only document content is replaced. * perf(notes): defer setMarkdown on switch so selection paints first Yield a double rAF before Lexical import on note change, show a blocking overlay for large notes, and ignore external sync while a swap is in flight. Hardens flaky Suspense loading assertions in SSR tests. * fix(notes): align task-list checkboxes and make them clickable in preview Center GFM checkboxes on the first text line, give them a solid hit target, and toggle markdown from preview clicks since Lexical readOnly blocks CheckListPlugin. Pure toggle helper lives in domain/notes/taskList. * fix(notes): close multi-agent review findings on switch/paste/tasks Gate stale onChange during deferred note-switch import, keep draft refs in display-markdown space, baseline paste settle on live getMarkdown to avoid false success after muted setMarkdown, mask code regions in task toggles, and drop abandoned GitHub/Streamdown preview module and deps. * fix(notes): address remaining Codex review findings Clear Lexical undo history on note switch, always inert-block during deferred setMarkdown, re-annotate host links and image sizes on attribute mutations, harden entity/code-region paste paths, and angle-bracket image destinations that contain spaces. * fix(notes): tighten Codex follow-ups on src, code masks, tasks Prefer real img src over data-src, normalize public/ paths only outside code, mask indented/blockquote fences, and accept 1) ordered task markers. * fix(notes): keep swap payloads fresh; keep nested list tasks Refresh deferred setMarkdown when the same note's value changes mid-yield, and stop treating nested list lines as indented code so task toggles stay aligned with the preview DOM. * test(notes): update swap guard source assertions * fix(notes): collision-free code masks, real href, cancel hover rAF Use a document-unique mask sentinel, match anchor href with attribute boundaries (not data-href), and cancel the pending link-hover frame on mouse leave so chips cannot reappear after exit. * fix(notes): accept longer closing fences in code masks CommonMark allows closing fences with more backticks/tildes than the opener; line-scan masking now matches length >= open so public/ paths and task samples inside those blocks stay protected. * fix(notes): harden CommonMark fence, span, and indented-code masks Use homogeneous fence openers, multi-backtick inline spans, and list-context aware indented-code detection so samples stay masked without breaking nested task indexing. * fix(notes): task spacing, angled image dests, deep blockquote fences Require whitespace after task brackets, parse <angled> image destinations with spaces, and mask fenced code at any blockquote depth. * fix(notes): raw-text HTML elements and HTML comment task masks Locate script/style closers without nested-tag walks so raw bodies with '<' are removed, and mask HTML comments before task indexing so preview toggles hit visible checkboxes only. * perf(notes): drop fade animation on note switch and editor load Use an instant solid cover instead of opacity/transform fade-in so Lexical swaps do not pay extra composite cost that can feel more janky than a hard cut. --------- Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
|
import type { VaultNote } from "../../types.ts";
|
|
import { TooltipProvider } from "../ui/tooltip.tsx";
|
|
import {
|
|
getFallbackNoteSelectionState,
|
|
getNoteActionTargetGroup,
|
|
getNoteGroupSelectionState,
|
|
getNotesGroupDropAction,
|
|
getNoteSelectionState,
|
|
getValidatedNoteSelectionState,
|
|
getSelectedVaultNote,
|
|
isNoteFolderTreeSelected,
|
|
NotesManager,
|
|
} from "./NotesManager.tsx";
|
|
|
|
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
|
|
id: "note-1",
|
|
title: "Postgres failover checklist",
|
|
content: "# Steps\n\nPromote replica",
|
|
group: "Ops",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
order: 1000,
|
|
...overrides,
|
|
});
|
|
|
|
const renderNotes = (
|
|
notes: VaultNote[] = [note()],
|
|
displayMode: React.ComponentProps<typeof NotesManager>["displayMode"] = "full",
|
|
noteGroups: string[] = ["Ops"],
|
|
openNoteId: string | null = null,
|
|
) => renderToStaticMarkup(
|
|
<I18nProvider locale="en">
|
|
<TooltipProvider>
|
|
<NotesManager
|
|
notes={notes}
|
|
noteGroups={noteGroups}
|
|
hosts={[]}
|
|
onUpdateNotes={() => undefined}
|
|
onUpdateNoteGroups={() => undefined}
|
|
displayMode={displayMode}
|
|
openNoteId={openNoteId}
|
|
/>
|
|
</TooltipProvider>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
test("NotesManager renders notes tree and selected markdown editor", () => {
|
|
const markup = renderNotes();
|
|
|
|
assert.match(markup, /Ops/);
|
|
assert.match(markup, /Postgres failover checklist/);
|
|
// React.lazy may show Suspense fallback or the resolved editor depending on
|
|
// whether a prior test already settled the MDX chunk.
|
|
assert.ok(
|
|
markup.includes('data-notes-editor-loading="true"')
|
|
|| markup.includes("editable markdown")
|
|
|| markup.includes("Promote replica"),
|
|
"full mode should mount the note editor (loading fallback or resolved MDX)",
|
|
);
|
|
});
|
|
|
|
test("NotesManager marks selected notebook rows with shared tree state", () => {
|
|
const markup = renderNotes();
|
|
|
|
assert.match(markup, /data-vault-tree-row="group"/);
|
|
assert.match(markup, /data-vault-tree-row="item"/);
|
|
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
|
|
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
|
|
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"/);
|
|
});
|
|
|
|
test("NotesManager balances folder and note tree icon sizes", () => {
|
|
const markup = renderNotes();
|
|
|
|
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
|
|
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
|
});
|
|
|
|
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
|
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
|
const noteSelection = getNoteSelectionState(notes[0], false);
|
|
const sidebarNoteSelection = getNoteSelectionState(notes[0], true);
|
|
const groupSelection = getNoteGroupSelectionState("Ops");
|
|
|
|
assert.equal(getSelectedVaultNote(notes, "note-1")?.title, "Postgres failover checklist");
|
|
assert.equal(getSelectedVaultNote(notes, null), null);
|
|
assert.deepEqual(noteSelection, {
|
|
selectedNoteId: "note-1",
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
assert.deepEqual(sidebarNoteSelection, {
|
|
selectedNoteId: "note-1",
|
|
selectedGroup: null,
|
|
overlayNoteId: "note-1",
|
|
});
|
|
assert.deepEqual(groupSelection, {
|
|
selectedNoteId: null,
|
|
selectedGroup: "Ops",
|
|
overlayNoteId: null,
|
|
});
|
|
assert.equal(isNoteFolderTreeSelected("Ops", "note-1", "Ops"), false);
|
|
assert.equal(isNoteFolderTreeSelected("Ops", null, "Ops"), true);
|
|
assert.equal(getNoteActionTargetGroup(notes[0], "Deploy"), "Ops");
|
|
assert.equal(getNoteActionTargetGroup(null, "Deploy"), "Deploy");
|
|
});
|
|
|
|
test("NotesManager note creation, duplicate, and delete fallback selections stay exclusive", () => {
|
|
const notes = [
|
|
note(),
|
|
note({ id: "note-2", title: "Deploy", group: "Deploy", order: 2000 }),
|
|
];
|
|
const createdOrDuplicated = note({ id: "new-note", group: "Ops" });
|
|
|
|
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, false), {
|
|
selectedNoteId: "new-note",
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, true), {
|
|
selectedNoteId: "new-note",
|
|
selectedGroup: null,
|
|
overlayNoteId: "new-note",
|
|
});
|
|
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), false), {
|
|
selectedNoteId: "note-2",
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), true), {
|
|
selectedNoteId: null,
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
});
|
|
|
|
test("NotesManager selects the first loaded note when full mode has no selection", () => {
|
|
const notes = [
|
|
note({ id: "note-2", title: "Loaded note", group: "Ops", order: 2000 }),
|
|
];
|
|
|
|
assert.deepEqual(getValidatedNoteSelectionState(notes, null, null, false), {
|
|
selectedNoteId: "note-2",
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
assert.equal(getValidatedNoteSelectionState(notes, null, "Ops", false), null);
|
|
assert.equal(getValidatedNoteSelectionState(notes, null, null, true), null);
|
|
});
|
|
|
|
test("NotesManager group drop helper separates reorder, inside, and ignored drops", () => {
|
|
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "before"), "reorder");
|
|
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "after"), "reorder");
|
|
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "inside"), "inside");
|
|
assert.equal(getNotesGroupDropAction("Ops", "Ops", "before"), "ignore");
|
|
assert.equal(getNotesGroupDropAction("Ops", "Ops/DB", "inside"), "ignore");
|
|
assert.equal(getNotesGroupDropAction(null, "Deploy", "before"), "ignore");
|
|
});
|
|
|
|
test("NotesManager exposes shared tree drag targets and context menus", () => {
|
|
const markup = renderNotes();
|
|
|
|
assert.match(markup, /data-notes-drop-zone="root"/);
|
|
assert.match(markup, /data-notes-drag-kind="group"/);
|
|
assert.match(markup, /data-notes-drag-kind="note"/);
|
|
assert.match(markup, /data-notes-context-menu="group"/);
|
|
assert.match(markup, /data-notes-context-menu="note"/);
|
|
assert.match(markup, /draggable="true"/);
|
|
assert.match(markup, /data-open="false"/);
|
|
assert.match(markup, /role="separator"/);
|
|
});
|
|
|
|
test("NotesManager restores and persists the note editor mode without resetting on note switch", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
|
|
assert.match(source, /STORAGE_KEY_VAULT_NOTES_EDITOR_MODE/);
|
|
assert.match(source, /useStoredString<NoteEditorMode>\(\s*STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,\s*"edit",\s*isNoteEditorMode/s);
|
|
assert.doesNotMatch(source, /localStorageAdapter/);
|
|
assert.doesNotMatch(source, /setNoteEditorMode\("edit"\)/);
|
|
});
|
|
|
|
test("NotesManager renders nested notebook folders", () => {
|
|
const markup = renderNotes([
|
|
note({
|
|
group: "Ops/DB/Failover",
|
|
title: "Replica promotion",
|
|
content: "Promote replica",
|
|
}),
|
|
]);
|
|
|
|
assert.match(markup, /Ops/);
|
|
assert.match(markup, /DB/);
|
|
assert.match(markup, /Failover/);
|
|
assert.match(markup, /Replica promotion/);
|
|
});
|
|
|
|
test("NotesManager keeps saved notebook folder order", () => {
|
|
const markup = renderNotes(
|
|
[
|
|
note({ id: "alpha-note", title: "Alpha note", group: "Alpha" }),
|
|
note({ id: "beta-note", title: "Beta note", group: "Beta" }),
|
|
],
|
|
"full",
|
|
["Beta", "Alpha"],
|
|
);
|
|
|
|
assert.ok(markup.indexOf("Beta") < markup.indexOf("Alpha"));
|
|
});
|
|
|
|
test("NotesManager renders empty state", () => {
|
|
const markup = renderNotes([]);
|
|
|
|
assert.match(markup, /No notes yet/);
|
|
assert.match(markup, /New Note/);
|
|
assert.match(markup, /Import Markdown/);
|
|
assert.doesNotMatch(markup, /data-notes-drop-zone="root"/);
|
|
});
|
|
|
|
test("NotesManager exposes markdown import controls", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
|
|
assert.match(source, /notes\.action\.importMarkdown/);
|
|
assert.match(source, /importMarkdownPayloadsToVaultNotes/);
|
|
assert.match(source, /accept="\.md,\.markdown,\.txt"/);
|
|
assert.match(source, /importTargetGroupRef/);
|
|
assert.match(source, /openImportMarkdownPicker/);
|
|
assert.match(source, /notes\.action\.importMarkdown[\s\S]*openImportMarkdownPicker\(groupPath\)/);
|
|
assert.match(source, /multiple/);
|
|
});
|
|
|
|
test("NotesManager exposes markdown export controls", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
|
|
assert.match(source, /notes\.action\.exportNote/);
|
|
assert.match(source, /notes\.action\.exportGroup/);
|
|
assert.match(source, /notes\.action\.exportAll/);
|
|
assert.match(source, /buildVaultNoteMarkdownExportFiles/);
|
|
assert.match(source, /buildTextFilesZipBlob/);
|
|
assert.match(source, /downloadNotesBlob/);
|
|
assert.match(source, /text\/markdown;charset=utf-8/);
|
|
});
|
|
|
|
test("NotesManager shows placeholder label for notes without titles", () => {
|
|
const markup = renderNotes([note({ title: "" })]);
|
|
|
|
assert.match(markup, /Note title/);
|
|
});
|
|
|
|
test("NotesManager flushes drafts on pagehide/beforeunload", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
assert.match(source, /addEventListener\("pagehide"/);
|
|
assert.match(source, /addEventListener\("beforeunload"/);
|
|
assert.match(source, /flushNoteDraft/);
|
|
});
|
|
|
|
test("NotesManager flushes drafts when retained mounts become inactive", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
assert.match(source, /isActive\?: boolean/);
|
|
assert.match(source, /if \(isActive\) return;/);
|
|
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
|
|
assert.match(source, /visibilitychange/);
|
|
});
|
|
|
|
test("NotesManager tree rename allows clearing note titles", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
|
|
|
|
assert.match(renameBlock, /renameNoteFromTree\(note\.id, name\)/);
|
|
assert.doesNotMatch(renameBlock, /if \(!title\) return/);
|
|
assert.match(source, /note\.id === noteId \? \{ \.\.\.note, title: nextTitle, updatedAt \}/);
|
|
});
|
|
|
|
test("NotesManager sidebar mode renders list without editor by default", () => {
|
|
const markup = renderNotes([note()], "sidebar");
|
|
|
|
assert.match(markup, /Ops/);
|
|
assert.match(markup, /Postgres failover checklist/);
|
|
assert.doesNotMatch(markup, /editable markdown/);
|
|
assert.doesNotMatch(markup, /data-notes-editor-loading="true"/);
|
|
});
|
|
|
|
test("NotesManager sidebar mode opens the requested note without selecting its folder", () => {
|
|
const markup = renderNotes(
|
|
[
|
|
note(),
|
|
note({
|
|
id: "note-2",
|
|
title: "Deploy overlay",
|
|
content: "Deploy overlay content",
|
|
group: "Ops",
|
|
order: 2000,
|
|
}),
|
|
],
|
|
"sidebar",
|
|
["Ops"],
|
|
"note-2",
|
|
);
|
|
|
|
assert.match(markup, /Deploy overlay/);
|
|
assert.ok(
|
|
markup.includes('data-notes-editor-loading="true"')
|
|
|| markup.includes("Deploy overlay content")
|
|
|| markup.includes("editable markdown"),
|
|
"openNoteId should mount the sidebar note overlay editor",
|
|
);
|
|
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
|
|
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
|
|
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
|
|
});
|
|
|
|
test("NotesManager can re-open the same sidebar note when the request id changes", () => {
|
|
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
|
|
|
assert.match(source, /openNoteRequestId\?: number \| null/);
|
|
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
|
|
});
|