From 24e5a7cab09120fb279e3c644e12cc41a629a50d Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 27 Jun 2026 12:17:55 +0530 Subject: [PATCH 1/9] feat(lsp): JSHint defers to the language server; expose server lifecycle events On desktop the TypeScript language server (vtsls) always lints JS, yet JSHint still also linted plain-JS projects (it only deferred to ESLint), so the Problems panel showed duplicate diagnostics. JSHint now defers to the language server the same way it defers to ESLint: it runs only when it is the sole JS diagnostics source, when a .jshintrc opts it in, or as a fallback when no server is running. ESLint and the language server keep coexisting (rules vs. compiler), like VS Code. Browser is unchanged - no server there, so JSHint stays the JS linter. - LSPClient: add isLintingProviderActive(languageId); make the module an EventDispatcher exposing EVENT_LANGUAGE_SERVER_STARTED / _STOPPED for extensions. A new _announceServerStarted() fires STARTED and re-runs inspection on every path that brings a server up (initial start, restart, crash auto-restart) - so linters that defer re-evaluate even on a clean file, whose empty publishDiagnostics wouldn't otherwise trigger a re-run. - JSHint: resolve a desktop-only LSPClient handle and defer in canInspect, deriving the language id from LanguageManager. - Tests: new "defers JSHint to the language server in a plain JS project" spec; update the two ESLint-failure-fallback specs to expect the LSP (not JSHint) to cover the file on desktop. --- src/extensions/default/JSLint/JSHint.js | 30 ++++++++- .../default/TypeScriptSupport/unittests.js | 29 +++++++- src/languageTools/LSPClient.js | 67 ++++++++++++++++++- test/spec/Extn-ESLint-integ-test.js | 38 ++++++++--- 4 files changed, 148 insertions(+), 16 deletions(-) diff --git a/src/extensions/default/JSLint/JSHint.js b/src/extensions/default/JSLint/JSHint.js index 83e569ce4b..ae62d95674 100644 --- a/src/extensions/default/JSLint/JSHint.js +++ b/src/extensions/default/JSLint/JSHint.js @@ -40,8 +40,29 @@ define(function (require, exports, module) { FileSystem = brackets.getModule("filesystem/FileSystem"), IndexingWorker = brackets.getModule("worker/IndexingWorker"), Metrics = brackets.getModule("utils/Metrics"), + LanguageManager = brackets.getModule("language/LanguageManager"), ESLint = require("./ESLint"); + // JSHint defers to the TypeScript language service (vtsls) when it is linting the file (see + // canInspect). Its module (languageTools/LSPClient) is loaded lazily and only on desktop - it's + // intentionally kept out of the browser build - so resolve a handle there to query its state. When + // a server starts/stops, LSPClient re-runs inspection itself, so JSHint doesn't track that here. In + // the browser there is no LSP, so _lspClient stays null and JSHint keeps linting as before. + let _lspClient = null; + if (Phoenix.isNativeApp) { + brackets.getModule(["languageTools/LSPClient"], function (LSPClient) { + _lspClient = LSPClient; + }); + } + + function _lspLintingActive(fullPath) { + if (!_lspClient) { + return false; + } + const language = LanguageManager.getLanguageForPath(fullPath); + return !!language && _lspClient.isLintingProviderActive(language.getId()); + } + if(Phoenix.isTestWindow) { IndexingWorker.on("JsHint_extension_Loaded", ()=>{ window._JsHintExtensionReadyToIntegTest = true; @@ -269,9 +290,12 @@ define(function (require, exports, module) { scanFileAsync: lintOneFile, canInspect: function (fullPath) { return !prefs.get(PREFS_JSHINT_DISABLED) && fullPath && !fullPath.endsWith(".min.js") - && (isJSHintConfigActive() || !ESLint.isESLintActive()); - // if there is no linter, then we use jsHint as the default linter as it works in browser and native apps. - // remove ESLint.isESLintActive() once we add typescript language service that supports browser. + && (isJSHintConfigActive() + || (!ESLint.isESLintActive() && !_lspLintingActive(fullPath))); + // JSHint is the default JS linter only when nothing richer is linting the file: it defers + // to ESLint and to the TypeScript language service (vtsls, desktop). It still runs when a + // .jshintrc opts in explicitly, and as a fallback when neither is active (e.g. the browser, + // where there is no LSP, or when the language server isn't running). } }); diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index 097a97721e..551edd9381 100644 --- a/src/extensions/default/TypeScriptSupport/unittests.js +++ b/src/extensions/default/TypeScriptSupport/unittests.js @@ -18,7 +18,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, awaitsFor, awaitsForDone */ +/*global describe, it, expect, beforeAll, afterAll, awaitsFor, awaitsForDone, path, jsPromise */ define(function (require, exports, module) { @@ -125,6 +125,33 @@ define(function (require, exports, module) { expect(panelText().includes(IMPLICIT_ANY_MESSAGE)).toBe(false); }, 75000); + it("defers JSHint to the language server in a plain JS project", async function () { + // The language server is the JS linter on desktop, so the legacy JSHint linter must defer + // to it: the file below would draw a "Missing semicolon. jshint (W033)" nag if JSHint ran, + // but it must not appear. (No .jshintrc, so JSHint isn't explicitly opted in.) + const FileSystem = testWindow.brackets.test.FileSystem; + const LSPClient = await new Promise(function (resolve) { + testWindow.brackets.getModule(["languageTools/LSPClient"], resolve); + }); + // A plain JS project (js-plain has no jsconfig/tsconfig). Add a file JSHint would flag. + const projectPath = await SpecRunnerUtils.getTempTestDirectory(testRootSpec + "js-plain"); + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(projectPath, "missing-semicolon.js"), 'console.log("hello")\n', FileSystem)); + await SpecRunnerUtils.loadProjectInTestWindow(projectPath); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["missing-semicolon.js"]), + "open missing-semicolon.js"); + + // The deferral only holds while the server is the active JS linter - wait for that. + await awaitsFor(function () { + return LSPClient.isLintingProviderActive("javascript"); + }, "the language server to be the active JS linter", 30000); + // The file is valid JS, so the server reports nothing; let inspection settle clean. + await awaitsFor(function () { + return $("#status-inspection").hasClass("inspection-valid"); + }, "inspection to settle with no problems", 30000); + expect(panelText().toLowerCase().includes("jshint")).toBe(false); + }, 45000); + // ----- 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/LSPClient.js b/src/languageTools/LSPClient.js index 6e4441e10a..4478fdf117 100644 --- a/src/languageTools/LSPClient.js +++ b/src/languageTools/LSPClient.js @@ -64,7 +64,28 @@ define(function (require, exports, module) { JumpToDefManager = require("features/JumpToDefManager"), FindReferencesManager = require("features/FindReferencesManager"), QuickViewManager = require("features/QuickViewManager"), - CodeInspection = require("language/CodeInspection"); + CodeInspection = require("language/CodeInspection"), + EventDispatcher = require("utils/EventDispatcher"); + + EventDispatcher.makeEventDispatcher(exports); + + /** + * Fired when a language server has finished (re)starting and is now serving/linting its languages. + * Handler args: `{ serverId, languages }`. Exposed for extensions that want to react to a server + * becoming available - it's loaded lazily, so they can't observe its startup directly. + * @const {string} + */ + const EVENT_LANGUAGE_SERVER_STARTED = "languageServerStarted"; + + /** + * Fired when a language server's process has stopped. Note a normal project switch does NOT stop + * the server - it is repointed in place via workspace/didChangeWorkspaceFolders. This fires only + * when the process is actually recycled: the restart fallback (for servers that can't change + * workspace folders live) and manual restarts, each followed by EVENT_LANGUAGE_SERVER_STARTED once + * back up. Handler args: `{ serverId, languages }`. + * @const {string} + */ + const EVENT_LANGUAGE_SERVER_STOPPED = "languageServerStopped"; const LSP_CONNECTOR_ID = "ph-lsp"; // Relative path required on the node side (resolved from src-node/utils.js). Lazy-loads the @@ -225,6 +246,7 @@ define(function (require, exports, module) { } _startAndInit(client).then(function () { client._crashCount = 0; + _announceServerStarted(client); DocumentSync.openSupportedDocuments(client); }).catch(function (err) { console.error("[LSP] auto-restart failed", client.serverId, err && (err.message || err)); @@ -622,6 +644,21 @@ define(function (require, exports, module) { }); } + /** + * Announce that `client`'s server has (re)started and is now serving its languages: notify + * listeners (EVENT_LANGUAGE_SERVER_STARTED) and re-run inspection so any linter that defers to a + * language server (e.g. JSHint -> the TS service) drops its now-redundant results - even on a + * clean file, where the server's empty publishDiagnostics wouldn't trigger a re-run. Called from + * every path that brings a server up: initial registration, restart, and crash auto-restart. + */ + function _announceServerStarted(client) { + exports.trigger(EVENT_LANGUAGE_SERVER_STARTED, { + serverId: client.serverId, + languages: client.languages + }); + CodeInspection.requestRun(); + } + /** * Register and start a language server, wiring all providers into the editor. * @@ -654,6 +691,7 @@ define(function (require, exports, module) { DocumentSync.openSupportedDocuments(client); DocumentHighlight.init(); DocumentHighlight.registerClient(client); + _announceServerStarted(client); return client; } catch (err) { console.error("[LSP] failed to start server", config.serverId, err && (err.message || err)); @@ -745,6 +783,7 @@ define(function (require, exports, module) { await stopServerProcess(client); try { await _startAndInit(client); + _announceServerStarted(client); DocumentSync.openSupportedDocuments(client); // The find-references command's enabled state is computed on file switch; on a project // switch that happens while the server is still restarting (capabilities not yet @@ -782,6 +821,29 @@ define(function (require, exports, module) { } await conn.execPeer("stopServer", { serverId: client.serverId }); client._stopping = false; + exports.trigger(EVENT_LANGUAGE_SERVER_STOPPED, { + serverId: client.serverId, + languages: client.languages + }); + } + + /** + * Whether a successfully-initialised language server is currently providing diagnostics + * (linting) for the given Phoenix language id. Gated on `capabilities` - which is only set + * after a successful `initialize` - so a server that failed to start does not suppress a + * fallback linter (e.g. JSHint). Returns false in the browser, where no servers are registered. + * + * @param {string} languageId - Phoenix language id (e.g. "javascript") + * @return {boolean} + */ + function isLintingProviderActive(languageId) { + for (const client of clients.values()) { + if (client.lintingProvider && client.capabilities && + client.languages && client.languages.indexOf(languageId) !== -1) { + return true; + } + } + return false; } exports.registerLanguageServer = registerLanguageServer; @@ -789,4 +851,7 @@ define(function (require, exports, module) { exports.changeWorkspaceRoot = changeWorkspaceRoot; exports.pathToServerUri = pathToServerUri; exports.serverUriToVfsUri = serverUriToVfsUri; + exports.isLintingProviderActive = isLintingProviderActive; + exports.EVENT_LANGUAGE_SERVER_STARTED = EVENT_LANGUAGE_SERVER_STARTED; + exports.EVENT_LANGUAGE_SERVER_STOPPED = EVENT_LANGUAGE_SERVER_STOPPED; }); diff --git a/test/spec/Extn-ESLint-integ-test.js b/test/spec/Extn-ESLint-integ-test.js index 0317659e94..44ff01065a 100644 --- a/test/spec/Extn-ESLint-integ-test.js +++ b/test/spec/Extn-ESLint-integ-test.js @@ -147,17 +147,31 @@ define(function (require, exports, module) { await _waitForProblemsPanelVisible(true); } - it("should show JSHint in desktop app if ESLint load failed for project", async function () { + // Wait until the TypeScript language server is the active JS linter. On desktop JSHint defers + // to it, so assertions about JSHint being absent are only meaningful once it's up. + async function _waitForLSPLintingJS() { + const LSPClient = await new Promise(function (resolve) { + testWindow.brackets.getModule(["languageTools/LSPClient"], resolve); + }); + await awaitsFor(function () { + return LSPClient.isLintingProviderActive("javascript"); + }, "the language server to be linting JavaScript", 30000); + } + + it("should defer JSHint to the LSP even when ESLint load failed (desktop)", async function () { await SpecRunnerUtils.parkProject(); await _openSimpleES6Project(); + // ESLint can't load (no node_modules) -> it surfaces the npm-install message... await awaitsFor(async ()=>{ await _fileSwitcherroForESLintFailDetection(); return $("#problems-panel").text().includes(Strings.DESCRIPTION_ESLINT_DO_NPM_INSTALL); }, "ESLint error to be shown", 3000, 300); - await awaitsFor(()=>{ - return $("#problems-panel").text().includes(JSHintErrorES6Error_js); - }, "JShint error to be shown"); - }, 5000); + // ...but JSHint does NOT step in: on desktop the language server is the JS linter, so + // JSHint defers to it instead of being the ESLint-failure fallback. + await _waitForLSPLintingJS(); + await awaits(100); // give JSHint time to run if it were going to + expect($("#problems-panel").text().includes("JSHint")).toBeFalse(); + }, 40000); describe("ES6 project", function () { let es6ProjectPath; @@ -180,13 +194,15 @@ define(function (require, exports, module) { await _loadAndValidateES6Project(); }, 30000); - it("should show ESLint and JSHint in desktop app for es6 project or below", async function () { + it("should show ESLint failure but defer JSHint to the LSP (es6 project, desktop)", async function () { await _loadAndValidateES6Project(); - await awaitsFor(async ()=>{ - await _fileSwitcherroForESLintFailDetection(); - return $("#problems-panel").text().includes(JSHintErrorES6Error_js); - }, "JShint error to be shown", 3000, 300); - }, 30000); + // ESLint v6 is unsupported, so ESLint surfaces its "load failed" message - but JSHint + // defers to the language server (the JS linter on desktop) rather than stepping in. + await _fileSwitcherroForESLintFailDetection(); + await _waitForLSPLintingJS(); + await awaits(100); + expect($("#problems-panel").text().includes("JSHint")).toBeFalse(); + }, 40000); }); describe("ES7 and JSHint project", function () { From 2ebfbcf1b17e7011ae797998abee484bf7c8db33 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 27 Jun 2026 13:21:08 +0530 Subject: [PATCH 2/9] feat(lsp): start the TypeScript server lazily, repoint only on project switch The server was spawned eagerly on app boot and repointed on every project switch, regardless of language - so a Python-only project still ran vtsls and re-indexed it on switch. Mirror VS Code's onLanguage model instead: start the server only when a served-language (JS/TS/JSX/TSX) file is the active editor, and repoint (workspace/didChangeWorkspaceFolders) only when a served file is active right after a project switch - never on ordinary file switches. Once started the server stays alive idle (no proactive shutdown). - main.js: _ensureServerForActiveEditor() driven by activeEditorChange + an initial evaluation; a pendingRepoint flag (set on EVENT_PROJECT_OPEN) gates the repoint so file switches don't touch the workspace-folder/restart machinery. Report a start failure via window.logger.reportError, deduped (start is retried lazily). Drop the eager appReady start and the now-meaningless readiness flag. - LSPClient.changeWorkspaceRoot: check same-root first so a redundant call is a no-op and never restarts a server that lacks live workspace-folder support. - tests: the lazy "never started" state isn't observable in the reused integration window (keep-alive, no stop path), so don't assert it; drop the readiness-flag wait (window load already guarantees the extension's appReady ran). --- .../default/TypeScriptSupport/main.js | 89 +++++++++++++++---- .../default/TypeScriptSupport/unittests.js | 8 +- src/languageTools/LSPClient.js | 16 ++-- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/extensions/default/TypeScriptSupport/main.js b/src/extensions/default/TypeScriptSupport/main.js index 3ab4165db3..af4ee71838 100644 --- a/src/extensions/default/TypeScriptSupport/main.js +++ b/src/extensions/default/TypeScriptSupport/main.js @@ -33,6 +33,7 @@ define(function (require, exports, module) { const AppInit = brackets.getModule("utils/AppInit"), ProjectManager = brackets.getModule("project/ProjectManager"), DocumentManager = brackets.getModule("document/DocumentManager"), + EditorManager = brackets.getModule("editor/EditorManager"), FileSystem = brackets.getModule("filesystem/FileSystem"), NodeConnector = brackets.getModule("NodeConnector"), CodeIntelligence = require("./CodeIntelligence"); @@ -203,8 +204,7 @@ define(function (require, exports, module) { * @return {boolean} */ function canRun() { - return typeof Phoenix !== "undefined" && Phoenix.isNativeApp && - NodeConnector.isNodeAvailable && NodeConnector.isNodeAvailable(); + return Phoenix.isNativeApp && NodeConnector.isNodeAvailable(); } /** @@ -252,23 +252,71 @@ define(function (require, exports, module) { } } - // Begin loading the LSP framework as soon as the (desktop-only) extension loads - the - // reliable moment for module loading - so it is ready by the time start() runs. + // Begin loading the LSP framework as soon as the (desktop-only) extension loads - the reliable + // moment for module loading - so it is ready by the time we first need it. This only loads the + // module; it does not spawn the server (that happens lazily, on the first served-language file). if (canRun()) { loadLSPClient(); } + /** + * True when the active editor holds a language this server handles (JS/TS/JSX/TSX). + * @return {boolean} + */ + function _isServedLanguageActive() { + const editor = EditorManager.getActiveEditor(); + return !!(editor && SUPPORTED_LANGUAGES.indexOf(editor.getLanguageForSelection().getId()) !== -1); + } + + let starting = false; + let pendingRepoint = false; // a project switch happened; repoint once a served file is active there + let initErrorReported = false; // start() is retried lazily, so report a failure to telemetry only once + + /** + * Lazily start the language server when a served-language file is active, and - only right after a + * project switch - repoint the running server at the new root. Mirrors VS Code's onLanguage model: + * a project with no JS/TS file opened never spawns vtsls; switching to a non-JS project leaves the + * idle server where it was; and plain file switches within a project never touch the + * workspace-folder / restart machinery (so they can't interfere with a crash auto-restart). + */ + function _ensureServerForActiveEditor() { + if (!canRun() || !_isServedLanguageActive()) { + return; + } + + // Not running yet: lazily start it (a fresh start already points at the current project root). + if (!registered) { + if (starting) { + return; // a start kicked off by a previous activeEditorChange is still in flight + } + starting = true; + pendingRepoint = false; + start().catch(function (err) { + if (!initErrorReported) { + initErrorReported = true; + window.logger && window.logger.reportError(err, "[TypeScriptSupport] LSP init failed"); + } + }).finally(function () { + starting = false; + }); + return; + } + + // Running: repoint at the current project, but only when a project switch armed it - never on + // ordinary file switches. + if (pendingRepoint) { + pendingRepoint = false; + loadLSPClient().then(function (LSPClient) { + LSPClient.changeWorkspaceRoot(SERVER_ID); + }); + } + } + AppInit.appReady(function () { if (!canRun()) { return; } _refreshCheckJs(); - start().catch(function (err) { - console.error("[TypeScriptSupport] init failed", err && (err.message || err)); - }).finally(function () { - // Signal for integration tests that the server start has been attempted/settled. - window._TypeScriptSupportReadyToIntegTest = true; - }); // Offer project-wide code intelligence (creates a default ts/jsconfig) when a JS/TS file is // opened in a project that has no config yet. Projects that already carry one are silent. @@ -283,17 +331,20 @@ define(function (require, exports, module) { } }); - // Re-point the server at the new workspace root when the project changes, and re-evaluate - // whether the new project type-checks its JS. This uses workspace/didChangeWorkspaceFolders - // (no process restart, so no tsserver cold start) and only falls back to a full restart for - // servers that don't support live workspace-folder changes. + // Lazily start / repoint the server from the active editor's language (VS Code's onLanguage + // model). Evaluate the editor already open at startup (session restore), then track switches. + EditorManager.on("activeEditorChange", _ensureServerForActiveEditor); + _ensureServerForActiveEditor(); + + // On project switch: re-evaluate checkJs and arm a one-shot repoint. The actual repoint + // (workspace/didChangeWorkspaceFolders, no restart) happens the next time a served-language + // file is active - here if one already is, otherwise on the activeEditorChange as the new + // project's file opens. Plain file switches within a project never set this, so they don't + // repoint. ProjectManager.on(ProjectManager.EVENT_PROJECT_OPEN, function () { _refreshCheckJs(); - if (registered) { - loadLSPClient().then(function (LSPClient) { - LSPClient.changeWorkspaceRoot(SERVER_ID); - }); - } + pendingRepoint = true; + _ensureServerForActiveEditor(); }); // Pick up a tsconfig/jsconfig being added, edited, or removed at the project root. diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index 551edd9381..f140175173 100644 --- a/src/extensions/default/TypeScriptSupport/unittests.js +++ b/src/extensions/default/TypeScriptSupport/unittests.js @@ -55,10 +55,10 @@ define(function (require, exports, module) { CodeInspection = testWindow.brackets.test.CodeInspection; QuickViewManager = testWindow.brackets.getModule("features/QuickViewManager"); CodeInspection.toggleEnabled(true); - // Wait until the extension has attempted to start the language server. - await awaitsFor(function () { - return testWindow._TypeScriptSupportReadyToIntegTest; - }, "TypeScript LSP server to start", 30000); + // createTestWindowAndRun already waited for the app (and so the extension's appReady, which + // wires the lazy-start hooks) to finish loading. The server itself starts lazily on the + // first served-language file - the warm-up below opens a .ts, which starts it; waiting for + // its diagnostics ("not assignable") is the real readiness signal. // Warm up tsserver. Its very first request pays a large one-time cost - spawning node, // launching vtsls, and loading the TypeScript library + project - which on a slow/loaded diff --git a/src/languageTools/LSPClient.js b/src/languageTools/LSPClient.js index 4478fdf117..5d814056e4 100644 --- a/src/languageTools/LSPClient.js +++ b/src/languageTools/LSPClient.js @@ -742,6 +742,16 @@ define(function (require, exports, module) { if (!client) { return; } + // Resolve the target root FIRST: a redundant call for the same root must be a cheap no-op and + // must never restart (this can be called often - e.g. once per editor switch). This check has + // to precede the restart fallbacks below, or a same-root call to a server that lacks live + // workspace-folder support would pointlessly recycle the process. + const newVfsPath = (client.config.rootUriProvider && client.config.rootUriProvider()) || _projectRootPath(); + const newUri = newVfsPath ? pathToServerUri(newVfsPath) : null; + const oldUri = client.rootUri || null; + if (newUri === oldUri) { + return; // same workspace - nothing to do + } // Not up yet (e.g. the project switched before init finished) - a (re)start picks up the // current root on its own. if (!client.capabilities) { @@ -754,12 +764,6 @@ define(function (require, exports, module) { if (!supportsLiveChange) { return restartLanguageServer(serverId); } - const newVfsPath = (client.config.rootUriProvider && client.config.rootUriProvider()) || _projectRootPath(); - const newUri = newVfsPath ? pathToServerUri(newVfsPath) : null; - const oldUri = client.rootUri || null; - if (newUri === oldUri) { - return; // same workspace - nothing to do - } const conn = await getConnector(); const added = newUri ? [{ uri: newUri, name: FileUtils.getBaseName(newVfsPath) }] : []; const removed = oldUri ? [{ uri: oldUri, name: client.rootName || FileUtils.getBaseName(oldUri) }] : []; From bd1806fc63f8522a0f70739ad3c07f020955c2aa Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 27 Jun 2026 13:35:16 +0530 Subject: [PATCH 3/9] fix(lsp): show the source module on a highlighted auto-import, not the clipped detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highlighted auto-import row rendered token.detail inline - a long "Add import from …" string that clipped to a useless "Add import from "fs" names…", while non-highlighted rows showed the short module. Skip the inline signature for auto-imports (their detail is that import line; the doc popup still shows it in full) and keep the short source-module tag visible on the highlighted row. In-scope items are unchanged - they still show their type signature inline. --- src/languageTools/DefaultProviders.js | 6 ++++-- src/styles/brackets.less | 10 ++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index ab0e4d9a66..4d1a9dd255 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -494,8 +494,10 @@ define(function (require, exports, module) { // Inline: the signature for the highlighted row. Re-inject every time (it is // idempotent) because the list DOM is rebuilt on each keystroke, which drops a // previously-injected signature. resolveCompletion is cached separately (_lspResolved), - // so this does not cause extra LSP requests. - if (token.detail) { + // so this does not cause extra LSP requests. Skip it for auto-imports - their detail is a + // long "Add import from " string that just clips uselessly; the short source-module + // tag stays visible there instead (see CSS), and the doc popup carries the full import. + if (token.detail && !_isAutoImport(token)) { _injectInlineSignature($span, token.detail); } // Beside the list: the signature header + the (possibly long) documentation. diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 88f15a97e5..33706d66f9 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -2663,12 +2663,10 @@ span.brackets-hints-with-type-details { opacity: 0.55; } -// On the highlighted row the side doc popup already names the import source, and the inline -// signature takes the right slot, so hide the source tag there to keep the row uncluttered. -// The clubbed "N imports…" tag is the row's whole purpose, so keep it visible when highlighted. -.codehint-menu.lsp-hints .highlight .lsp-hint-source:not(.lsp-hint-import-group) { - display: none; -} +// The source-module tag stays visible on the highlighted row too. Auto-imports get no inline +// signature (their detail is a long "Add import from " that would just clip), so the short +// module tag is the most useful thing to keep there; in-scope items have no source tag and show +// their signature instead. // The clubbed "N imports…" affordance reads as an action, not a source path: not italic, a touch // more present than a plain source tag. From ebbe987c130601d97923d13577e319892d1736df Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 27 Jun 2026 14:07:20 +0530 Subject: [PATCH 4/9] feat(lsp): unify code-hint doc & parameter popups, make doc text selectable Restyle the LSP documentation popup and the parameter-hint popup so all three code-hint surfaces (completion list, doc popup, parameter hints) share one visual identity: same menu background, border radius and shadow, in both light and dark themes. Redesign the fenced code blocks inside the doc popup as elevated panels and add a per-block copy button (with copied feedback). Make the doc popup text selectable: add user-select and an I-beam cursor, and stop the popup's own mousedown/click from bubbling to Bootstrap's outside-click handler (which was tearing the popup down before a drag-select could start). Native selection is preserved because the handler does not preventDefault. --- src/languageTools/DefaultProviders.js | 62 +++++++++++++++++ src/styles/brackets.less | 97 +++++++++++++++++++++------ 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/languageTools/DefaultProviders.js b/src/languageTools/DefaultProviders.js index 4d1a9dd255..063c043fd0 100644 --- a/src/languageTools/DefaultProviders.js +++ b/src/languageTools/DefaultProviders.js @@ -201,6 +201,47 @@ define(function (require, exports, module) { return parts.join(""); } + // Minimal copy glyph (stroked, inherits currentColor) for the per-code-block copy button. + const COPY_ICON = ''; + + function _copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function (resolve, reject) { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.cssText = "position:fixed;opacity:0;pointer-events:none;"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + resolve(); + } catch (e) { + reject(e); + } + }); + } + + // Give every code block in the doc popup a hover-revealed copy button. + function _addCopyButtons($popup) { + $popup.find("pre").each(function () { + const $pre = $(this); + if ($pre.children(".lsp-copy-btn").length) { + return; + } + $("