Skip to content

Commit 1c64a34

Browse files
authored
Extend snippet support to Markdown and code notes (#10025)
2 parents 5eee09d + ef65b67 commit 1c64a34

17 files changed

Lines changed: 607 additions & 8 deletions

File tree

apps/client/src/stylesheets/theme-next-dark.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
--badge-temporaraily-editable-background-color: #297331;
219219
--badge-read-only-background-color: #af4340;
220220
--badge-share-background-color: #4d4d4d;
221+
--badge-snippet-background-color: #4d4d4d;
221222
--badge-clipped-note-background-color: #295773;
222223
--badge-doc-url-background-color: #1e5c42;
223224
--badge-execute-background-color: #604180;

apps/client/src/stylesheets/theme-next-light.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@
210210
--badge-temporaraily-editable-background-color: #35a64c;
211211
--badge-read-only-background-color: #c8302c;
212212
--badge-share-background-color: #6b6b6b;
213+
--badge-snippet-background-color: #6b6b6b;
213214
--badge-clipped-note-background-color: #2284c0;
214215
--badge-doc-url-background-color: #2e7d5e;
215216
--badge-execute-background-color: #7b47af;

apps/client/src/translations/en/translation.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2552,6 +2552,12 @@
25522552
"bookmark_buttons": {
25532553
"bookmarks": "Bookmarks"
25542554
},
2555+
"snippet_badge": {
2556+
"label": "{{type}} snippet",
2557+
"type_text": "Text",
2558+
"type_code": "Code",
2559+
"tooltip": "A reusable piece of text or code, meant to be inserted into the editor (from the snippet menu in the toolbar, or via a slash command)."
2560+
},
25552561
"active_content_badges": {
25562562
"type_icon_pack": "Icon pack",
25572563
"type_backend_script": "Backend script",

apps/client/src/widgets/layout/NoteBadges.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
&.clipped-note-badge {--color: var(--badge-clipped-note-background-color);}
2323
&.doc-url-badge {--color: var(--badge-doc-url-background-color);}
2424
&.execute-badge {--color: var(--badge-execute-background-color);}
25+
&.snippet-badge {--color: var(--badge-snippet-background-color);}
2526
&.save-status-badge {
2627
--default-opacity: 0.4;
2728
opacity: var(--default-opacity);

apps/client/src/widgets/layout/NoteBadges.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useGetContextDataFrom, useIsNoteReadOnly, useNoteContext, useNoteLabel,
1212
import { useShareState } from "../ribbon/BasicPropertiesTab";
1313
import { useShareInfo } from "../shared_info";
1414
import { ActiveContentBadges } from "./ActiveContentBadges";
15+
import { SnippetBadge } from "./SnippetBadge";
1516

1617
export default function NoteBadges() {
1718
return (
@@ -21,6 +22,7 @@ export default function NoteBadges() {
2122
<ShareBadge />
2223
<ClippedNoteBadge />
2324
<ExecuteBadge />
25+
<SnippetBadge />
2426
<ActiveContentBadges />
2527
</div>
2628
);
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
3+
import FNote from "../../entities/fnote";
4+
import attributes from "../../services/attributes";
5+
import { t } from "../../services/i18n";
6+
import mimeTypesService from "../../services/mime_types";
7+
import { Badge } from "../react/Badge";
8+
import { useNoteContext, useNoteProperty, useTriliumEvent } from "../react/hooks";
9+
10+
interface SnippetInfo {
11+
/** Human-readable kind of snippet, e.g. "Text", "Markdown", "CSS". */
12+
typeName: string;
13+
icon: string;
14+
}
15+
16+
/**
17+
* Informational badge marking a note as a reusable snippet and naming its kind — e.g. "CSS snippet"
18+
* rather than just a generic code note, and "Text snippet" so a rich-text snippet is distinguishable
19+
* from an ordinary text note. Read-only; enabling/disabling is not offered.
20+
*/
21+
export function SnippetBadge() {
22+
const { note } = useNoteContext();
23+
const info = useSnippetInfo(note);
24+
25+
return (info &&
26+
<Badge
27+
className="snippet-badge"
28+
icon={info.icon}
29+
text={t("snippet_badge.label", { type: info.typeName })}
30+
tooltip={t("snippet_badge.tooltip")}
31+
/>
32+
);
33+
}
34+
35+
function useSnippetInfo(note: FNote | null | undefined) {
36+
const [ info, setInfo ] = useState<SnippetInfo | null>(null);
37+
const noteType = useNoteProperty(note, "type");
38+
const noteMime = useNoteProperty(note, "mime");
39+
40+
function refresh() {
41+
if (!note || !(note.hasLabel("snippet") || note.hasLabel("textSnippet"))) {
42+
setInfo(null);
43+
return;
44+
}
45+
46+
// Rich-text snippets are plain text notes; name them "Text" rather than via their text/html MIME.
47+
if (note.type === "text") {
48+
setInfo({ typeName: t("snippet_badge.type_text"), icon: "bx bx-align-left" });
49+
return;
50+
}
51+
52+
// A generic (plain-text) or unrecognized code snippet reads as "Code" — clearer than "Plain
53+
// text" (which collides with "Text") and matching the "Code snippet" template. A recognized
54+
// language keeps its own name and icon (e.g. "CSS snippet").
55+
const mimeType = mimeTypesService.getMimeTypes().find((mt) => mt.mime === note.mime);
56+
if (!mimeType || note.mime === "text/plain") {
57+
setInfo({ typeName: t("snippet_badge.type_code"), icon: "bx bx-code" });
58+
return;
59+
}
60+
setInfo({ typeName: mimeType.title, icon: mimeType.icon ?? "bx bx-code" });
61+
}
62+
63+
useEffect(refresh, [ note, noteType, noteMime ]);
64+
65+
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
66+
if (loadResults.getAttributeRows().some((attr) => attributes.isAffecting(attr, note))) {
67+
refresh();
68+
}
69+
});
70+
71+
return info;
72+
}

apps/client/src/widgets/type_widgets/code/Code.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import "./code.css";
33
import { default as VanillaCodeMirror, getThemeById } from "@triliumnext/codemirror";
44
import { NoteType } from "@triliumnext/commons";
55
import { Ref } from "preact";
6-
import { useEffect, useRef, useState } from "preact/hooks";
6+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
77

88
import { CommandListenerData } from "../../../components/app_context";
99
import FNote from "../../../entities/fnote";
@@ -14,6 +14,7 @@ import { refToJQuerySelector } from "../../react/react_utils";
1414
import { CODE_THEME_DEFAULT_PREFIX as DEFAULT_PREFIX } from "../constants";
1515
import { TypeWidgetProps } from "../type_widget";
1616
import CodeMirror, { CodeMirrorProps } from "./CodeMirror";
17+
import { useSnippetSlashCommands } from "./snippets";
1718

1819
interface CodeEditorProps {
1920
/** By default, the code editor will try to match the color of the scrolling container to match the one from the theme for a full-screen experience. If the editor is embedded, it makes sense not to have this behaviour. */
@@ -87,11 +88,13 @@ function formatViewSource(note: FNote, content: string) {
8788
export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentComponent, updateInterval, noteType = "code", onContentChanged, dataSaved, placeholder, editorRef: externalEditorRef, ...editorProps }: EditableCodeProps) {
8889
const editorRef = useRef<VanillaCodeMirror>(null);
8990
const containerRef = useRef<HTMLPreElement>(null);
90-
const combinedEditorRef = (view: VanillaCodeMirror | null) => {
91+
const [ editorView, setEditorView ] = useState<VanillaCodeMirror | null>(null);
92+
const combinedEditorRef = useCallback((view: VanillaCodeMirror | null) => {
9193
editorRef.current = view;
94+
setEditorView(view);
9295
if (typeof externalEditorRef === "function") externalEditorRef(view);
9396
else if (externalEditorRef) externalEditorRef.current = view;
94-
};
97+
}, [externalEditorRef]);
9598
const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled");
9699
const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth");
97100
const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs");
@@ -126,6 +129,16 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC
126129

127130
useKeyboardShortcuts("code-detail", containerRef, parentComponent, ntxId);
128131

132+
// Code snippets (#snippet notes with a matching MIME) as `/snippet:<name>` slash commands.
133+
// Disabled for Markdown notes, whose editor provides its own combined slash-command menu.
134+
useSnippetSlashCommands(
135+
editorView,
136+
(candidate) => candidate.type === "code" && (candidate.mime === note.mime || candidate.mime === "text/plain"),
137+
note.mime,
138+
!note.isMarkdown(),
139+
note.noteId
140+
);
141+
129142
return (
130143
<CodeEditor
131144
ntxId={ntxId} parentComponent={parentComponent}

apps/client/src/widgets/type_widgets/code/Markdown.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import SplitEditor from "../helpers/SplitEditor";
3030
import SAMPLE_DIAGRAMS from "../mermaid/sample_diagrams";
3131
import { ReadOnlyTextContent } from "../text/ReadOnlyText";
3232
import { TypeWidgetProps } from "../type_widget";
33+
import { buildSnippetCompletions, SLASH_COMMAND_REGEX, useCodeSnippets } from "./snippets";
3334

3435
const marked = new Marked({ breaks: true, gfm: true });
3536

@@ -508,6 +509,12 @@ function useSlashCommands(parentComponent: TypeWidgetProps["parentComponent"], e
508509
// The user-configured todo task states (from the `_taskStates` subtree), loaded once.
509510
// Read inside the autocomplete closure, so `/todo:*` commands reflect the current config.
510511
const taskStatesRef = useRef<TaskStateDef[]>([]);
512+
// Markdown snippets (#snippet code notes with a markdown MIME) plus generic plain-text snippets,
513+
// inserted via `/snippet:<name>`. useCodeSnippets keeps the ref fresh so the menu reads the latest.
514+
const snippetsRef = useCodeSnippets(
515+
(candidate) => candidate.isMarkdown() || (candidate.type === "code" && candidate.mime === "text/plain"),
516+
"markdown"
517+
);
511518
useEffect(() => { noteRef.current = note; }, [note]);
512519
useEffect(() => { parentRef.current = parentComponent; }, [parentComponent]);
513520
useEffect(() => { void getTaskStateDefinitions().then((states) => { taskStatesRef.current = states; }); }, []);
@@ -518,7 +525,7 @@ function useSlashCommands(parentComponent: TypeWidgetProps["parentComponent"], e
518525
const ext = autocompletion({
519526
override: [(ctx) => {
520527
// `:` and `-` are allowed so `/todo:<state>` (e.g. `/todo:in-progress`) matches as one token.
521-
const match = ctx.matchBefore(/(?:^|(?<=\s))\/[\w:-]*/);
528+
const match = ctx.matchBefore(SLASH_COMMAND_REGEX);
522529
if (!match) return null;
523530

524531
// Suppress slash menu inside fenced/indented code blocks and inline code spans —
@@ -675,7 +682,8 @@ function useSlashCommands(parentComponent: TypeWidgetProps["parentComponent"], e
675682
view.dispatch({ changes: { from, to, insert } });
676683
}
677684
};
678-
})
685+
}),
686+
...buildSnippetCompletions(snippetsRef.current.filter((snippet) => snippet.noteId !== noteRef.current.noteId))
679687
]
680688
};
681689
}],
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { Completion } from "@codemirror/autocomplete";
2+
import type { EditorView } from "@codemirror/view";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
import type FNote from "../../../entities/fnote";
6+
import type LoadResults from "../../../services/load_results";
7+
8+
vi.mock("../../../services/search.js", () => ({
9+
default: { searchForNotes: vi.fn() }
10+
}));
11+
12+
import search from "../../../services/search.js";
13+
import { buildSnippetCompletions, getCodeSnippets, isCodeSnippetChange } from "./snippets.js";
14+
15+
const logErrorMock = vi.fn();
16+
17+
interface NoteStub {
18+
noteId: string;
19+
title: string;
20+
type: string;
21+
mime: string;
22+
isArchived: boolean;
23+
isContentAvailable: () => boolean;
24+
getLabelValue: (name: string) => string | null;
25+
getContent: () => Promise<string | undefined>;
26+
}
27+
28+
function makeNote(overrides: Partial<NoteStub> = {}): FNote {
29+
return {
30+
noteId: "note",
31+
title: "Snippet",
32+
type: "code",
33+
mime: "text/css",
34+
isArchived: false,
35+
isContentAvailable: () => true,
36+
getLabelValue: () => null,
37+
getContent: async () => "body",
38+
...overrides
39+
} as unknown as FNote;
40+
}
41+
42+
function makeLoadResults(opts: {
43+
attrs?: { type: string; name?: string; value?: string }[];
44+
noteIds?: string[];
45+
} = {}): LoadResults {
46+
return {
47+
getAttributeRows: () => opts.attrs ?? [],
48+
getNoteIds: () => opts.noteIds ?? []
49+
} as unknown as LoadResults;
50+
}
51+
52+
describe("buildSnippetCompletions", () => {
53+
it("formats labels with/without a description and inserts content on apply", () => {
54+
const [withDesc, withoutDesc] = buildSnippetCompletions([
55+
{ noteId: "a", title: "Header", description: "Page header", content: "# Header\n" },
56+
{ noteId: "b", title: "Footer", content: "the footer" }
57+
]);
58+
59+
expect(withDesc?.label).toBe("/snippet:Header - Page header");
60+
expect(withoutDesc?.label).toBe("/snippet:Footer");
61+
62+
// Applying replaces the typed [from, to] token with the content, caret at its end.
63+
const apply = withoutDesc?.apply;
64+
expect(typeof apply).toBe("function");
65+
if (typeof apply !== "function") return;
66+
67+
const dispatched: unknown[] = [];
68+
const view = { dispatch: (tx: unknown) => dispatched.push(tx) } as unknown as EditorView;
69+
apply(view, {} as Completion, 3, 11);
70+
71+
expect(dispatched).toEqual([{
72+
changes: { from: 3, to: 11, insert: "the footer" },
73+
selection: { anchor: 3 + "the footer".length }
74+
}]);
75+
});
76+
});
77+
78+
describe("isCodeSnippetChange", () => {
79+
const known = new Set(["s1"]);
80+
81+
it("reacts to snippet label/relation changes and to edits of a known snippet", () => {
82+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "snippet" }] }), known)).toBe(true);
83+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "snippetDescription" }] }), known)).toBe(true);
84+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_markdown_snippet" }] }), known)).toBe(true);
85+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_code_snippet" }] }), known)).toBe(true);
86+
// Content/title edit of a snippet the caller already holds.
87+
expect(isCodeSnippetChange(makeLoadResults({ noteIds: ["s1"] }), known)).toBe(true);
88+
});
89+
90+
it("ignores unrelated attribute and note changes", () => {
91+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "label", name: "color" }], noteIds: ["other"] }), known)).toBe(false);
92+
expect(isCodeSnippetChange(makeLoadResults({ attrs: [{ type: "relation", value: "_template_table" }] }), known)).toBe(false);
93+
expect(isCodeSnippetChange(makeLoadResults(), known)).toBe(false);
94+
});
95+
});
96+
97+
describe("getCodeSnippets", () => {
98+
beforeEach(() => {
99+
vi.mocked(search.searchForNotes).mockReset();
100+
logErrorMock.mockReset();
101+
vi.stubGlobal("logError", logErrorMock);
102+
});
103+
104+
it("maps matching notes, reading the description from #snippetDescription", async () => {
105+
vi.mocked(search.searchForNotes).mockResolvedValue([
106+
makeNote({
107+
noteId: "css1",
108+
title: "Reset",
109+
mime: "text/css",
110+
getContent: async () => ".x {}",
111+
getLabelValue: (name) => (name === "snippetDescription" ? "CSS reset" : null)
112+
})
113+
]);
114+
115+
const result = await getCodeSnippets((note) => note.mime === "text/css");
116+
117+
expect(result).toEqual([{ noteId: "css1", title: "Reset", description: "CSS reset", content: ".x {}" }]);
118+
});
119+
120+
it("drops notes failing the predicate, archived notes, and content-unavailable (protected) notes", async () => {
121+
vi.mocked(search.searchForNotes).mockResolvedValue([
122+
makeNote({ noteId: "keep", mime: "text/css" }),
123+
makeNote({ noteId: "wrong-mime", mime: "text/x-markdown" }),
124+
makeNote({ noteId: "archived", mime: "text/css", isArchived: true }),
125+
makeNote({ noteId: "protected", mime: "text/css", isContentAvailable: () => false })
126+
]);
127+
128+
const result = await getCodeSnippets((note) => note.mime === "text/css");
129+
130+
expect(result.map((snippet) => snippet.noteId)).toEqual(["keep"]);
131+
});
132+
133+
it("defaults a missing description to undefined and missing content to an empty string", async () => {
134+
vi.mocked(search.searchForNotes).mockResolvedValue([
135+
makeNote({ getLabelValue: () => null, getContent: async () => undefined })
136+
]);
137+
138+
const [snippet] = await getCodeSnippets(() => true);
139+
140+
expect(snippet?.description).toBeUndefined();
141+
expect(snippet?.content).toBe("");
142+
});
143+
144+
it("returns an empty list and logs when loading fails", async () => {
145+
vi.mocked(search.searchForNotes).mockRejectedValue(new Error("boom"));
146+
147+
expect(await getCodeSnippets(() => true)).toEqual([]);
148+
expect(logErrorMock).toHaveBeenCalled();
149+
});
150+
});

0 commit comments

Comments
 (0)