diff --git a/CLAUDE.md b/CLAUDE.md index be13693257..0f095c4618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ - **`src/cacheManifest.json`** is a generated build artifact (gitignored, produced by `gulpfile.js/index.js`). It lists files + hashes for the service-worker cache. Never hand-edit or commit it — it is regenerated by the build, so edits are overwritten and won't be tracked anyway. When you add/remove/rename source files, just let the build regenerate it. ## Translations / i18n -- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. +- All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. This applies only to genuinely translatable natural-language text. Content that must render identically in every locale — literal code syntax, keyword/identifier examples, brand names — is not translatable and must NOT go in strings.js; keep it as a local constant in the source file instead. Reason: `src/nls/root/strings.js` values are sent as-is to an automated AI translation pass (`gulpfile.js/translateStrings.js`) with no awareness that a given string represents code rather than prose, so a translatable-looking word embedded in code syntax (e.g. `name` in `function name() {...}`) can get mistranslated into garbled pseudo-code in other locales. - Use `const Strings = require("strings");` then `Strings.KEY_NAME`. - For parameterized strings use `StringUtils.format(Strings.KEY, arg0, arg1)` with `{0}`, `{1}` placeholders. - Keys use UPPER_SNAKE_CASE grouped by feature prefix (e.g. `AI_CHAT_*`). diff --git a/src/editor/Editor.js b/src/editor/Editor.js index b105553689..8e0b9b2b84 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -1396,6 +1396,37 @@ define(function (require, exports, module) { }; } + /** + * Mark option for a subdued outline box, used to show every remaining stop of an active + * snippet/tab-stop session (see editor/TabstopManager.js) so the user can see at a glance how + * many fields are left and where, even for the ones they haven't tabbed to yet. + */ + function getMarkOptionTabstopOutline() { + return { + className: "editor-text-tabstop-outline", + startStyle: "editor-text-tabstop-outline-left", + endStyle: "editor-text-tabstop-outline-right", + clearWhenEmpty: false, + inclusiveLeft: true, + inclusiveRight: true + }; + } + + /** + * Mark option for the bold/active variant of the above, layered on top of it for whichever stop + * is currently selected in an active snippet/tab-stop session. + */ + function getMarkOptionTabstopOutlineActive() { + return { + className: "editor-text-tabstop-outline-active", + startStyle: "editor-text-tabstop-outline-active-left", + endStyle: "editor-text-tabstop-outline-active-right", + clearWhenEmpty: false, + inclusiveLeft: true, + inclusiveRight: true + }; + } + /** * Mark option to underline errors. */ @@ -1430,6 +1461,8 @@ define(function (require, exports, module) { * Mark option for renaming outlines. */ Editor.getMarkOptionRenameOutline = getMarkOptionRenameOutline; + Editor.getMarkOptionTabstopOutline = getMarkOptionTabstopOutline; + Editor.getMarkOptionTabstopOutlineActive = getMarkOptionTabstopOutlineActive; /** * Can be used to mark a range of text with a specific CSS class name. cursorFrom and cursorTo should be {line, ch} diff --git a/src/editor/TabstopManager.js b/src/editor/TabstopManager.js index ee4907d5fa..5fd62842ad 100644 --- a/src/editor/TabstopManager.js +++ b/src/editor/TabstopManager.js @@ -35,11 +35,19 @@ * stop, and (when there is more than one stop) starts a Tab-navigable session backed by markers * so the stops follow any later edits (e.g. an auto-import line inserted above). * - * NOTE: this is currently wired only into the LSP completion path (languageTools/DefaultProviders). - * The Emmet expander (HTMLCodeHints) and the custom-snippets feature have their own stable cursor - * handling and were intentionally left untouched; they can migrate onto this manager in future. + * Used by the LSP completion path (languageTools/DefaultProviders), DocCommentHints, and Custom + * Snippets (extensionsIntegrated/CustomSnippets/snippetCursorManager.js). The Emmet expander + * (HTMLCodeHints) still has its own separate cursor handling. + * + * While a Tab-navigable session is active, every remaining stop gets a subdued outline box (see + * Editor.getMarkOptionTabstopOutline) so the user can see at a glance how many fields are left and + * where, with the currently-selected one getting a bolder "active" outline layered on top (see + * Editor.getMarkOptionTabstopOutlineActive) - matches the visual language RenameIdentifier.js already + * uses for its own outline box. Zero-width stops (a bare `$N`/`$0` with no default text - just a + * caret position, no marker range) don't get an outline, since there's no span to box. */ define(function (require, exports, module) { + const Editor = require("editor/Editor").Editor; /** * Expand an LSP snippet into plain text plus the list of tab-stops. @@ -174,7 +182,7 @@ define(function (require, exports, module) { // ---- Tab-navigation session ---------------------------------------------------------------- - var _session = null; // { editor, markers: [marker], index, keymap } + var _session = null; // { editor, markers: [marker], index, keymap, activeOutlineMarker } function _clearSession() { if (!_session) { @@ -185,10 +193,49 @@ define(function (require, exports, module) { session.markers.forEach(function (m) { m.clear(); }); + if (session.activeOutlineMarker) { + session.activeOutlineMarker.clear(); + } session.editor._codeMirror.removeKeyMap(session.keymap); session.editor.off(".tabstop"); } + /** + * @param {{line: number, ch: number}} pos - a document position + * @return {boolean} true if `pos` falls within the line span currently covered by the active + * session's markers (i.e. the snippet the user is still tabbing through) + */ + function _isWithinSessionBounds(pos) { + var minLine = Infinity, + maxLine = -Infinity; + _session.markers.forEach(function (m) { + var r = _markerRange(m); + if (r) { + minLine = Math.min(minLine, r.from.line); + maxLine = Math.max(maxLine, r.to.line); + } + }); + if (minLine === Infinity) { + return false; // no markers left resolve-able + } + return pos.line >= minLine && pos.line <= maxLine; + } + + /** + * Ends the session as soon as the user's cursor leaves the snippet's lines (e.g. clicks + * elsewhere to fix something unrelated) or a multi-cursor selection is made - matches standard + * editor behavior (VS Code et al.) and avoids a stray later Tab press unexpectedly jumping the + * cursor back into a snippet the user has moved on from. + */ + function _handleCursorActivity(event, editor) { + if (!_session || _session.editor !== editor) { + return; + } + if (editor.getSelections().length > 1 || !_isWithinSessionBounds(editor.getCursorPos())) { + _clearSession(); + } + } + /** * Resolve a marker (markText range or bookmark) to a {from, to} document range, or null if the * marker no longer exists in the document. @@ -215,10 +262,28 @@ define(function (require, exports, module) { } _session.index = index; _session.editor.setSelection(range.from, range.to); + + // swap the bold "active" outline onto whichever stop we just landed on - only meaningful for + // a real span (a bare $N/$0 with no default text is a zero-width caret, nothing to box) + if (_session.activeOutlineMarker) { + _session.activeOutlineMarker.clear(); + _session.activeOutlineMarker = null; + } + if (range.from.line !== range.to.line || range.from.ch !== range.to.ch) { + _session.activeOutlineMarker = _session.editor.markText( + "tabstop-active", range.from, range.to, Editor.getMarkOptionTabstopOutlineActive()); + } return true; } function _gotoNext() { + if (!_session) { + // no-op: goToNextStop/goToPreviousStop are exported as public API (see bottom of file) + // for callers like Custom Snippets' snippetCursorManager.js to drive navigation directly, + // not only via the CodeMirror keymap installed below (which only exists while a session is + // active, so it could never reach this function with no session) - a direct caller could. + return; + } // Move forward through the stops; leaving the last one ends the session (caret stays put). for (var i = _session.index + 1; i < _session.markers.length; i++) { if (_selectStop(i)) { @@ -233,6 +298,9 @@ define(function (require, exports, module) { } function _gotoPrev() { + if (!_session) { + return; // see _gotoNext's no-op comment - same reasoning applies here + } for (var i = _session.index - 1; i >= 0; i--) { if (_selectStop(i)) { return; @@ -286,6 +354,11 @@ define(function (require, exports, module) { } // Multiple stops: lay down markers and start a Tab-navigable session. + // the subdued outline (visual only) is layered onto the SAME functional tracking options + // below by className/startStyle/endStyle alone - deliberately not spreading the whole helper + // object in, since its own inclusiveLeft/clearWhenEmpty differ from what marker TRACKING here + // actually needs (inclusiveLeft: false is what makes typing at a stop's start not stick to it). + var outlineOption = Editor.getMarkOptionTabstopOutline(); var markers = parsed.stops.map(function (stop) { var ms = posFromOffset(stop.start), me = posFromOffset(stop.end); @@ -295,7 +368,10 @@ define(function (require, exports, module) { return editor.markText("tabstop", ms, me, { clearWhenEmpty: false, inclusiveLeft: false, - inclusiveRight: true + inclusiveRight: true, + className: outlineOption.className, + startStyle: outlineOption.startStyle, + endStyle: outlineOption.endStyle }); }); @@ -312,11 +388,13 @@ define(function (require, exports, module) { } }; - _session = { editor: editor, markers: markers, index: -1, keymap: keymap }; + _session = { editor: editor, markers: markers, index: -1, keymap: keymap, activeOutlineMarker: null }; editor._codeMirror.addKeyMap(keymap); - // End the session if the editor it belongs to is destroyed (file closed). Namespaced so - // _clearSession can remove it with a single off(".tabstop"). + // End the session if the editor it belongs to is destroyed (file closed), or the user moves + // on (cursor leaves the snippet's lines, or a multi-cursor selection is made). Namespaced so + // _clearSession can remove both with a single off(".tabstop"). editor.on("beforeDestroy.tabstop", _clearSession); + editor.on("cursorActivity.tabstop", _handleCursorActivity); _selectStop(0); return parsed; @@ -338,4 +416,8 @@ define(function (require, exports, module) { exports.insertSnippet = insertSnippet; exports.hasActiveSession = hasActiveSession; exports.endSession = endSession; + // exposed so other features with their own stable session lifecycle (e.g. custom snippets) + // can drive Tab / Shift-Tab navigation without duplicating this logic + exports.goToNextStop = _gotoNext; + exports.goToPreviousStop = _gotoPrev; }); diff --git a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js index 2159fe8d4b..cdd422f8ee 100644 --- a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js +++ b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js @@ -23,7 +23,6 @@ define(function (require, exports, module) { const EditorManager = require("editor/EditorManager"); const Metrics = require("utils/Metrics"); - const Global = require("./global"); const Driver = require("./driver"); const Helper = require("./helper"); const SnippetCursorManager = require("./snippetCursorManager"); @@ -84,7 +83,8 @@ define(function (require, exports, module) { if (matchingSnippets.length > 0) { const customSnippetHints = matchingSnippets.map((snippet) => { - return Helper.createHintItem(snippet.abbreviation, needle.word, snippet.description); + return Helper.createHintItem( + snippet.abbreviation, needle.word, snippet.description, snippet.insertionKey); }); return { @@ -108,34 +108,33 @@ define(function (require, exports, module) { insertHint: function (hint) { // check if the hint is a custom snippet if (hint && hint.jquery && hint.attr("data-isCustomSnippet")) { - // handle custom snippet insertion - const abbreviation = hint.attr("data-val"); - if (Global.SnippetHintsList) { - const matchedSnippet = Global.SnippetHintsList.find( - (snippet) => snippet.abbreviation === abbreviation - ); + // handle custom snippet insertion. The hint list was already built from the correctly + // language-scoped candidates (see getHints above), and each hint element carries the + // exact resolved snippet's insertionKey - so accepting it is a direct O(1) lookup, not + // a re-search by abbreviation + the (possibly since-changed) current language context + const insertionKey = hint.attr("data-insertion-key"); + + // Get current editor from EditorManager since it's not passed + const editor = EditorManager.getActiveEditor(); + if (editor) { + const matchedSnippet = Helper.getSnippetByInsertionKey(insertionKey); if (matchedSnippet) { - // Get current editor from EditorManager since it's not passed - const editor = EditorManager.getActiveEditor(); - - if (editor) { - // to track the usage metrics - const fileCategory = Helper.categorizeFileExtensionForMetrics(matchedSnippet.fileExtension); - Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `use.${fileCategory}`); - - // replace the typed abbreviation with the template text using cursor manager - const wordInfo = Driver.getWordBeforeCursor(); - const start = { line: wordInfo.line, ch: wordInfo.ch + 1 }; - const end = editor.getCursorPos(); - - SnippetCursorManager.insertSnippetWithTabStops( - editor, - matchedSnippet.templateText, - start, - end - ); - return true; // handled - } + // to track the usage metrics + const fileCategory = Helper.categorizeFileExtensionForMetrics(matchedSnippet.fileExtension); + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `use.${fileCategory}`); + + // replace the typed abbreviation with the template text using cursor manager + const wordInfo = Driver.getWordBeforeCursor(); + const start = { line: wordInfo.line, ch: wordInfo.ch + 1 }; + const end = editor.getCursorPos(); + + SnippetCursorManager.insertSnippetWithTabStops( + editor, + matchedSnippet.templateText, + start, + end + ); + return true; // handled } } } diff --git a/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js b/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js new file mode 100644 index 0000000000..38e38ebdd6 --- /dev/null +++ b/src/extensionsIntegrated/CustomSnippets/defaultSnippets.js @@ -0,0 +1,114 @@ +/* + * GNU AGPL-3.0 License + * + * Copyright (c) 2021 - present core.ai . All rights reserved. + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License + * for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://opensource.org/licenses/AGPL-3.0. + * + */ + +define(function (require, exports, module) { + // INDENT is templateText's own indent-unit marker (see snippetCursorManager.js INDENT_TOKEN) - + // resolved to this specific file's actual detected/configured indent (spaces or tabs, whatever + // width) at insertion time, instead of hardcoding a literal " " that would look wrong the + // moment a user's file uses 2-space indent, tabs, etc. + const INDENT = require("./snippetCursorManager").INDENT_TOKEN; + + // These are literal code syntax shown verbatim in the hint tooltip, not natural-language prose - + // there is nothing in them for a translator to translate, so per the i18n rule in CLAUDE.md they + // are local constants here rather than strings.js keys (only genuinely translatable strings belong + // in strings.js; content that must render identically in every locale - like code syntax - does + // not, and should never be sent through the automated AI translation pass). + const FUNCTION_DESC = "function name() {...}"; + const ARROW_DESC = "const name = () => {...}"; + const PYTHON_FUNCTION_DESC = "def name(): ..."; + + /** + * Built-in snippets shipped with Phoenix (see https://github.com/phcode-dev/phoenix/issues/618). + * + * These are NOT persisted to the user's customSnippets.json and NOT part of `Global.SnippetHintsList` + * at all - they're merged into the matching engine's optimized structures directly (see helper.js + * `rebuildOptimizedStructures`), which is also why they never appear in the Custom Snippets panel + * (snippetsList.js/driver.js only ever read/write `Global.SnippetHintsList`, so a built-in is simply + * invisible to add/edit/delete there) and can't be user-edited or deleted. Because they're always + * re-derived straight from this file on every boot, template/description improvements in a later + * Phoenix release reach every user immediately - there's nothing to keep in sync. + * + * `isDefault: true` marks an entry as one of these built-ins (as opposed to a user-created snippet) + * wherever the merged/optimized snippet objects are inspected. + * + * `prefixTrigger: true` lets the hint pop up as soon as the user has typed a leading prefix of + * `abbreviation` (2+ chars - see helper.js `hasExactMatchingSnippet`), not only once the whole word + * is typed - e.g. typing "fu"/"fun"/"func" all offer the "function" entry. Regular user-created + * snippets never get this flag, so their exact-match-only behavior is unaffected. + */ + const DEFAULT_SNIPPETS = [ + { + id: "default-function", + isDefault: true, + abbreviation: "function", + prefixTrigger: true, + description: FUNCTION_DESC, + templateText: + "function ${1:name}(${2}) {\n" + + INDENT + "${0}\n" + + "}", + fileExtension: ".js, .jsx, .ts, .tsx" + }, + { + id: "default-arrow-function", + isDefault: true, + abbreviation: "arrow", + prefixTrigger: true, + description: ARROW_DESC, + templateText: + "const ${1:name} = (${2}) => {\n" + + INDENT + "${0}\n" + + "};", + fileExtension: ".js, .jsx, .ts, .tsx" + }, + { + // PHP genuinely uses the same `function` keyword as JS - deliberately shares the same + // abbreviation, scoped to .php only. Requires hasExactMatchingSnippet to check ALL + // same-named candidates per language (see helper.js) rather than a single Map winner. + id: "default-function-php", + isDefault: true, + abbreviation: "function", + prefixTrigger: true, + description: FUNCTION_DESC, + templateText: + "function ${1:name}(${2}) {\n" + + INDENT + "${0}\n" + + "}", + fileExtension: ".php" + }, + { + // Python has no braces - the body is indentation-scoped under the colon-terminated + // `def` line, so this needs its own trigger word ("def") and template shape entirely. + // Unlike JS/PHP, an empty body here is a real syntax error (IndentationError), so the + // final stop defaults to "pass" (selected, ready to type over) instead of being empty. + id: "default-function-python", + isDefault: true, + abbreviation: "def", + prefixTrigger: true, + description: PYTHON_FUNCTION_DESC, + templateText: + "def ${1:name}(${2}):\n" + + INDENT + "${0:pass}", + fileExtension: ".py" + } + ]; + + exports.DEFAULT_SNIPPETS = DEFAULT_SNIPPETS; +}); diff --git a/src/extensionsIntegrated/CustomSnippets/helper.js b/src/extensionsIntegrated/CustomSnippets/helper.js index 43713dcda5..89fd7d71ab 100644 --- a/src/extensionsIntegrated/CustomSnippets/helper.js +++ b/src/extensionsIntegrated/CustomSnippets/helper.js @@ -23,6 +23,7 @@ define(function (require, exports, module) { const Global = require("./global"); const UIHelper = require("./UIHelper"); const Strings = require("strings"); + const DefaultSnippets = require("./defaultSnippets"); // list of all the navigation and function keys that are allowed inside the input fields const ALLOWED_NAVIGATION_KEYS = [ @@ -56,7 +57,13 @@ define(function (require, exports, module) { // Optimized data structures for fast snippet lookups let snippetsByLanguage = new Map(); let snippetsByAbbreviation = new Map(); + let snippetsByInsertionKey = new Map(); let allSnippetsOptimized = []; + // small dedicated subset of allSnippetsOptimized (only built-in defaults ever set prefixTrigger - + // see defaultSnippets.js), scanned on every keystroke by hasExactMatchingSnippet below. Kept + // separate from allSnippetsOptimized so that scan's cost never grows with the user's own + // (potentially much larger) custom snippet count. + let prefixTriggerSnippets = []; /** * Preprocesses a snippet to add optimized lookup properties @@ -69,6 +76,12 @@ define(function (require, exports, module) { // pre-compute lowercase abbreviation for faster matching optimizedSnippet.abbreviationLower = snippet.abbreviation.toLowerCase(); + // a stable key identifying this exact snippet for insertion (see findSnippetForInsertion / + // getSnippetByInsertionKey) - built-ins use their unique `id`; regular user snippets don't + // have one, but driver.js already enforces globally-unique abbreviations for those on add, so + // the abbreviation itself is a safe stable key for them + optimizedSnippet.insertionKey = snippet.id || snippet.abbreviation; + // parse and create a Set of supported extensions for O(1) lookup if (snippet.fileExtension.toLowerCase() === "all") { optimizedSnippet.supportedLangSet = new Set(["all"]); @@ -90,20 +103,44 @@ define(function (require, exports, module) { * Rebuilds optimized data structures from the current snippet list * we call this function whenever snippets are loaded, added, modified, or deleted * i.e. whenever the snippetList is updated + * + * This is also where built-in default snippets (see defaultSnippets.js) get merged into the + * matching engine - they are NOT part of Global.SnippetHintsList and are never persisted/shown + * in the panel, they only exist here, in this optimized/derived view. */ function rebuildOptimizedStructures() { // clear existing structures snippetsByLanguage.clear(); snippetsByAbbreviation.clear(); + snippetsByInsertionKey.clear(); allSnippetsOptimized.length = 0; + prefixTriggerSnippets.length = 0; - // Process each snippet - Global.SnippetHintsList.forEach(snippet => { + // Process each snippet - user snippets first, so a user snippet that happens to share an + // abbreviation with a default is index-order-first (relevant only for iteration order, since + // matching itself checks every candidate for language support regardless of order) + Global.SnippetHintsList.concat(DefaultSnippets.DEFAULT_SNIPPETS).forEach(snippet => { const optimizedSnippet = preprocessSnippet(snippet); allSnippetsOptimized.push(optimizedSnippet); - // Index by abbreviation (lowercase) for exact matches - snippetsByAbbreviation.set(optimizedSnippet.abbreviationLower, optimizedSnippet); + if (optimizedSnippet.prefixTrigger) { + prefixTriggerSnippets.push(optimizedSnippet); + } + + // O(1) lookup by stable identity for insertion - see findSnippetForInsertion. Collisions + // aren't expected (built-ins key by unique `id`; user snippets key by their own + // abbreviation, which driver.js already enforces is unique among user snippets on add) - + // if one somehow occurs, last one indexed wins, same as any other Map.set. + snippetsByInsertionKey.set(optimizedSnippet.insertionKey, optimizedSnippet); + + // Index by abbreviation (lowercase) for exact matches. Multiple snippets CAN share the + // same abbreviation (e.g. "function" for both JS and PHP) as long as they're scoped to + // different, non-overlapping languages - so this maps to an array of candidates, not a + // single winner, and hasExactMatchingSnippet below checks all of them. + if (!snippetsByAbbreviation.has(optimizedSnippet.abbreviationLower)) { + snippetsByAbbreviation.set(optimizedSnippet.abbreviationLower, []); + } + snippetsByAbbreviation.get(optimizedSnippet.abbreviationLower).push(optimizedSnippet); // Index by supported languages/extensions if (optimizedSnippet.supportsAllLanguages) { @@ -366,24 +403,66 @@ define(function (require, exports, module) { return false; } + // Minimum characters the user must type before a `prefixTrigger` snippet (see defaultSnippets.js) + // is allowed to fire on a partial/in-progress prefix of its abbreviation, instead of only once + // fully typed. Guards against 1-character noise (e.g. every word starting with "f"). + const MIN_PREFIX_TRIGGER_LENGTH = 2; + /** - * Checks if there's at least one exact match for the query + * Checks if there's at least one matching snippet for the query - either an exact abbreviation + * match, or, for snippets opted into `prefixTrigger` (see defaultSnippets.js), a leading prefix + * of their abbreviation once the user has typed at least MIN_PREFIX_TRIGGER_LENGTH characters. + * Regular user-created snippets never set `prefixTrigger`, so they keep requiring an exact match, + * unaffected by this. + * + * Multiple snippets can share the same abbreviation across different languages (e.g. "function" + * for both JS and PHP) - every candidate for a given abbreviation/prefix is checked against the + * current language context, so one language's entry never shadows another's. * @param {string} query - The search query * @param {Editor} editor - The editor instance - * @returns {boolean} - True if there's an exact match + * @returns {boolean} - True if there's a matching snippet */ function hasExactMatchingSnippet(query, editor) { const queryLower = query.toLowerCase(); const languageContext = getCurrentLanguageContext(editor); - const snippet = snippetsByAbbreviation.get(queryLower); - if (snippet) { - return isSnippetSupportedInLanguageContext(snippet, languageContext, editor); + const exactCandidates = snippetsByAbbreviation.get(queryLower); + if (exactCandidates && exactCandidates.some((snippet) => + isSnippetSupportedInLanguageContext(snippet, languageContext, editor))) { + return true; + } + + if (queryLower.length >= MIN_PREFIX_TRIGGER_LENGTH) { + // scoped to the small prefixTriggerSnippets subset (built-ins only), not the user's full + // (potentially much larger) snippet list - see its declaration for why + const hasPrefixMatch = prefixTriggerSnippets.some((snippet) => + snippet.abbreviationLower.startsWith(queryLower) && + isSnippetSupportedInLanguageContext(snippet, languageContext, editor) + ); + if (hasPrefixMatch) { + return true; + } } return false; } + /** + * Looks up the exact snippet to insert by its insertionKey (see preprocessSnippet) - an O(1) Map + * lookup, not a re-derivation of language context. The hint list shown to the user was already + * built from the correctly language-scoped candidates (getMatchingSnippets/hasExactMatchingSnippet + * run at hint-display time); each rendered hint element carries the exact resolved snippet's + * insertionKey (see createHintItem), so accepting a hint just needs to look that key up directly - + * whatever was shown is exactly what gets inserted, with no risk of re-resolving to a different + * snippet than the one actually displayed (e.g. if the cursor's language context could ever change + * between the hint being shown and being accepted). + * @param {string} insertionKey - the `data-insertion-key` carried by the accepted hint element + * @returns {Object|null} the matching snippet, or null if not found + */ + function getSnippetByInsertionKey(insertionKey) { + return snippetsByInsertionKey.get(insertionKey) || null; + } + /** * Gets all snippets that match the query (prefix matches) * @param {string} query - The search query @@ -450,14 +529,21 @@ define(function (require, exports, module) { * @param {String} abbr - the abbreviation text that is to be displayed in the code hint * @param {String} query - the query string typed by the user for highlighting matching characters * @param {String} description - the description of the snippet to be displayed + * @param {String} [insertionKey] - the exact snippet's insertionKey (see preprocessSnippet), + * carried on the element so insertHint can look it up directly (getSnippetByInsertionKey) + * instead of re-resolving by abbreviation + current language context at accept time * @returns {JQuery} - the jquery item that has the abbr text and the Snippet icon */ - function createHintItem(abbr, query, description) { + function createHintItem(abbr, query, description, insertionKey) { var $hint = $("") .addClass("brackets-css-hints brackets-hints custom-snippets-hint") .attr("data-val", abbr) .attr("data-isCustomSnippet", true); + if (insertionKey !== undefined && insertionKey !== null) { + $hint.attr("data-insertion-key", insertionKey); + } + // add the tooltip for the description shown when the hint is hovered if (description && description.trim() !== "") { $hint.attr("title", description.trim()); @@ -917,6 +1003,7 @@ define(function (require, exports, module) { exports.isSnippetSupportedInLanguageContext = isSnippetSupportedInLanguageContext; exports.isSnippetSupportedInFile = isSnippetSupportedInFile; exports.hasExactMatchingSnippet = hasExactMatchingSnippet; + exports.getSnippetByInsertionKey = getSnippetByInsertionKey; exports.getMatchingSnippets = getMatchingSnippets; exports.sanitizeFileExtensionInput = sanitizeFileExtensionInput; exports.handleFileExtensionInput = handleFileExtensionInput; diff --git a/src/extensionsIntegrated/CustomSnippets/main.js b/src/extensionsIntegrated/CustomSnippets/main.js index e88122f448..7b1b843cc3 100644 --- a/src/extensionsIntegrated/CustomSnippets/main.js +++ b/src/extensionsIntegrated/CustomSnippets/main.js @@ -290,7 +290,9 @@ define(function (require, exports, module) { _addToMenu(); CodeHintIntegration.init(); - // load snippets from file storage + // load snippets from file storage. Built-in default snippets (see defaultSnippets.js) are + // NOT part of this user data - they're merged directly into the matching engine's optimized + // structures (see helper.js rebuildOptimizedStructures), so they never touch this file. const _snippetsLoadedPromise = SnippetsState.loadSnippetsFromState() .then(function () { // track boot-time snippet count (only if user has snippets) @@ -304,8 +306,6 @@ define(function (require, exports, module) { logger.reportError(error, "Custom Snippets: didn't load on app init"); }); - SnippetCursorManager.registerHandlers(); - // Expose modules for integration testing if (brackets.test) { brackets.test.CustomSnippetsGlobal = Global; @@ -313,6 +313,7 @@ define(function (require, exports, module) { brackets.test.CustomSnippetsCursorManager = SnippetCursorManager; brackets.test.CustomSnippetsCodeHintHandler = CodeHintIntegration._CustomSnippetsHandler; brackets.test.CustomSnippetsDriver = Driver; + brackets.test.CustomSnippetsState = SnippetsState; brackets.test._customSnippetsLoadedPromise = _snippetsLoadedPromise; } }); diff --git a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js index a7ce3de810..ee71c83a8d 100644 --- a/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js +++ b/src/extensionsIntegrated/CustomSnippets/snippetCursorManager.js @@ -20,124 +20,76 @@ define(function (require, exports, module) { const KeyEvent = require("utils/KeyEvent"); - const EditorManager = require("editor/EditorManager"); - - // tab stops regex to handle ${1}, ${2}.... etc. - const TAB_STOP_REGEX = /\$\{(\d+)\}/g; - - // this is to check whether an active snippet session is on or off - let activeSnippetSession = null; + const TabstopManager = require("editor/TabstopManager"); + const Editor = require("editor/Editor").Editor; /** - * this represents an active snippet session with tab stops + * Marker a snippet's templateText can use to mean "one indent level, in whatever this specific + * file/editor is actually configured/detected to use" (spaces vs tabs, and how many) - resolved by + * resolveIndentToken below, entirely within this module's own preprocessing, before the text ever + * reaches TabstopManager's shared LSP-grammar parser. That's deliberate: TabstopManager is also + * used directly by LSP completions and DocCommentHints (see editor/TabstopManager.js), and this + * marker is never taught to THAT shared parser at all - by the time TabstopManager sees the text, + * this token has already been fully replaced with literal characters, so there is nothing here for + * it to interpret, and real LSP-served snippet text (which never passes through this module) can + * never trigger this substitution either. Deliberately NOT `$`-prefixed, so it can never collide + * with real `${...}` tab-stop/placeholder syntax even if this substitution were ever skipped - it + * would just show up as this literal, obviously-wrong-looking text instead of silently misbehaving. */ - function SnippetSession(editor, tabStops, startLine, endLine) { - this.editor = editor; - this.tabStops = tabStops; // this is an array of {number, line} sorted by number - this.currentTabNumber = tabStops.length > 0 ? tabStops[0].number : 1; - this.startLine = startLine; - this.endLine = endLine; - this.isActive = true; - } + const INDENT_TOKEN = "@@INDENT@@"; /** - * this function is responsible to parse the template text and extract all the tab stops + * Resolves what "one indent level" literally looks like right now for the given editor's file - + * same auto-detection + project/language preference cascade Phoenix's own Tab-key handling uses + * (see Editor.getUseTabChar/getSpaceUnits), so it always matches what pressing Tab in that file + * would actually insert. * - * @param {string} templateText - the template text with tab stops - * @returns {Object} - Object containing the text and tab stop information + * @param {Editor} editor - the editor instance being inserted into + * @returns {string} - e.g. " " or "\t", scoped to this specific file/language/project */ - function parseTemplateText(templateText) { - const tabStops = []; - let match; - - // reset regex - TAB_STOP_REGEX.lastIndex = 0; - - // find all the tab stops - while ((match = TAB_STOP_REGEX.exec(templateText)) !== null) { - const tabNumber = parseInt(match[1], 10); - tabStops.push({ - number: tabNumber - }); - } - - // sort the tab stops by number. note: 0 should come at last - tabStops.sort((a, b) => { - if (a.number === 0) { - return 1; - } - if (b.number === 0) { - return -1; - } - return a.number - b.number; - }); - - return { - text: templateText, - tabStops: tabStops - }; + function getOneIndentUnit(editor) { + const fullPath = editor && editor.document && editor.document.file && editor.document.file.fullPath; + return Editor.getUseTabChar(fullPath) ? "\t" : " ".repeat(Editor.getSpaceUnits(fullPath)); } /** - * Find tab stops in the snippet lines and return their positions - * this is called after snippet insertion to find actual positions in the editor + * Replaces every INDENT_TOKEN in templateText with the current editor's actual one-indent-level + * string. See INDENT_TOKEN's own doc comment for why this is a plain string substitution done here + * rather than new tab-stop syntax taught to the shared TabstopManager parser. * - * @param {Editor} editor - editor instance - * @param {number} startLine - Start line of snippet - * @param {number} endLine - End line of snippet - * @returns {Array} - array of {number, line, start, end} sorted by number + * @param {string} templateText - the raw template text, may contain zero or more INDENT_TOKENs + * @param {Editor} editor - the editor instance being inserted into + * @returns {string} - templateText with every INDENT_TOKEN replaced */ - function findTabStops(editor, startLine, endLine) { - const tabStops = []; - const document = editor.document; - - for (let line = startLine; line <= endLine; line++) { - const lineText = document.getLine(line); - let match; - - TAB_STOP_REGEX.lastIndex = 0; - while ((match = TAB_STOP_REGEX.exec(lineText)) !== null) { - const tabNumber = parseInt(match[1], 10); - tabStops.push({ - number: tabNumber, - line: line, - start: { line: line, ch: match.index }, - end: { line: line, ch: match.index + match[0].length } - }); - } + function resolveIndentToken(templateText, editor) { + if (templateText.indexOf(INDENT_TOKEN) === -1) { + return templateText; // fast path - most snippets (all user-authored ones, today) skip this } - - tabStops.sort((a, b) => { - if (a.number === 0) { - return 1; - } - if (b.number === 0) { - return -1; - } - return a.number - b.number; - }); - - return tabStops; + const unit = getOneIndentUnit(editor); + return templateText.split(INDENT_TOKEN).join(unit); } /** - * responsible to check if session should continue (tab stops still exist in template area) - * we need this because users can delete tab stops while typing + * Custom snippet templateText historically only ever recognized the braced form `${1}` as a + * tab stop (regex `/\$\{(\d+)\}/g`) - a bare `$1`, `$scope`, `$5`, etc. was always just literal + * text. TabstopManager understands the fuller LSP snippet grammar (bare `$1` tab stops, `${VAR}` + * variables that get silently dropped if unresolved, `${1:default}` placeholders, `\$`/`\}`/`\\` + * escapes). To keep every already-saved snippet behaving exactly as before after this migration, + * we escape every '$' that isn't immediately starting a `${...}` group before handing the text to + * TabstopManager - this way only the braced forms are ever treated as snippet syntax, exactly + * matching the old engine's behavior, while additively allowing `${1:default text}` and + * `${1|a,b,c|}` for anyone (including our own default snippets) who wants richer placeholders. * - * @returns {boolean} + * @param {string} text - the raw template text + * @returns {string} - text with any bare (non-`${`) '$' escaped as '\$' */ - function shouldContinueSession() { - if (!activeSnippetSession || !activeSnippetSession.isActive) { - return false; - } - - const session = activeSnippetSession; - const tabStops = findTabStops(session.editor, session.startLine, session.endLine); - - // update the session with current tab stops - session.tabStops = tabStops; - - return tabStops.length > 0; + function escapeBareDollarSigns(text) { + // escape pre-existing literal backslashes first, so they aren't misread as introducing a + // \$, \}, \\ escape sequence once the next step injects backslashes next to '$' characters + let escaped = text.replace(/\\/g, "\\\\"); + // escape every '$' not immediately followed by '{' + escaped = escaped.replace(/\$(?!\{)/g, "\\$"); + return escaped; } /** @@ -202,333 +154,102 @@ define(function (require, exports, module) { * @param {Object} endPos - End position for insertion */ function insertSnippetWithTabStops(editor, templateText, startPos, endPos) { - const parsed = parseTemplateText(templateText); + // Resolve any INDENT_TOKEN to this file's actual indent unit first, so everything downstream + // just sees plain literal characters - see resolveIndentToken's doc comment for why this must + // happen before escaping/parsing, not as new syntax taught to the shared TabstopManager parser. + const withIndentResolved = resolveIndentToken(templateText, editor); + + const escapedText = escapeBareDollarSigns(withIndentResolved); // Get the current line's indentation to apply to all subsequent lines const baseIndent = getLineIndentation(editor, startPos); // Apply proper indentation to the snippet text for multi-line snippets - const indentedText = addIndentationToSnippet(parsed.text, baseIndent); + const indentedText = addIndentationToSnippet(escapedText, baseIndent); - editor.document.replaceRange(indentedText, startPos, endPos); - - // calculate snippet bounds - const lines = indentedText.split("\n"); - const startLine = startPos.line; - const endLine = startPos.line + lines.length - 1; - - // find tab stops in the inserted snippet - const tabStops = findTabStops(editor, startLine, endLine); - - if (tabStops.length > 0) { - activeSnippetSession = new SnippetSession(editor, tabStops, startLine, endLine); - - // move to first tab stop. this is the default behaviour - navigateToTabStop(activeSnippetSession.currentTabNumber); - } else { - // when no tab stops, we just place cursor at end - const finalPos = { - line: endLine, - ch: lines.length === 1 ? startPos.ch + lines[0].length : lines[lines.length - 1].length - }; - editor.setCursorPos(finalPos); - } + return TabstopManager.insertSnippet(editor, indentedText, startPos, endPos); } /** - * Navigate to a specific tab stop by number - * @param {number} tabNumber - Tab stop number to navigate to + * Check if we're currently in a snippet session + * @returns {boolean} */ - function navigateToTabStop(tabNumber) { - if (!shouldContinueSession()) { - endSnippetSession(); - return; - } - - const session = activeSnippetSession; - - // find the tab stop with the specified number - const tabStop = session.tabStops.find((t) => t.number === tabNumber); - - if (tabStop) { - session.currentTabNumber = tabNumber; + function isInSnippetSession() { + return TabstopManager.hasActiveSession(); + } - // select the entire tab stop placeholder - session.editor.setSelection(tabStop.start, tabStop.end); - session.editor.focus(); - } else { - endSnippetSession(); - } + /** + * End the current snippet session + */ + function endSnippetSession() { + TabstopManager.endSession(); } /** * Navigate to the next tab stop - * this handles the logic for finding the next available tab stop in sequence + * @returns {boolean} true if a session was active and navigation happened */ function navigateToNextTabStop() { - if (!shouldContinueSession()) { - endSnippetSession(); + if (!TabstopManager.hasActiveSession()) { return false; } - - const session = activeSnippetSession; - const currentNumber = session.currentTabNumber; - - let nextTabStop = null; - - // If we're currently at ${0}, there's no next tab stop so we need to end the session - if (currentNumber === 0) { - endSnippetSession(); - return false; - } - - // at first, look for the next numbered tab stop (greater than current) - for (let i = 0; i < session.tabStops.length; i++) { - if (session.tabStops[i].number > currentNumber && session.tabStops[i].number !== 0) { - nextTabStop = session.tabStops[i]; - break; - } - } - - // If no numbered tab stop found, look for ${0} as the final stop - if (!nextTabStop) { - nextTabStop = session.tabStops.find((t) => t.number === 0); - } - - if (nextTabStop) { - navigateToTabStop(nextTabStop.number); - return true; - } - endSnippetSession(); - return false; + TabstopManager.goToNextStop(); + return true; } /** * Navigate to the previous tab stop - * this handles shift+tab navigation to go backwards + * @returns {boolean} true if a session was active and navigation happened */ function navigateToPreviousTabStop() { - if (!shouldContinueSession()) { - endSnippetSession(); - return false; - } - - const session = activeSnippetSession; - const currentNumber = session.currentTabNumber; - - // Find the previous tab stop number in the sorted array - let prevTabStop = null; - - // If we're currently at ${0}, find the highest numbered tab stop - if (currentNumber === 0) { - let maxNumber = -1; - for (let i = 0; i < session.tabStops.length; i++) { - if (session.tabStops[i].number !== 0 && session.tabStops[i].number > maxNumber) { - maxNumber = session.tabStops[i].number; - prevTabStop = session.tabStops[i]; - } - } - } else { - // Find the previous numbered tab stop (less than current, but not 0) - for (let i = session.tabStops.length - 1; i >= 0; i--) { - if (session.tabStops[i].number < currentNumber && session.tabStops[i].number !== 0) { - prevTabStop = session.tabStops[i]; - break; - } - } - } - - if (prevTabStop) { - navigateToTabStop(prevTabStop.number); - return true; - } - return false; - } - - /** - * End the current snippet session - * this cleans up all remaining tab stop placeholders and resets the session - */ - function endSnippetSession() { - if (activeSnippetSession) { - const session = activeSnippetSession; - - // Remove any remaining tab stop placeholders - const tabStops = findTabStops(session.editor, session.startLine, session.endLine); - tabStops.reverse().forEach((tabStop) => { - session.editor.document.replaceRange("", tabStop.start, tabStop.end); - }); - - activeSnippetSession.isActive = false; - activeSnippetSession = null; - } - } - - /** - * Check if we're currently in a snippet session - * @returns {boolean} - */ - function isInSnippetSession() { - return activeSnippetSession && activeSnippetSession.isActive; - } - - /** - * Check if cursor is within snippet lines - * we need this to end the session if user moves cursor outside the snippet area - * - * @param {Object} cursorPos - Current cursor position - * @returns {boolean} - */ - function isCursorInSnippetLines(cursorPos) { - if (!activeSnippetSession) { + if (!TabstopManager.hasActiveSession()) { return false; } - - return cursorPos.line >= activeSnippetSession.startLine && cursorPos.line <= activeSnippetSession.endLine; + TabstopManager.goToPreviousStop(); + return true; } /** - * Handle key events for tab navigation - * this is where all the tab/shift+tab/escape key handling happens + * Handle key events for tab navigation. + * NOTE: real Tab/Shift-Tab/Esc handling during an active session is now owned by + * TabstopManager's own CodeMirror keymap (installed per-session in insertSnippet). This + * function is kept only as a thin compatibility shim for callers/tests that dispatch a + * synthesized key event directly instead of going through the real DOM/CodeMirror path. * - * @param {Event} jqEvent - jQuery event + * @param {Event} jqEvent - jQuery event (unused, kept for signature compatibility) * @param {Editor} editor - Editor instance * @param {KeyboardEvent} event - Keyboard event */ function handleKeyEvent(jqEvent, editor, event) { - if (!isInSnippetSession() || activeSnippetSession.editor !== editor) { - return false; - } - - // make sure that the cursor is still within snippet lines - const cursorPos = editor.getCursorPos(); - if (!isCursorInSnippetLines(cursorPos)) { - endSnippetSession(); + if (!TabstopManager.hasActiveSession()) { return false; } - // Tab key handling if (event.keyCode === KeyEvent.DOM_VK_TAB) { - if (event.shiftKey) { - // Shift+Tab: go to previous tab stop - if (navigateToPreviousTabStop()) { - event.preventDefault(); - return true; - } - } else { - // Tab: go to next tab stop - if (navigateToNextTabStop()) { - event.preventDefault(); - return true; - } + const moved = event.shiftKey ? navigateToPreviousTabStop() : navigateToNextTabStop(); + if (moved) { + event.preventDefault(); + return true; } } - // 'Esc' key to end snippet session if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { endSnippetSession(); event.preventDefault(); return true; } - // handle Delete/Backspace - check if session should continue - // we need this because users might delete the template text from the editor - if (event.keyCode === KeyEvent.DOM_VK_DELETE || event.keyCode === KeyEvent.DOM_VK_BACK_SPACE) { - // just to let the delete/backspace complete - setTimeout(() => { - if (!shouldContinueSession()) { - endSnippetSession(); - } - }, 10); - } - return false; } - /** - * Handle cursor position changes - * this ends the session if user moves cursor outside snippet bounds or creates multiple selections - * @param {Event} event - Cursor activity event - * @param {Editor} editor - Editor instance - */ - function handleCursorActivity(event, editor) { - if (!isInSnippetSession() || activeSnippetSession.editor !== editor) { - return; - } - - // end session if user creates multiple selections - if (editor.getSelections().length > 1) { - endSnippetSession(); - return; - } - - const cursorPos = editor.getCursorPos(); - if (!isCursorInSnippetLines(cursorPos)) { - endSnippetSession(); - } - } - - /** - * This function is responsible to register all the required handers - * we need this to set up all the event listeners for cursor navigation - */ - function registerHandlers() { - // register the event handler for snippet cursor navigation - const editorHolder = $("#editor-holder")[0]; - if (editorHolder) { - editorHolder.addEventListener( - "keydown", - function (event) { - const editor = EditorManager.getActiveEditor(); - if (editor) { - handleKeyEvent(null, editor, event); - } - }, - true - ); - } - - // Listen for editor changes to end snippet sessions - EditorManager.on("activeEditorChange", function (event, current, previous) { - if (isInSnippetSession()) { - endSnippetSession(); - } - }); - - // Register cursor activity handler for current and future editors - function registerCursorActivityForEditor(editor) { - if (editor) { - editor.on("cursorActivity", handleCursorActivity); - } - } - - // Register for current editor - const currentEditor = EditorManager.getActiveEditor(); - if (currentEditor) { - registerCursorActivityForEditor(currentEditor); - } - - // Register for editor changes - EditorManager.on("activeEditorChange", function (event, current, previous) { - if (previous) { - previous.off("cursorActivity", handleCursorActivity); - } - if (current) { - registerCursorActivityForEditor(current); - } - if (isInSnippetSession()) { - endSnippetSession(); - } - }); - } - - exports.parseTemplateText = parseTemplateText; + exports.escapeBareDollarSigns = escapeBareDollarSigns; exports.insertSnippetWithTabStops = insertSnippetWithTabStops; exports.isInSnippetSession = isInSnippetSession; exports.handleKeyEvent = handleKeyEvent; - exports.handleCursorActivity = handleCursorActivity; exports.endSnippetSession = endSnippetSession; - exports.registerHandlers = registerHandlers; exports.navigateToNextTabStop = navigateToNextTabStop; // exposed for integration testing exports.navigateToPreviousTabStop = navigateToPreviousTabStop; // exposed for integration testing + exports.INDENT_TOKEN = INDENT_TOKEN; // referenced by defaultSnippets.js templateText + exports.resolveIndentToken = resolveIndentToken; // exposed for unit testing + exports.getOneIndentUnit = getOneIndentUnit; // exposed for unit testing }); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 65886217ac..315082ca2d 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2423,7 +2423,7 @@ define({ "CUSTOM_SNIPPETS_ABBR_INPUT_TOOLTIP": "Enter a short abbreviation (e.g., 'clg', 'fn', 'div'). This is what you'll type to trigger the snippet.", "CUSTOM_SNIPPETS_DESC_INPUT_TOOLTIP": "Brief description of what this snippet does. Leave empty if no description needed.", "CUSTOM_SNIPPETS_FILE_EXT_INPUT_TOOLTIP": "Specify file types where this snippet should be available (e.g., '.js', '.html', '.css'). Leave empty to make it available for all files.", - "CUSTOM_SNIPPETS_TEMPLATE_INPUT_TOOLTIP": "The actual code that will be inserted. Use ${1}, ${2}, ${3}, etc. for cursor positions. ${1} is the initial position, tab moves to ${2}, ${3}, etc. ${0} is the final position.", + "CUSTOM_SNIPPETS_TEMPLATE_INPUT_TOOLTIP": "The actual code that will be inserted. Use ${1}, ${2}, ${3}, etc. for cursor positions. ${1} is the initial position, tab moves to ${2}, ${3}, etc. ${0} is the final position. Add default text that gets selected for type-over with ${1:placeholder text}. Use @@INDENT@@ for a nested line's indent - it matches the current file's actual indent settings (spaces or tabs) instead of a fixed width.", "CUSTOM_SNIPPETS_DESC_PLACEHOLDER": "console log shortcut (optional)", "CUSTOM_SNIPPETS_FILE_EXT_PLACEHOLDER": "Leave empty for all files, or specify like .js, .html", "CUSTOM_SNIPPETS_TEMPLATE_PLACEHOLDER": "console.log(${1});", diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 2481e40371..1b7b3a53b6 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -190,6 +190,58 @@ html, body { box-shadow: inset 1px 0 0 0 @bc-primary-btn-border, inset -1px 0 0 0 @bc-primary-btn-border; /* Left, right shadow */ } +// Subdued outline shown for every remaining stop of an active snippet/tab-stop session (see +// editor/TabstopManager.js) - a neutral border color, distinct from the bold "active" variant below. +// Uses @bc-editor-decoration-neutral (no .dark & split - see its own definition for why editor-canvas +// decorations need one universal mid-tone value instead of a light/dark UI-chrome color pair). +.editor-text-tabstop-outline { + border-top: 1px @bc-editor-decoration-neutral solid; + border-bottom: 1px @bc-editor-decoration-neutral solid; +} +.editor-text-tabstop-outline-left { + box-shadow: inset 1px 0 0 0 @bc-editor-decoration-neutral; /* Left shadow */ +} +.editor-text-tabstop-outline-right { + box-shadow: inset -1px 0 0 0 @bc-editor-decoration-neutral; /* right shadow */ +} +.editor-text-tabstop-outline-left.editor-text-tabstop-outline-right { + box-shadow: inset 1px 0 0 0 @bc-editor-decoration-neutral, inset -1px 0 0 0 @bc-editor-decoration-neutral; /* Left, right shadow */ +} + +// Bold/active outline for whichever stop is currently selected, layered on top of the subdued one +// above (same accent color rename-outline uses, for a consistent "you're actively editing here" +// visual language across features). +.editor-text-tabstop-outline-active { + border-top: 1px @bc-primary-btn-border solid; + border-bottom: 1px @bc-primary-btn-border solid; + + .dark & { + border-top: 1px @dark-bc-primary-btn-border solid; + border-bottom: 1px @dark-bc-primary-btn-border solid; + } +} +.editor-text-tabstop-outline-active-left { + box-shadow: inset 1px 0 0 0 @bc-primary-btn-border; + + .dark & { + box-shadow: inset 1px 0 0 0 @dark-bc-primary-btn-border; + } +} +.editor-text-tabstop-outline-active-right { + box-shadow: inset -1px 0 0 0 @bc-primary-btn-border; + + .dark & { + box-shadow: inset -1px 0 0 0 @dark-bc-primary-btn-border; + } +} +.editor-text-tabstop-outline-active-left.editor-text-tabstop-outline-active-right { + box-shadow: inset 1px 0 0 0 @bc-primary-btn-border, inset -1px 0 0 0 @bc-primary-btn-border; + + .dark & { + box-shadow: inset 1px 0 0 0 @dark-bc-primary-btn-border, inset -1px 0 0 0 @dark-bc-primary-btn-border; + } +} + // error class has highest visual precedence, followed by warning, spell error and info. // .error.error: This selector has 4x the class name, which increases its specificity compared to single class // selectors like .warning or .info. Even if error, warning, and info are used on the same div in any order, diff --git a/src/styles/brackets_core_ui_variables.less b/src/styles/brackets_core_ui_variables.less index afa394ce41..2ca580f129 100644 --- a/src/styles/brackets_core_ui_variables.less +++ b/src/styles/brackets_core_ui_variables.less @@ -65,6 +65,12 @@ @bc-error: #f74687; @bc-modal-backdrop-opacity: 0.4; @bc-spinner: #78b2f2; +// Deliberately NOT theme-split (no @dark- pair): this decorates the EDITOR CANVAS, whose background +// depends on the user's chosen code theme (not just the app's light/dark UI mode - see +// .cm-matchhighlight in brackets_codemirror_override.less for the same reasoning), so it needs one +// mid-tone value that reads reasonably against both very light and very dark editor backgrounds, +// rather than a UI-chrome color pair tuned only for the app's own near-white/near-black panels. +@bc-editor-decoration-neutral: #808080; // Highlights and Shadows @bc-highlight: rgba(255, 255, 255, 0.12); diff --git a/src/styles/brackets_patterns_override.less b/src/styles/brackets_patterns_override.less index bc24e0eb12..65ae797185 100644 --- a/src/styles/brackets_patterns_override.less +++ b/src/styles/brackets_patterns_override.less @@ -839,12 +839,13 @@ a:focus { line-height: inherit; } +// Always absolutely positioned (not just while highlighted) so that when it's hidden +// (visibility: hidden, below) it's taken out of normal flow entirely - otherwise, on a +// static-positioned item that isn't highlighted, this label's own reserved inline box can +// still wrap the row onto a second line, leaving a blank line behind and shifting every +// hint below it down as selection moves off this item (see #618 follow-up). .custom-snippet-code-hint { visibility: hidden; -} - -.codehint-menu .dropdown-menu li .highlight .custom-snippet-code-hint { - visibility: visible; position: absolute; right: 0; margin-top: -2px; @@ -857,6 +858,10 @@ a:focus { } } +.codehint-menu .dropdown-menu li .highlight .custom-snippet-code-hint { + visibility: visible; +} + .custom-snippets-hint { min-width: 200px !important; max-width: 350px !important; diff --git a/test/spec/CustomSnippets-test-files/test.php b/test/spec/CustomSnippets-test-files/test.php new file mode 100644 index 0000000000..aa4bc41ceb --- /dev/null +++ b/test/spec/CustomSnippets-test-files/test.php @@ -0,0 +1,3 @@ + {\n ${3}\n};${0}", + templateText: "const ${1:myFunction} = (${2:param}) => {\n ${3:body}\n};${0}", fileExtension: ".js, .ts" }, { @@ -69,7 +69,7 @@ define(function (require, exports, module) { { abbreviation: "divbox", description: "HTML div box", - templateText: "
\n ${2}\n
${0}", + templateText: "
\n ${2:content}\n
${0}", fileExtension: ".html" }, { @@ -81,7 +81,7 @@ define(function (require, exports, module) { { abbreviation: "clgdup", description: "Another clg variant", - templateText: "console.log('debug:', ${1});${0}", + templateText: "console.log('debug:', ${1:value});${0}", fileExtension: ".js" } ]; @@ -516,11 +516,11 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(clgHint); - // After insertion of "console.log(${1});${0}" - // cursor should be selecting ${1} + // After insertion of "console.log(${1:value});${0}" + // cursor should be selecting the "value" placeholder text (ready to type over) const selection = editor.getSelection(); const selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("value"); }); it("should replace only the typed abbreviation text, preserving preceding text", async function () { @@ -586,20 +586,20 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // Template: "const ${1} = (${2}) => {\n ${3}\n};${0}" + // Template: "const ${1:myFunction} = (${2:param}) => {\n ${3:body}\n};${0}" expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("myFunction"); - // Navigate to ${2} + // Navigate to the "param" stop CustomSnippetsCursorManager.navigateToNextTabStop(); expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${2}"); + expect(selectedText).toBe("param"); }); it("should navigate to previous tab stop", async function () { @@ -612,20 +612,20 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // Navigate forward: ${1} -> ${2} + // Navigate forward: "myFunction" -> "param" CustomSnippetsCursorManager.navigateToNextTabStop(); let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${2}"); + expect(selectedText).toBe("param"); - // Navigate backward: ${2} -> ${1} + // Navigate backward: "param" -> "myFunction" CustomSnippetsCursorManager.navigateToPreviousTabStop(); selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("myFunction"); }); - it("should navigate to ${0} (exit point) after all numbered tab stops", async function () { + it("should navigate to ${0} (exit point, collapsed caret) after all numbered tab stops", async function () { const editor = await openCleanFile("test.js"); typeAtCursor(editor, "clg"); @@ -635,19 +635,40 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(clgHint); - // Template: "console.log(${1});${0}" + // Template: "console.log(${1:value});${0}" let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("value"); - // Navigate to ${0} + // Navigate to ${0} - a bare stop with no default text is a collapsed caret, not a selection CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${0}"); + expect(selectedText).toBe(""); }); - it("should end snippet session after navigating past ${0}", async function () { + it("should treat a bare ${N} stop with no default text as a zero-width caret", async function () { + await openFile("test.py"); + const editor = EditorManager.getActiveEditor(); + editor.document.setText(""); + editor.setCursorPos({line: 0, ch: 0}); + typeAtCursor(editor, "pydef"); + + const result = CustomSnippetsHandler.getHints(editor, "f"); + const pydefHint = result.hints.find(function (h) { + return h.attr("data-val") === "pydef"; + }); + CustomSnippetsHandler.insertHint(pydefHint); + + // Template: "def ${1}(${2}):\n ${3}" - no default text on any stop + const selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); + const lineText = editor.document.getLine(0); + expect(lineText).toBe("def ():"); + }); + + it("should end the snippet session as soon as it lands on the final ${0} stop", async function () { const editor = await openCleanFile("test.js"); typeAtCursor(editor, "clg"); @@ -657,16 +678,13 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(clgHint); - // Navigate: ${1} -> ${0} - CustomSnippetsCursorManager.navigateToNextTabStop(); - expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); - - // Navigate past ${0} - should end session + // Navigate: "value" -> ${0}. Reaching the final stop ends the session right away + // (matching standard LSP/VS Code tab-stop semantics) - there is nothing left to tab to. CustomSnippetsCursorManager.navigateToNextTabStop(); expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeFalsy(); }); - it("should end session and remove all tab stop placeholders on endSnippetSession", async function () { + it("should leave already-expanded snippet text untouched when session ends early", async function () { const editor = await openCleanFile("test.js"); typeAtCursor(editor, "clg"); @@ -677,13 +695,17 @@ define(function (require, exports, module) { CustomSnippetsHandler.insertHint(clgHint); expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); + // Template: "console.log(${1:value});${0}" - the default text is substituted into + // the buffer at insertion time, so ending the session mid-way (still on the first + // stop) must not mutate the text at all, only stop navigation. + expect(editor.document.getText().trim()).toBe("console.log(value);"); + CustomSnippetsCursorManager.endSnippetSession(); expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeFalsy(); const fullText = editor.document.getText(); - expect(fullText).not.toContain("${1}"); - expect(fullText).not.toContain("${0}"); - expect(fullText.trim()).toBe("console.log();"); + expect(fullText).not.toContain("${"); + expect(fullText.trim()).toBe("console.log(value);"); }); it("should navigate through all tab stops in correct order for multi-stop snippet", async function () { @@ -696,34 +718,32 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // Template: "const ${1} = (${2}) => {\n ${3}\n};${0}" - // Verify full navigation order: ${1} -> ${2} -> ${3} -> ${0} + // Template: "const ${1:myFunction} = (${2:param}) => {\n ${3:body}\n};${0}" + // Verify full navigation order: myFunction -> param -> body -> ${0} (collapsed) let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("myFunction"); CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${2}"); + expect(selectedText).toBe("param"); CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${3}"); + expect(selectedText).toBe("body"); CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); - selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${0}"); + expect(selection.start).toEqual(selection.end); // ${0} is a collapsed caret - // Past ${0} - session ends - CustomSnippetsCursorManager.navigateToNextTabStop(); + // reaching the final stop ends the session right away - nothing left to tab to expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeFalsy(); }); - it("should remove all remaining tab stop placeholders when session ends early", async function () { + it("should leave already-expanded multi-stop text untouched when session ends early", async function () { const editor = await openCleanFile("test.js"); typeAtCursor(editor, "fnn"); @@ -733,14 +753,14 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // End session early at ${1} + // End session early, still on the first stop CustomSnippetsCursorManager.endSnippetSession(); const fullText = editor.document.getText(); - expect(fullText).not.toContain("${1}"); - expect(fullText).not.toContain("${2}"); - expect(fullText).not.toContain("${3}"); - expect(fullText).not.toContain("${0}"); + expect(fullText).not.toContain("${"); + expect(fullText).toContain("myFunction"); + expect(fullText).toContain("param"); + expect(fullText).toContain("body"); }); it("should handle key event for Tab navigation", async function () { @@ -755,7 +775,7 @@ define(function (require, exports, module) { let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("myFunction"); // Simulate Tab key via handleKeyEvent const KeyEvent = testWindow.require("utils/KeyEvent"); @@ -769,7 +789,7 @@ define(function (require, exports, module) { selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${2}"); + expect(selectedText).toBe("param"); expect(prevented).toBeTrue(); }); @@ -783,7 +803,7 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // Navigate forward first: ${1} -> ${2} + // Navigate forward first: "myFunction" -> "param" CustomSnippetsCursorManager.navigateToNextTabStop(); const KeyEvent = testWindow.require("utils/KeyEvent"); @@ -797,7 +817,7 @@ define(function (require, exports, module) { const selection = editor.getSelection(); const selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("myFunction"); expect(prevented).toBeTrue(); }); @@ -856,7 +876,7 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(fnnHint); - // Template: "const ${1} = (${2}) => {\n ${3}\n};${0}" + // Template: "const ${1:myFunction} = (${2:param}) => {\n ${3:body}\n};${0}" const line0 = editor.document.getLine(0); const line1 = editor.document.getLine(1); const line2 = editor.document.getLine(2); @@ -902,24 +922,23 @@ define(function (require, exports, module) { }); CustomSnippetsHandler.insertHint(divHint); - // Template: "
\n ${2}\n
${0}" + // Template: "
\n ${2:content}\n
${0}" let selection = editor.getSelection(); let selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${1}"); + expect(selectedText).toBe("className"); const firstLine = selection.start.line; - // Navigate to ${2} (second line) + // Navigate to "content" (second line) CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${2}"); + expect(selectedText).toBe("content"); expect(selection.start.line).toBeGreaterThan(firstLine); - // Navigate to ${0} (third line) + // Navigate to ${0} (third line, collapsed caret) CustomSnippetsCursorManager.navigateToNextTabStop(); selection = editor.getSelection(); - selectedText = editor.document.getRange(selection.start, selection.end); - expect(selectedText).toBe("${0}"); + expect(selection.start).toEqual(selection.end); }); }); @@ -1032,5 +1051,302 @@ define(function (require, exports, module) { expect(lineText).toContain("end"); }); }); + + // ================================================================ + // Test Suite: Default Snippets (function / arrow) - issue #618 + // ================================================================ + describe("Default Snippets", function () { + + afterEach(async function () { + if (CustomSnippetsCursorManager.isInSnippetSession()) { + CustomSnippetsCursorManager.endSnippetSession(); + } + await closeAllFiles(); + }); + + // "function" is a `prefixTrigger` snippet (see defaultSnippets.js / helper.js + // `hasExactMatchingSnippet`): the SAME single entry shows up as soon as the user has + // typed a leading prefix of it (2+ chars), not only once the full word is typed - and it + // must appear exactly once (not as several near-duplicate abbreviation-length entries). + ["fu", "fun", "func", "function"].forEach(function (typed) { + it("should offer exactly one 'function' hint when '" + typed + "' is typed", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, typed); + + const lastChar = typed.charAt(typed.length - 1); + expect(CustomSnippetsHandler.hasHints(editor, lastChar)).toBeTrue(); + + const result = CustomSnippetsHandler.getHints(editor, lastChar); + const functionHints = result.hints.filter(function (h) { + return h.attr("data-val") === "function"; + }); + expect(functionHints.length).toBe(1); + }); + }); + + it("should NOT offer the function snippet below the minimum prefix length", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "f"); + + expect(CustomSnippetsHandler.hasHints(editor, "f")).toBeFalse(); + }); + + it("should insert the default function snippet and cycle every stop", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "func"); + + const result = CustomSnippetsHandler.getHints(editor, "c"); + const hint = result.hints.find(function (h) { + return h.attr("data-val") === "function"; + }); + expect(hint).toBeTruthy(); + CustomSnippetsHandler.insertHint(hint); + + const fullText = editor.document.getText(); + expect(fullText).toContain("function name() {"); + expect(fullText).not.toContain("/**"); // no JSDoc - just the bare skeleton + + expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); + let selection = editor.getSelection(); + expect(editor.document.getRange(selection.start, selection.end)).toBe("name"); + + CustomSnippetsCursorManager.navigateToNextTabStop(); // function param - empty stop + selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); + + CustomSnippetsCursorManager.navigateToNextTabStop(); // $0 - body, collapsed caret + selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); + }); + + it("should indent the function body using the FILE's actual indent settings, not a fixed width", + async function () { + const Editor = testWindow.require("editor/Editor").Editor; + const editor = await openCleanFile("test.js"); + const fullPath = editor.document.file.fullPath; + const originalUseTabChar = Editor.getUseTabChar(fullPath); + const originalSpaceUnits = Editor.getSpaceUnits(fullPath); + + try { + Editor.setUseTabChar(false, fullPath); + Editor.setSpaceUnits(2, fullPath); // deliberately NOT the hardcoded old "4" + + typeAtCursor(editor, "func"); + const result = CustomSnippetsHandler.getHints(editor, "c"); + const hint = result.hints.find(function (h) { + return h.attr("data-val") === "function"; + }); + CustomSnippetsHandler.insertHint(hint); + + expect(editor.document.getLine(1)).toBe(" "); // 2-space body line, not 4 + } finally { + Editor.setUseTabChar(originalUseTabChar, fullPath); + Editor.setSpaceUnits(originalSpaceUnits, fullPath); + } + }); + + it("should indent the function body with a literal tab when the file is configured for tabs", + async function () { + const Editor = testWindow.require("editor/Editor").Editor; + const editor = await openCleanFile("test.js"); + const fullPath = editor.document.file.fullPath; + const originalUseTabChar = Editor.getUseTabChar(fullPath); + + try { + Editor.setUseTabChar(true, fullPath); + + typeAtCursor(editor, "func"); + const result = CustomSnippetsHandler.getHints(editor, "c"); + const hint = result.hints.find(function (h) { + return h.attr("data-val") === "function"; + }); + CustomSnippetsHandler.insertHint(hint); + + expect(editor.document.getLine(1)).toBe("\t"); + } finally { + Editor.setUseTabChar(originalUseTabChar, fullPath); + } + }); + + ["ar", "arr", "arrow"].forEach(function (typed) { + it("should offer exactly one 'arrow' hint when '" + typed + "' is typed", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, typed); + + const lastChar = typed.charAt(typed.length - 1); + expect(CustomSnippetsHandler.hasHints(editor, lastChar)).toBeTrue(); + + const result = CustomSnippetsHandler.getHints(editor, lastChar); + const arrowHints = result.hints.filter(function (h) { + return h.attr("data-val") === "arrow"; + }); + expect(arrowHints.length).toBe(1); + }); + }); + + it("should NOT offer the arrow snippet below the minimum prefix length", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "a"); + + expect(CustomSnippetsHandler.hasHints(editor, "a")).toBeFalse(); + }); + + it("should NOT offer the arrow snippet for '=>' (word-only triggers by design)", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "=>"); + + expect(CustomSnippetsHandler.hasHints(editor, ">")).toBeFalse(); + }); + + it("should insert the default 'arrow' snippet and cycle through every stop", async function () { + const editor = await openCleanFile("test.js"); + typeAtCursor(editor, "arrow"); + + const result = CustomSnippetsHandler.getHints(editor, "w"); + const hint = result.hints.find(function (h) { + return h.attr("data-val") === "arrow"; + }); + expect(hint).toBeTruthy(); + CustomSnippetsHandler.insertHint(hint); + + const fullText = editor.document.getText(); + expect(fullText).toContain("const name = () => {"); + expect(fullText).not.toContain("/**"); // no JSDoc - just the bare skeleton + + expect(CustomSnippetsCursorManager.isInSnippetSession()).toBeTrue(); + let selection = editor.getSelection(); + expect(editor.document.getRange(selection.start, selection.end)).toBe("name"); + + CustomSnippetsCursorManager.navigateToNextTabStop(); // arrow function param - empty stop + selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); + + CustomSnippetsCursorManager.navigateToNextTabStop(); // $0 - body, collapsed caret + selection = editor.getSelection(); + expect(selection.start).toEqual(selection.end); + }); + + it("should offer the PHP 'function' default in a .php file, scoped independently of JS", + async function () { + await openFile("test.php"); + const editor = EditorManager.getActiveEditor(); + // line 2 is the intentionally-blank line inside the tag in the fixture, so the whole file - including this blank line - stays + // in PHP mode; CodeMirror's php mode otherwise defaults to HTML outside ${2}${0}"; - const result = SnippetCursorManager.parseTemplateText(template); - expect(result.text).toBe(template); + it("should resolve every occurrence of INDENT_TOKEN, not just the first", function () { + Editor.setUseTabChar(false, spacesPath); + Editor.setSpaceUnits(4, spacesPath); + + const template = SnippetCursorManager.INDENT_TOKEN + "a\n" + + SnippetCursorManager.INDENT_TOKEN + SnippetCursorManager.INDENT_TOKEN + "b"; + const resolved = SnippetCursorManager.resolveIndentToken(template, mockEditorForPath(spacesPath)); + expect(resolved).toBe(" a\n b"); // 4 spaces, then 8 (two levels) + }); + + // interleaved set+assert per path below: these fake paths aren't within any real open + // project, so PreferencesManager can't give them a genuinely isolated path-scoped context + // here - each set reflects globally, so we check right after setting, not in a batch (see + // the integration suite for true multi-file isolation with real project files). + it("should resolve to the current global indent settings for a given fake path", function () { + Editor.setUseTabChar(false, spacesPath); + Editor.setSpaceUnits(2, spacesPath); + expect(SnippetCursorManager.getOneIndentUnit(mockEditorForPath(spacesPath))).toBe(" "); + + Editor.setUseTabChar(true, tabsPath); + expect(SnippetCursorManager.getOneIndentUnit(mockEditorForPath(tabsPath))).toBe("\t"); }); }); + // ===================================================================== + // Built-in default snippets: merged silently, never part of user data + // ===================================================================== + describe("Built-in defaults: silent merge (not in Global.SnippetHintsList or the panel)", function () { + let savedSnippetsList; + + beforeEach(function () { + savedSnippetsList = Global.SnippetHintsList.slice(); + Global.SnippetHintsList.length = 0; // simulate a user with zero saved snippets + Helper.rebuildOptimizedStructures(); + }); + + afterEach(function () { + Global.SnippetHintsList.length = 0; + savedSnippetsList.forEach(function (s) { Global.SnippetHintsList.push(s); }); + Helper.rebuildOptimizedStructures(); + }); + + it("should never appear in Global.SnippetHintsList itself - only in the merged/optimized view", + function () { + const ids = Global.SnippetHintsList.filter(function (s) { return s.id; }); + expect(ids.length).toBe(0); + }); + + it("should still be found by the matching engine (hasExactMatchingSnippet) with an empty user list", + function () { + const jsMockEditor = { + getLanguageForPosition: function () { + return { getId: function () { return "javascript"; } }; + }, + document: { file: { fullPath: "/test/file.js" } } + }; + expect(Helper.hasExactMatchingSnippet("function", jsMockEditor)).toBe(true); + expect(Helper.hasExactMatchingSnippet("arrow", jsMockEditor)).toBe(true); + }); + + it("should not be deletable/editable via driver.js, since it never touches the user's list", + function () { + // Global.SnippetHintsList is what driver.js's add/edit/delete code reads and writes - + // an empty list here means there is nothing for the panel to show or let the user + // delete for any of the built-ins, by construction. + expect(Global.SnippetHintsList.length).toBe(0); + }); + }); + // ===================================================================== // FilterSnippets: filterSnippets // ===================================================================== @@ -838,6 +1016,87 @@ define(function (require, exports, module) { const $hint = Helper.createHintItem("clg", "", ""); expect($hint.hasClass("custom-snippets-hint")).toBe(true); }); + + it("should attach the insertionKey as a data-insertion-key attribute when provided", function () { + const $hint = Helper.createHintItem("clg", "", "", "default-function"); + expect($hint.attr("data-insertion-key")).toBe("default-function"); + }); + + it("should not add a data-insertion-key attribute when insertionKey is omitted", function () { + const $hint = Helper.createHintItem("clg", "", ""); + expect($hint.attr("data-insertion-key")).toBeUndefined(); + }); + }); + + // ===================================================================== + // Helper: insertionKey / getSnippetByInsertionKey (O(1) insertion lookup) + // ===================================================================== + describe("insertionKey and getSnippetByInsertionKey", function () { + let savedSnippetsList; + + beforeEach(function () { + savedSnippetsList = Global.SnippetHintsList.slice(); + Global.SnippetHintsList.length = 0; + Global.SnippetHintsList.push({ + abbreviation: "myown", description: "user snippet", templateText: "x", fileExtension: "all" + }); + Helper.rebuildOptimizedStructures(); + }); + + afterEach(function () { + Global.SnippetHintsList.length = 0; + savedSnippetsList.forEach(function (s) { Global.SnippetHintsList.push(s); }); + Helper.rebuildOptimizedStructures(); + }); + + it("should key a built-in default by its stable `id`", function () { + const snippet = Helper.getSnippetByInsertionKey("default-function"); + expect(snippet).toBeTruthy(); + expect(snippet.abbreviation).toBe("function"); + }); + + it("should key a user snippet (no `id`) by its abbreviation", function () { + const snippet = Helper.getSnippetByInsertionKey("myown"); + expect(snippet).toBeTruthy(); + expect(snippet.description).toBe("user snippet"); + }); + + it("should return null for an unknown insertionKey", function () { + expect(Helper.getSnippetByInsertionKey("zzznonexistent")).toBeNull(); + }); + + it("should return the exact same object getMatchingSnippets would resolve for that language", + function () { + const jsMockEditor = { + getLanguageForPosition: function () { + return { getId: function () { return "javascript"; } }; + }, + document: { file: { fullPath: "/test/file.js" } } + }; + const matched = Helper.getMatchingSnippets("function", jsMockEditor) + .find(function (s) { return s.insertionKey === "default-function"; }); + const byKey = Helper.getSnippetByInsertionKey("default-function"); + expect(byKey).toBe(matched); // same object reference, not just equal content + }); + + it("should resolve 'function' to the PHP-scoped default (not JS) in a PHP file's hint list", + function () { + // "function" is shared by both the JS default (default-function) and the PHP + // default (default-function-php) - this is the actual disambiguation guarantee: + // whichever one getMatchingSnippets resolves for a PHP file must be the PHP one, + // and insertion (via its insertionKey) must return that exact object, never JS's. + const phpMockEditor = { + getLanguageForPosition: function () { + return { getId: function () { return "php"; } }; + }, + document: { file: { fullPath: "/test/file.php" } } + }; + const matched = Helper.getMatchingSnippets("function", phpMockEditor) + .find(function (s) { return s.abbreviationLower === "function"; }); + expect(matched.insertionKey).toBe("default-function-php"); + expect(Helper.getSnippetByInsertionKey(matched.insertionKey)).toBe(matched); + expect(Helper.getSnippetByInsertionKey(matched.insertionKey).fileExtension).toBe(".php"); + }); }); // ===================================================================== diff --git a/test/spec/TabstopManager-test.js b/test/spec/TabstopManager-test.js index d57dd81260..9b17a2d2a7 100644 --- a/test/spec/TabstopManager-test.js +++ b/test/spec/TabstopManager-test.js @@ -260,6 +260,53 @@ define(function (require, exports, module) { expect(TabstopManager.hasActiveSession()).toBe(false); }); + describe("ending the session when the user moves on", function () { + it("should end the session when the cursor moves outside the snippet's lines", function () { + createTestEditor("some other line here\nsome other line here\n"); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + expect(TabstopManager.hasActiveSession()).toBe(true); + + // simulate the user clicking far away, outside the snippet's lines entirely + myEditor.setCursorPos({ line: 2, ch: 5 }); + expect(TabstopManager.hasActiveSession()).toBe(false); + }); + + it("should NOT end the session for cursor movement that stays within the snippet's lines", + function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + // move within the same line, not via Tab (e.g. arrow-key navigation) + myEditor.setCursorPos({ line: 0, ch: 0 }); + expect(TabstopManager.hasActiveSession()).toBe(true); + }); + + it("should end the session on a multi-cursor selection", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + expect(TabstopManager.hasActiveSession()).toBe(true); + + myEditor.setSelections([ + { start: { line: 0, ch: 0 }, end: { line: 0, ch: 1 } }, + { start: { line: 0, ch: 2 }, end: { line: 0, ch: 3 } } + ]); + expect(TabstopManager.hasActiveSession()).toBe(false); + }); + + it("should not unexpectedly jump the cursor back on a later Tab after the session ended", + function () { + createTestEditor("some other line here\nsome other line here\nyet another line\n"); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + myEditor.setCursorPos({ line: 2, ch: 5 }); // moves away, ends the session + expect(TabstopManager.hasActiveSession()).toBe(false); + + // a stray Tab here must not resurrect/navigate the old session + TabstopManager.goToNextStop(); + expect(TabstopManager.hasActiveSession()).toBe(false); + const cursor = myEditor.getCursorPos(); + expect([cursor.line, cursor.ch]).toEqual([2, 5]); // cursor stayed put + }); + }); + it("should keep stops correct when text is inserted above (markers follow edits)", function () { createTestEditor(""); TabstopManager.insertSnippet(myEditor, "fn(${1:a}, ${2:b})", ORIGIN, ORIGIN); @@ -277,6 +324,123 @@ define(function (require, exports, module) { TabstopManager.insertSnippet(myEditor, "log($1)", { line: 0, ch: 4 }, { line: 0, ch: 7 }); expect(myDocument.getText()).toBe("foo.log()baz"); }); + + // goToNextStop/goToPreviousStop are the exported primitives other features (e.g. Custom + // Snippets' snippetCursorManager.js) drive session navigation with directly, rather than + // going through the CodeMirror keymap - test them called directly, not just via Tab/Shift-Tab. + describe("goToNextStop / goToPreviousStop (direct API, not via keymap)", function () { + it("should advance to the next stop when called directly", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + expect(myEditor.getSelectedText()).toBe("a"); + + TabstopManager.goToNextStop(); + expect(myEditor.getSelectedText()).toBe("b"); + expect(TabstopManager.hasActiveSession()).toBe(true); + }); + + it("should go back to the previous stop when called directly", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + TabstopManager.goToNextStop(); + expect(myEditor.getSelectedText()).toBe("b"); + + TabstopManager.goToPreviousStop(); + expect(myEditor.getSelectedText()).toBe("a"); + expect(TabstopManager.hasActiveSession()).toBe(true); + }); + + it("should end the session when goToNextStop lands on the final $0 stop", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + TabstopManager.goToNextStop(); // -> b + TabstopManager.goToNextStop(); // -> $0, final stop + expect(myEditor.getSelectedText()).toBe(""); + expect(TabstopManager.hasActiveSession()).toBe(false); + }); + + it("should be a no-op when there is no active session", function () { + createTestEditor("plain text, no snippet inserted"); + expect(TabstopManager.hasActiveSession()).toBe(false); + expect(function () { + TabstopManager.goToNextStop(); + TabstopManager.goToPreviousStop(); + }).not.toThrow(); + expect(TabstopManager.hasActiveSession()).toBe(false); + }); + + it("should behave identically to pressing Tab/Shift-Tab in the real editor", function () { + createTestEditor(""); + // note the trailing $0: without a final stop after "c", "c" itself would BE the + // final stop and landing on it would end the session immediately (see the + // "should end the session..." test above) - this test wants to stay mid-session + // across every move, so "c" must not be the last stop. + TabstopManager.insertSnippet(myEditor, "fn(${1:a}, ${2:b}, ${3:c})$0", ORIGIN, ORIGIN); + + TabstopManager.goToNextStop(); + pressTab(); + expect(myEditor.getSelectedText()).toBe("c"); // two forward moves: a -> b -> c + expect(TabstopManager.hasActiveSession()).toBe(true); // $0 still ahead + + TabstopManager.goToPreviousStop(); + pressShiftTab(); + expect(myEditor.getSelectedText()).toBe("a"); // two backward moves: c -> b -> a + }); + }); + + // Visual boxing while a session is active - see Editor.getMarkOptionTabstopOutline/ + // getMarkOptionTabstopOutlineActive and brackets.less .editor-text-tabstop-outline*. + describe("visual outline markers", function () { + function outlineMarks(className) { + return myEditor._codeMirror.getAllMarks().filter(function (m) { + return m.className === className; + }); + } + + it("should give every range-based stop a subdued outline marker on insertion", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + // 2 range stops (a, b) get the subdued outline; $0 (zero-width, no default) does not + expect(outlineMarks("editor-text-tabstop-outline").length).toBe(2); + }); + + it("should give only the CURRENTLY selected stop the bold active outline", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + expect(outlineMarks("editor-text-tabstop-outline-active").length).toBe(1); + + pressTab(); + expect(outlineMarks("editor-text-tabstop-outline-active").length).toBe(1); + }); + + it("should move the active outline as the user tabs, keeping the subdued ones in place", + function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + expect(outlineMarks("editor-text-tabstop-outline-active")[0].find().from.ch).toBe(0); // "a" + + pressTab(); + expect(outlineMarks("editor-text-tabstop-outline-active")[0].find().from.ch).toBe(2); // "b" + // both subdued outlines (a and b) are still there, untouched + expect(outlineMarks("editor-text-tabstop-outline").length).toBe(2); + }); + + it("should not give a zero-width stop (no default text) any active outline", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} $2 $0", ORIGIN, ORIGIN); + pressTab(); // -> $2, a bare zero-width stop + expect(myEditor.getSelectedText()).toBe(""); + expect(outlineMarks("editor-text-tabstop-outline-active").length).toBe(0); + }); + + it("should clear all outline markers when the session ends", function () { + createTestEditor(""); + TabstopManager.insertSnippet(myEditor, "${1:a} ${2:b} $0", ORIGIN, ORIGIN); + TabstopManager.endSession(); + expect(outlineMarks("editor-text-tabstop-outline").length).toBe(0); + expect(outlineMarks("editor-text-tabstop-outline-active").length).toBe(0); + }); + }); }); }); });