From 1055c61a867f14c6be3174c1d8ade9ca83718a41 Mon Sep 17 00:00:00 2001 From: Davi Date: Tue, 28 Apr 2026 18:29:10 -0300 Subject: [PATCH] feat: add lightweight multi-language snippet system --- src/lib/editorManager.js | 22 ++ src/lib/settings.js | 4 + src/lib/snippets.js | 536 +++++++++++++++++++++++++++++++++ src/settings/editorSettings.js | 98 ++++++ 4 files changed, 660 insertions(+) create mode 100644 src/lib/snippets.js diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index eb9e05152..fc0b99434 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -68,6 +68,10 @@ import quickTools from "components/quickTools"; import ScrollBar from "components/scrollbar"; import SideButton, { sideButtonContainer } from "components/sideButton"; import keyboardHandler, { keydownState } from "handlers/keyboard"; +import { + createSnippetCompletionSource, + expandSnippetShortcut, +} from "lib/snippets"; import EditorFile from "./editorFile"; import openFile from "./openFile"; import { addedFolder } from "./openFolder"; @@ -1155,6 +1159,24 @@ async function EditorManager($header, $body) { }); const exts = [...baseExtensions]; maybeAttachEmmetCompletions(exts, syntax); + const snippetLanguageId = getFileLanguageId(file); + exts.push( + EditorState.languageData.of(() => [ + { + autocomplete: createSnippetCompletionSource(snippetLanguageId), + }, + ]), + ); + exts.push( + Prec.high( + keymap.of([ + { + key: "Tab", + run: (view) => expandSnippetShortcut(view, snippetLanguageId), + }, + ]), + ), + ); try { const langExtFn = file.currentLanguageExtension; let initialLang = []; diff --git a/src/lib/settings.js b/src/lib/settings.js index 42b161921..7c046f129 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -191,6 +191,10 @@ class Settings { servers: {}, }, developerMode: false, + snippets: { + enabled: true, + user: {}, + }, shiftClickSelection: false, }; this.value = structuredClone(this.#defaultSettings); diff --git a/src/lib/snippets.js b/src/lib/snippets.js new file mode 100644 index 000000000..cb7483631 --- /dev/null +++ b/src/lib/snippets.js @@ -0,0 +1,536 @@ +import { snippet, snippetCompletion } from "@codemirror/autocomplete"; +import appSettings from "lib/settings"; + +const SUPPORTED_LANGUAGES = [ + "html", + "css", + "javascript", + "typescript", + "python", + "java", + "c", + "cpp", + "csharp", + "php", + "ruby", + "go", + "kotlin", + "swift", + "dart", + "rust", + "sql", + "bash", + "json", + "yaml", + "xml", + "markdown", + "lua", + "perl", + "r", + "scala", + "haskell", + "elixir", + "clojure", + "objectivec", + "groovy", + "powershell", + "shellscript", + "vbscript", + "assembly", + "matlab", + "julia", + "cobol", + "fortran", +]; + +const LANGUAGE_ALIASES = { + html: "html", + css: "css", + javascript: "javascript", + js: "javascript", + typescript: "typescript", + ts: "typescript", + python: "python", + py: "python", + java: "java", + c: "c", + cpp: "cpp", + "c++": "cpp", + c_cpp: "cpp", + csharp: "csharp", + "c#": "csharp", + cs: "csharp", + php: "php", + ruby: "ruby", + rb: "ruby", + go: "go", + golang: "go", + kotlin: "kotlin", + swift: "swift", + dart: "dart", + rust: "rust", + sql: "sql", + bash: "bash", + sh: "bash", + shell: "shellscript", + shellscript: "shellscript", + json: "json", + yaml: "yaml", + yml: "yaml", + xml: "xml", + markdown: "markdown", + md: "markdown", + lua: "lua", + perl: "perl", + pl: "perl", + r: "r", + scala: "scala", + haskell: "haskell", + hs: "haskell", + elixir: "elixir", + ex: "elixir", + clojure: "clojure", + objectivec: "objectivec", + "objective-c": "objectivec", + objc: "objectivec", + groovy: "groovy", + powershell: "powershell", + ps1: "powershell", + vbscript: "vbscript", + assembly: "assembly", + asm: "assembly", + matlab: "matlab", + julia: "julia", + cobol: "cobol", + fortran: "fortran", +}; + +const BUILTIN_SNIPPETS = { + html: [ + { + prefix: "html5", + body: '\n\n\n\t\n\t\n\t${1:Document}\n\n\n\t$0\n\n', + description: "HTML5 base", + }, + ], + css: [ + { + prefix: "flexc", + body: "display: flex;\njustify-content: ${1:center};\nalign-items: ${2:center};\n$0", + description: "Flex center", + }, + ], + javascript: [ + { + prefix: "fn", + body: "function ${1:name}(${2:params}) {\n\t$0\n}", + description: "Function", + }, + ], + typescript: [ + { + prefix: "iface", + body: "interface ${1:Name} {\n\t${2:key}: ${3:string};\n}\n$0", + description: "Interface", + }, + ], + python: [ + { + prefix: "def", + body: "def ${1:function_name}(${2:args}):\n\t$0", + description: "Function", + }, + ], + java: [ + { + prefix: "main", + body: "public static void main(String[] args) {\n\t$0\n}", + description: "Main method", + }, + ], + c: [ + { + prefix: "main", + body: "int main(void) {\n\t$0\n\treturn 0;\n}", + description: "Main function", + }, + ], + cpp: [ + { + prefix: "main", + body: "int main() {\n\t$0\n\treturn 0;\n}", + description: "Main function", + }, + ], + csharp: [ + { + prefix: "prop", + body: "public ${1:string} ${2:Name} { get; set; }$0", + description: "Auto property", + }, + ], + php: [ + { + prefix: "fn", + body: "function ${1:name}(${2:$args}) {\n\t$0\n}", + description: "Function", + }, + ], + ruby: [ + { + prefix: "def", + body: "def ${1:method_name}(${2:args})\n\t$0\nend", + description: "Method", + }, + ], + go: [ + { + prefix: "fn", + body: "func ${1:name}(${2:args}) ${3:error} {\n\t$0\n}", + description: "Function", + }, + ], + kotlin: [ + { + prefix: "fun", + body: "fun ${1:name}(${2:args}) {\n\t$0\n}", + description: "Function", + }, + ], + swift: [ + { + prefix: "func", + body: "func ${1:name}(${2:params}) {\n\t$0\n}", + description: "Function", + }, + ], + dart: [ + { + prefix: "main", + body: "void main() {\n\t$0\n}", + description: "Main function", + }, + ], + rust: [ + { + prefix: "fn", + body: "fn ${1:name}(${2:args}) {\n\t$0\n}", + description: "Function", + }, + ], + sql: [ + { + prefix: "sel", + body: "SELECT ${1:*}\nFROM ${2:table}\nWHERE ${3:condition};\n$0", + description: "Select query", + }, + ], + bash: [ + { + prefix: "if", + body: "if [ ${1:condition} ]; then\n\t$0\nfi", + description: "If block", + }, + ], + json: [ + { + prefix: "obj", + body: '{\n\t"${1:key}": "${2:value}"\n}$0', + description: "JSON object", + }, + ], + yaml: [ + { + prefix: "kv", + body: "${1:key}: ${2:value}\n$0", + description: "Key value", + }, + ], + xml: [ + { + prefix: "tag", + body: "<${1:tag}>${0}", + description: "XML tag", + }, + ], + markdown: [ + { + prefix: "link", + body: "[${1:text}](${2:url})$0", + description: "Markdown link", + }, + ], + lua: [ + { + prefix: "fn", + body: "function ${1:name}(${2:args})\n\t$0\nend", + description: "Function", + }, + ], + perl: [ + { + prefix: "sub", + body: "sub ${1:name} {\n\tmy (${2:@args}) = @_;\n\t$0\n}", + description: "Subroutine", + }, + ], + r: [ + { + prefix: "fn", + body: "${1:name} <- function(${2:args}) {\n\t$0\n}", + description: "Function", + }, + ], + scala: [ + { + prefix: "def", + body: "def ${1:name}(${2:args}): ${3:Unit} = {\n\t$0\n}", + description: "Method", + }, + ], + haskell: [ + { + prefix: "fn", + body: "${1:name} :: ${2:a -> b}\n${1:name} ${3:x} = $0", + description: "Function", + }, + ], + elixir: [ + { + prefix: "def", + body: "def ${1:name}(${2:args}) do\n\t$0\nend", + description: "Function", + }, + ], + clojure: [ + { + prefix: "defn", + body: "(defn ${1:name} [${2:args}]\n\t$0)", + description: "Function", + }, + ], + objectivec: [ + { + prefix: "meth", + body: "- (${1:void})${2:methodName} {\n\t$0\n}", + description: "Method", + }, + ], + groovy: [ + { + prefix: "def", + body: "def ${1:name}(${2:args}) {\n\t$0\n}", + description: "Method", + }, + ], + powershell: [ + { + prefix: "fn", + body: "function ${1:Name} {\n\tparam(${2:$value})\n\t$0\n}", + description: "Function", + }, + ], + shellscript: [ + { + prefix: "main", + body: 'main() {\n\t$0\n}\n\nmain "$@"', + description: "Main wrapper", + }, + ], + vbscript: [ + { + prefix: "sub", + body: "Sub ${1:Name}()\n\t$0\nEnd Sub", + description: "Subroutine", + }, + ], + assembly: [ + { + prefix: "proc", + body: "${1:label}:\n\t$0\n\tret", + description: "Procedure", + }, + ], + matlab: [ + { + prefix: "fn", + body: "function ${1:out} = ${2:name}(${3:in})\n\t$0\nend", + description: "Function", + }, + ], + julia: [ + { + prefix: "fn", + body: "function ${1:name}(${2:args})\n\t$0\nend", + description: "Function", + }, + ], + cobol: [ + { + prefix: "prog", + body: "IDENTIFICATION DIVISION.\nPROGRAM-ID. ${1:HELLO}.\nPROCEDURE DIVISION.\n\t$0\n\tSTOP RUN.", + description: "Program skeleton", + }, + ], + fortran: [ + { + prefix: "prog", + body: "program ${1:main}\n\timplicit none\n\t$0\nend program ${1:main}", + description: "Program skeleton", + }, + ], +}; + +function normalizeSnippetLanguage(languageId) { + if (!languageId) return "plaintext"; + return ( + LANGUAGE_ALIASES[String(languageId).toLowerCase()] || + String(languageId).toLowerCase() + ); +} + +function normalizeSnippetsArray(value) { + if (!Array.isArray(value)) return []; + return value + .map((item) => ({ + prefix: String(item?.prefix || "").trim(), + body: String(item?.body || ""), + description: String(item?.description || ""), + })) + .filter((item) => item.prefix && item.body); +} + +export function getSnippetSettings() { + const current = appSettings.value?.snippets; + if (!current || typeof current !== "object") { + return { enabled: true, user: {} }; + } + return { + enabled: current.enabled !== false, + user: current.user && typeof current.user === "object" ? current.user : {}, + }; +} + +export function getSupportedSnippetLanguages() { + return [...SUPPORTED_LANGUAGES]; +} + +export function getSnippetsForLanguage(languageId) { + const normalized = normalizeSnippetLanguage(languageId); + const builtin = normalizeSnippetsArray(BUILTIN_SNIPPETS[normalized]); + const { user } = getSnippetSettings(); + const custom = normalizeSnippetsArray(user[normalized]); + return [...builtin, ...custom]; +} + +export function getUserSnippetsForLanguage(languageId) { + const normalized = normalizeSnippetLanguage(languageId); + const { user } = getSnippetSettings(); + return normalizeSnippetsArray(user[normalized]); +} + +export function setUserSnippetsForLanguage(languageId, snippets) { + const normalized = normalizeSnippetLanguage(languageId); + const settings = getSnippetSettings(); + appSettings.update({ + snippets: { + ...settings, + user: { + ...settings.user, + [normalized]: normalizeSnippetsArray(snippets), + }, + }, + }); +} + +export function setSnippetSystemEnabled(enabled) { + const settings = getSnippetSettings(); + appSettings.update({ + snippets: { + ...settings, + enabled: !!enabled, + }, + }); +} + +export function exportUserSnippetsAsJson() { + const settings = getSnippetSettings(); + return JSON.stringify( + { + version: 1, + languages: settings.user, + }, + null, + 2, + ); +} + +export function importUserSnippetsFromJson(jsonString) { + const parsed = JSON.parse(jsonString); + const languages = parsed?.languages; + if (!languages || typeof languages !== "object") { + throw new Error("Invalid snippets JSON. Expected: { languages: { ... } }"); + } + const normalized = {}; + for (const [lang, snippets] of Object.entries(languages)) { + const key = normalizeSnippetLanguage(lang); + normalized[key] = normalizeSnippetsArray(snippets); + } + const settings = getSnippetSettings(); + appSettings.update({ + snippets: { + ...settings, + user: normalized, + }, + }); +} + +function getApplicableSnippets(languageId) { + const settings = getSnippetSettings(); + if (!settings.enabled) return []; + return getSnippetsForLanguage(languageId); +} + +export function createSnippetCompletionSource(languageId) { + return (context) => { + const snippets = getApplicableSnippets(languageId); + if (!snippets.length) return null; + const word = context.matchBefore(/[A-Za-z_][A-Za-z0-9_-]*/); + if (!context.explicit && !word) return null; + const from = word ? word.from : context.pos; + const typed = word ? word.text.toLowerCase() : ""; + const options = snippets + .filter((item) => !typed || item.prefix.toLowerCase().startsWith(typed)) + .map((item) => + snippetCompletion(item.body, { + label: item.prefix, + detail: item.description || "Snippet", + type: "keyword", + }), + ); + if (!options.length) return null; + return { + from, + options, + validFor: /[A-Za-z0-9_-]*/, + }; + }; +} + +export function expandSnippetShortcut(view, languageId) { + const snippets = getApplicableSnippets(languageId); + if (!snippets.length) return false; + const { from, to, empty } = view.state.selection.main; + if (!empty || from !== to) return false; + const pos = from; + const line = view.state.doc.lineAt(pos); + const leftText = line.text.slice(0, pos - line.from); + const match = leftText.match(/([A-Za-z_][A-Za-z0-9_-]*)$/); + if (!match) return false; + const token = match[1]; + const snippetDef = snippets.find((item) => item.prefix === token); + if (!snippetDef) return false; + const applySnippet = snippet(snippetDef.body); + applySnippet(view, null, pos - token.length, pos); + return true; +} diff --git a/src/settings/editorSettings.js b/src/settings/editorSettings.js index 2a466c7b4..eec9ca2bf 100644 --- a/src/settings/editorSettings.js +++ b/src/settings/editorSettings.js @@ -1,7 +1,19 @@ import settingsPage from "components/settingsPage"; +import toast from "components/toast"; +import prompt from "dialogs/prompt"; +import select from "dialogs/select"; import constants from "lib/constants"; import fonts from "lib/fonts"; import appSettings from "lib/settings"; +import { + exportUserSnippetsAsJson, + getSnippetSettings, + getSupportedSnippetLanguages, + getUserSnippetsForLanguage, + importUserSnippetsFromJson, + setSnippetSystemEnabled, + setUserSnippetsForLanguage, +} from "lib/snippets"; import scrollSettings from "./scrollSettings"; export default function editorSettings() { @@ -126,6 +138,13 @@ export default function editorSettings() { info: strings["settings-info-editor-live-autocomplete"], category: categories.assistance, }, + { + key: "snippet-manager", + text: "Snippet manager", + info: "Create, edit, import, and export snippets in JSON.", + category: categories.assistance, + chevron: true, + }, { key: "autoCloseTags", text: strings["auto close tags"], @@ -234,4 +253,83 @@ export default function editorSettings() { break; } } + + async function openSnippetManager() { + const menuItems = [ + { + value: "toggle", + text: `Snippet system: ${getSnippetSettings().enabled ? "On" : "Off"}`, + }, + { value: "language", text: "Edit snippets by language" }, + { value: "import", text: "Import snippets JSON" }, + { value: "export", text: "Export snippets JSON" }, + ]; + let action = null; + try { + action = await select("Snippet manager", menuItems); + } catch (_) { + return; + } + if (!action) return; + + if (action === "toggle") { + const enabled = !getSnippetSettings().enabled; + setSnippetSystemEnabled(enabled); + toast(`${enabled ? "Enabled" : "Disabled"} snippets.`); + return; + } + + if (action === "language") { + const language = await select( + "Choose language", + getSupportedSnippetLanguages().map((lang) => ({ + value: lang, + text: lang, + })), + ); + if (!language) return; + const current = getUserSnippetsForLanguage(language); + const initial = JSON.stringify(current, null, 2); + const result = await prompt( + `Edit snippets for ${language} as JSON array [{prefix, body, description}]`, + initial, + "textarea", + { capitalize: false }, + ); + if (result === null) return; + try { + const parsed = JSON.parse(result); + if (!Array.isArray(parsed)) throw new Error("Expected array"); + setUserSnippetsForLanguage(language, parsed); + toast(`Saved ${language} snippets.`); + } catch (error) { + toast(error?.message || "Invalid JSON"); + } + return; + } + + if (action === "import") { + const input = await prompt( + 'Paste snippets JSON: {\n "languages": { ... }\n}', + "", + "textarea", + { capitalize: false }, + ); + if (input === null) return; + try { + importUserSnippetsFromJson(input); + toast("Snippets imported."); + } catch (error) { + toast(error?.message || "Invalid JSON"); + } + return; + } + + if (action === "export") { + const output = exportUserSnippetsAsJson(); + await prompt("Copy your snippets JSON", output, "textarea", { + capitalize: false, + }); + } + } }