From 1055c61a867f14c6be3174c1d8ade9ca83718a41 Mon Sep 17 00:00:00 2001 From: Davi Date: Tue, 28 Apr 2026 18:29:10 -0300 Subject: [PATCH 1/6] 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, + }); + } + } } From 56c739bcc3d0a105b732803b70f0d7c3be2028c4 Mon Sep 17 00:00:00 2001 From: Emmanuel Lobo <76094069+UnschooledGamer@users.noreply.github.com> Date: Sat, 9 May 2026 14:25:28 +0530 Subject: [PATCH 2/6] Update src/settings/editorSettings.js Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/settings/editorSettings.js | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/settings/editorSettings.js b/src/settings/editorSettings.js index eec9ca2bf..67da0fc52 100644 --- a/src/settings/editorSettings.js +++ b/src/settings/editorSettings.js @@ -276,23 +276,20 @@ export default function editorSettings() { 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, - })), - ); + let language = null; + try { + language = await select( + "Choose language", + getSupportedSnippetLanguages().map((lang) => ({ + value: lang, + text: lang, + })), + ); + } catch (_) { + return; + } 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 }, ); From 0be6e9503b8a4f76109b617a63056102b6bd908c Mon Sep 17 00:00:00 2001 From: Davi Date: Sat, 9 May 2026 09:22:01 -0300 Subject: [PATCH 3/6] fix(snippets): wire manager action and guard language picker cancel --- src/lib/editorManager.js | 22 ++ src/lib/settings.js | 4 + src/lib/snippets.js | 536 +++++++++++++++++++++++++++++++++ src/settings/editorSettings.js | 108 +++++++ 4 files changed, 670 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..9e9ea245a 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"], @@ -226,6 +245,11 @@ export default function editorSettings() { case "editorFont": fonts.setFont(value); + return; + + case "snippet-manager": + openSnippetManager(); + return; default: appSettings.update({ @@ -234,4 +258,88 @@ 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") { + let language = null; + try { + language = await select( + "Choose language", + getSupportedSnippetLanguages().map((lang) => ({ + value: lang, + text: lang, + })), + ); + } catch (_) { + return; + } + 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, + }); + } + } } From a4f450227aa0271fcc0688445383d7106cc90e0a Mon Sep 17 00:00:00 2001 From: Davi Date: Sat, 9 May 2026 10:07:38 -0300 Subject: [PATCH 4/6] Add GitHub Actions workflow for PR checks --- .github/workflows/valid_pr.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/valid_pr.yml diff --git a/.github/workflows/valid_pr.yml b/.github/workflows/valid_pr.yml new file mode 100644 index 000000000..bcaf739a3 --- /dev/null +++ b/.github/workflows/valid_pr.yml @@ -0,0 +1,29 @@ +name: PR Checks + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + checks: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Biome check + run: npx biome check src/lib/editorManager.js src/settings/editorSettings.js src/lib/snippets.js src/lib/settings.js + + - name: Typecheck + run: npm run typecheck From e78800fed0c70a34fed311d4f3d1de36fdaf99f1 Mon Sep 17 00:00:00 2001 From: Davi Date: Sat, 9 May 2026 10:18:42 -0300 Subject: [PATCH 5/6] Set LF line endings for shell scripts Updated .gitattributes to ensure shell scripts use LF line endings. --- .gitattributes | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.gitattributes b/.gitattributes index dfdb8b771..95a39f9be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,33 @@ +# Normalize all text files to LF in the repository +* text=auto eol=lf + +# Common text/code files +*.js text eol=lf +*.ts text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.css text eol=lf +*.html text eol=lf +*.xml text eol=lf +*.sh text eol=lf + +# Windows scripts keep CRLF +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.keystore binary +*.jks binary +*.jar binary +*.apk binary + +# Change *.sh text eol=lf From d36d6bfb82de175df9e48af6599bc1c7f929d3f8 Mon Sep 17 00:00:00 2001 From: Davi Date: Sat, 9 May 2026 10:19:34 -0300 Subject: [PATCH 6/6] Update CI workflow for PR checks and linting --- .github/workflows/valid_pr.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/valid_pr.yml b/.github/workflows/valid_pr.yml index bcaf739a3..e620cfe24 100644 --- a/.github/workflows/valid_pr.yml +++ b/.github/workflows/valid_pr.yml @@ -1,19 +1,31 @@ -name: PR Checks +name: CI on: pull_request: branches: [main] + push: + branches: [main, work] workflow_dispatch: +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: - checks: - runs-on: ubuntu-latest + quality: + name: Linting and formatting + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Setup Node + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 @@ -22,8 +34,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Biome check - run: npx biome check src/lib/editorManager.js src/settings/editorSettings.js src/lib/snippets.js src/lib/settings.js + - name: Run Biome CI + run: npx biome ci . - name: Typecheck run: npm run typecheck