From 4d4b78910d7e28fe6c3cc9e278877b5e0cc46e14 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 1 Jul 2026 15:55:11 +0530 Subject: [PATCH 1/7] feat(lsp): JS code intelligence inside HTML + + From 55362ebf5f7dc7701aa37c93b99e002bd0c8909a Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 1 Jul 2026 15:55:20 +0530 Subject: [PATCH 2/7] fix(codehints): tighten the gap between the hint list and the docs popup The documentation popup anchored at list.right + 6px, which read as a wide gap between the two floating boxes. Reduce it to 2px so the docs sit snug against the completion list. --- src/languageTools/DefaultProviders.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index 063c043fd0..5fc2e5df14 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -91,7 +91,7 @@ define(function (require, exports, module) { if (anchor.width === 0 && anchor.height === 0) { return; // list not laid out yet (mid-reflow) - try again next frame } - var GAP = 6, + var GAP = 2, winW = $(window).width(), winH = $(window).height(), pw = $lspDocPopup.outerWidth(), From ad032141f34a4957094bd919d9e403b83223fb24 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 1 Jul 2026 16:09:18 +0530 Subject: [PATCH 3/7] fix(codehints): don't show a docs popup that just echoes the hint For keyword/plain-identifier completions vtsls returns the label itself as `detail` (`this` -> "this", `throw` -> "throw"), so the docs popup rendered a box containing only that word - a verbatim echo of the highlighted hint row. _docPopupHtml now treats a detail that merely restates the label as "no signature"; with no documentation either it returns "", and _showDocPopup hides the popup entirely. Items with a real signature (function foo(): void) or actual documentation are unaffected. Also drops the hint-list-to-docs-popup gap to 0 so they sit flush. Covered in the integration:TypeScript LSP suite: the echo cases, a meaningful signature, and docs-present-with-a-trivial-signature. --- .../default/TypeScriptSupport/unittests.js | 14 ++++++++++++++ src/languageTools/DefaultProviders.js | 15 ++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index a403a447b3..ccc5eed587 100644 --- a/src/extensions/default/TypeScriptSupport/unittests.js +++ b/src/extensions/default/TypeScriptSupport/unittests.js @@ -267,6 +267,20 @@ define(function (require, exports, module) { "close embedded.html"); }, 45000); + // The docs popup beside the hint list must stay empty when its only content would be a + // signature that just repeats the item's label (vtsls returns the label itself as `detail` + // for keywords/plain identifiers like `this`/`throw`) - otherwise it echoes the hint row. + it("suppresses the docs popup that would only echo the hint label", function () { + const docHtml = testWindow.brackets.getModule("languageTools/DefaultProviders")._docPopupHtml; + expect(docHtml({ label: "this", detail: "this" })).toBe(""); + expect(docHtml({ label: "throw", detail: "throw", documentation: "" })).toBe(""); + expect(docHtml({ label: "x" })).toBe(""); // no detail, no docs + // A real signature or actual documentation still renders. + expect(docHtml({ label: "foo", detail: "function foo(): void" }).length).toBeGreaterThan(0); + expect(docHtml({ label: "this", detail: "this", documentation: "The context." })) + .toContain("context"); + }); + // ----- hover quick-actions (Go to Definition / Find Usages) ------------------------------- // Query the hover popover at a position the same way QuickViewManager does internally. diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index 5fc2e5df14..65310d569b 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -91,7 +91,7 @@ define(function (require, exports, module) { if (anchor.width === 0 && anchor.height === 0) { return; // list not laid out yet (mid-reflow) - try again next frame } - var GAP = 2, + var GAP = 0, winW = $(window).width(), winH = $(window).height(), pw = $lspDocPopup.outerWidth(), @@ -182,7 +182,13 @@ define(function (require, exports, module) { function _docPopupHtml(token) { var parts = []; - if (token.detail) { + // The signature is worth a popup only when it adds something beyond the item's own name. + // vtsls returns the label itself as `detail` for keywords/plain identifiers (`this` -> "this", + // `throw` -> "throw"), which would make the popup a verbatim echo of the hint row - so a detail + // that merely restates the label counts as "no signature". + var label = (token.label || "").trim(); + var detail = (token.detail || "").trim(); + if (detail && detail !== label) { try { parts.push(_highlightCode(marked.parse("```" + _signatureLang() + "\n" + token.detail + "\n```"))); } catch (e) { @@ -195,9 +201,7 @@ define(function (require, exports, module) { parts.push(docHtml); } - if (!token.detail && !docHtml) { - return ""; - } + // Empty -> "" so _showDocPopup hides the popup entirely instead of echoing the hint row. return parts.join(""); } @@ -1020,4 +1024,5 @@ define(function (require, exports, module) { exports.LintingProvider = LintingProvider; exports.ReferencesProvider = ReferencesProvider; exports.serverRespToSearchModelFormat = serverRespToSearchModelFormat; + exports._docPopupHtml = _docPopupHtml; // exposed for unit tests }); From 9f0c810984dd652ef5e80d2b98e0385573fc7045 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 2 Jul 2026 09:01:20 +0530 Subject: [PATCH 4/7] perf(lsp): incremental HTML->JS script view via CodeMirror markers The JS view of an HTML doc (script blocks verbatim, everything else blanked to spaces, 1:1 positions) was rebuilt by HTMLUtils.findBlocks on every debounced sync AND every flush() before a feature request - i.e. an O(document) re-tokenize potentially per keystroke on large HTML files. New core module languageTools/HtmlJsView keeps an invisible text marker on each inside a script is invisible to markers), a marker-boundary touch, or a multi-record change batch. An idle-time findBlocks parity check self-heals (and console.warns) any drift, so the view can never silently diverge. extract(editor) keeps the old stateless contract, so DocumentSync is untouched. HtmlJsView is preloaded in brackets.js/SpecRunner.js: languageTools/* is otherwise lazy-loaded, and the extension's synchronous getModule at load time crashed the whole extension ("Module name ... has not been loaded yet"). Tests (integration:TypeScript LSP) generate a ~2.6k-line, 120-"); + } + p.push("", ""); + return p.join("\n"); + } + + // Index of the first line containing `substr` in the (current) document. + function lineOf(substr) { + return bigEditor.document.getText().split("\n").findIndex(function (l) { + return l.indexOf(substr) !== -1; + }); + } + + beforeAll(async function () { + HtmlJsView = testWindow.brackets.getModule("languageTools/HtmlJsView"); + const FileSystem = testWindow.brackets.test.FileSystem; + const bigProject = await SpecRunnerUtils.getTempTestDirectory(testRootSpec + "html"); + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(bigProject, "big.html"), buildLargeHtml(120, 15), FileSystem)); + await SpecRunnerUtils.loadProjectInTestWindow(bigProject); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["big.html"]), "open big.html"); + bigEditor = EditorManager.getCurrentFullEditor(); + }, 60000); + + afterAll(async function () { + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), + "close big.html"); + await SpecRunnerUtils.removeTempDirectory(); + }, 30000); + + it("provides member completions inside a edit and stays correct", function () { + HtmlJsView.extract(bigEditor); + HtmlJsView._resetStats(); + const jsLine = lineOf("var block7"); + bigEditor.document.replaceRange(" keeps its (zero-length) marker, matching + // what findBlocks reports; inclusive*:false so edits exactly at a boundary fall outside and force + // a recompile (the seam is only ever defined by findBlocks). + const MARK_TYPE = "htmlJsScriptRange"; + const MARK_OPTIONS = { inclusiveLeft: false, inclusiveRight: false, clearWhenEmpty: false }; + + // Re-verify against a fresh findBlocks this long after the last edit (typing pause). A backstop + // only: interior edits keep markers and findBlocks in lock-step, and structural edits recompile. + const IDLE_VERIFY_MS = 650; + + const cache = new Map(); // doc.file.fullPath -> state + const stats = { recompiles: 0, patches: 0, builds: 0, verifications: 0, mismatches: 0 }; + + function _cmpPos(a, b) { + return (a.line - b.line) || (a.ch - b.ch); + } + + function _editorFor(doc) { + return doc._masterEditor || EditorManager.getActiveEditor() || null; + } + + function _hasAngle(lines) { + if (!lines) { + return false; + } + for (let i = 0; i < lines.length; i++) { + if (lines[i].indexOf("<") !== -1 || lines[i].indexOf(">") !== -1) { + return true; + } + } + return false; + } + + /** + * Build the blanked JS view from a set of JS ranges (from findBlocks blocks or from markers). + * Byte-identical to the legacy _extractHtmlJs regardless of the range source. + * @param {Editor} editor + * @param {Array<{start:{line,ch}, end:{line,ch}}>} ranges - sorted ascending, non-overlapping + * @return {string} + */ + function _buildViewFromRanges(editor, ranges) { + const doc = editor.document; + const lastLine = editor.lineCount() - 1; + const eof = { line: lastLine, ch: doc.getLine(lastLine).length }; + let view = ""; + let from = { line: 0, ch: 0 }; + function blank(rangeStart, rangeEnd) { + return doc.getRange(rangeStart, rangeEnd).replace(/[^\n]/g, " "); // keep \n, blank the rest + } + ranges.forEach(function (r) { + view += blank(from, r.start) + doc.getRange(r.start, r.end); + from = r.end; + }); + view += blank(from, eof); // trailing markup, so the view length matches the HTML exactly + return view; + } + + /** + * Full, authoritative build straight from findBlocks (no markers, no cache). Source of truth for + * recompile and the self-heal parity check. + * @param {Editor} editor + * @return {string} + */ + function _extractFull(editor) { + const blocks = HTMLUtils.findBlocks(editor, "javascript"); + return _buildViewFromRanges(editor, blocks.map(function (b) { + return { start: b.start, end: b.end }; + })); + } + + // Current marker ranges, sorted by start position. + function _rangesFromMarkers(editor) { + const ranges = []; + editor.getAllMarks(MARK_TYPE).forEach(function (m) { + const r = m.find(); + if (r) { + ranges.push({ start: r.from, end: r.to }); + } + }); + ranges.sort(function (a, b) { + return _cmpPos(a.start, b.start); + }); + return ranges; + } + + // Re-run findBlocks, drop and recreate the markers from the fresh blocks, and return the view. + function _remark(editor) { + editor.clearAllMarks(MARK_TYPE); + const blocks = HTMLUtils.findBlocks(editor, "javascript"); + blocks.forEach(function (b) { + editor.markText(MARK_TYPE, b.start, b.end, MARK_OPTIONS); + }); + return _buildViewFromRanges(editor, blocks.map(function (b) { + return { start: b.start, end: b.end }; + })); + } + + function _recompile(state, editor) { + state.view = _remark(editor); + state.valid = true; + state.version++; + state.builtVersion = state.version; + stats.recompiles++; + } + + /** + * True when a single change record can be carried by the markers alone (no restructure): the edit + * is strictly interior to one "); - } - p.push("", ""); - return p.join("\n"); - } - - // Index of the first line containing `substr` in the (current) document. - function lineOf(substr) { - return bigEditor.document.getText().split("\n").findIndex(function (l) { - return l.indexOf(substr) !== -1; - }); - } - - beforeAll(async function () { - HtmlJsView = testWindow.brackets.getModule("languageTools/HtmlJsView"); - const FileSystem = testWindow.brackets.test.FileSystem; - const bigProject = await SpecRunnerUtils.getTempTestDirectory(testRootSpec + "html"); - await jsPromise(SpecRunnerUtils.createTextFile( - path.join(bigProject, "big.html"), buildLargeHtml(120, 15), FileSystem)); - await SpecRunnerUtils.loadProjectInTestWindow(bigProject); - await awaitsForDone(SpecRunnerUtils.openProjectFiles(["big.html"]), "open big.html"); - bigEditor = EditorManager.getCurrentFullEditor(); - }, 60000); - - afterAll(async function () { - await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE, { _forceClose: true }), - "close big.html"); - await SpecRunnerUtils.removeTempDirectory(); - }, 30000); - - it("provides member completions inside a edit and stays correct", function () { - HtmlJsView.extract(bigEditor); - HtmlJsView._resetStats(); - const jsLine = lineOf("var block7"); - bigEditor.document.replaceRange(" keeps its (zero-length) marker, matching - // what findBlocks reports; inclusive*:false so edits exactly at a boundary fall outside and force - // a recompile (the seam is only ever defined by findBlocks). - const MARK_TYPE = "htmlJsScriptRange"; - const MARK_OPTIONS = { inclusiveLeft: false, inclusiveRight: false, clearWhenEmpty: false }; - - // Re-verify against a fresh findBlocks this long after the last edit (typing pause). A backstop - // only: interior edits keep markers and findBlocks in lock-step, and structural edits recompile. - const IDLE_VERIFY_MS = 650; - - const cache = new Map(); // doc.file.fullPath -> state - const stats = { recompiles: 0, patches: 0, builds: 0, verifications: 0, mismatches: 0 }; - - function _cmpPos(a, b) { - return (a.line - b.line) || (a.ch - b.ch); - } - - function _editorFor(doc) { - return doc._masterEditor || EditorManager.getActiveEditor() || null; - } - - function _hasAngle(lines) { - if (!lines) { - return false; - } - for (let i = 0; i < lines.length; i++) { - if (lines[i].indexOf("<") !== -1 || lines[i].indexOf(">") !== -1) { - return true; - } - } - return false; - } - - /** - * Build the blanked JS view from a set of JS ranges (from findBlocks blocks or from markers). - * Byte-identical to the legacy _extractHtmlJs regardless of the range source. - * @param {Editor} editor - * @param {Array<{start:{line,ch}, end:{line,ch}}>} ranges - sorted ascending, non-overlapping - * @return {string} - */ - function _buildViewFromRanges(editor, ranges) { - const doc = editor.document; - const lastLine = editor.lineCount() - 1; - const eof = { line: lastLine, ch: doc.getLine(lastLine).length }; - let view = ""; - let from = { line: 0, ch: 0 }; - function blank(rangeStart, rangeEnd) { - return doc.getRange(rangeStart, rangeEnd).replace(/[^\n]/g, " "); // keep \n, blank the rest - } - ranges.forEach(function (r) { - view += blank(from, r.start) + doc.getRange(r.start, r.end); - from = r.end; - }); - view += blank(from, eof); // trailing markup, so the view length matches the HTML exactly - return view; - } - - /** - * Full, authoritative build straight from findBlocks (no markers, no cache). Source of truth for - * recompile and the self-heal parity check. - * @param {Editor} editor - * @return {string} - */ - function _extractFull(editor) { - const blocks = HTMLUtils.findBlocks(editor, "javascript"); - return _buildViewFromRanges(editor, blocks.map(function (b) { - return { start: b.start, end: b.end }; - })); - } - - // Current marker ranges, sorted by start position. - function _rangesFromMarkers(editor) { - const ranges = []; - editor.getAllMarks(MARK_TYPE).forEach(function (m) { - const r = m.find(); - if (r) { - ranges.push({ start: r.from, end: r.to }); - } - }); - ranges.sort(function (a, b) { - return _cmpPos(a.start, b.start); - }); - return ranges; - } - - // Re-run findBlocks, drop and recreate the markers from the fresh blocks, and return the view. - function _remark(editor) { - editor.clearAllMarks(MARK_TYPE); - const blocks = HTMLUtils.findBlocks(editor, "javascript"); - blocks.forEach(function (b) { - editor.markText(MARK_TYPE, b.start, b.end, MARK_OPTIONS); - }); - return _buildViewFromRanges(editor, blocks.map(function (b) { - return { start: b.start, end: b.end }; - })); - } - - function _recompile(state, editor) { - state.view = _remark(editor); - state.valid = true; - state.version++; - state.builtVersion = state.version; - stats.recompiles++; - } - - /** - * True when a single change record can be carried by the markers alone (no restructure): the edit - * is strictly interior to one