From dd1fcb1cdcc4156eaa1c333e68bf940a4f85a104 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 8 Jul 2026 02:09:46 +0530 Subject: [PATCH] feat(python): python code intelligence via Pyrefly with Ruff formatting - New PythonSupport default extension (desktop only): completion, hover, signature help, jump-to-definition, references, type diagnostics and quick fixes through the shared LSP framework, powered by an exact-pinned Pyrefly wheel installed on demand from PyPI (sha256-verified, node-side download + stdlib zip extraction, no npm or Python runtime needed) - Ruff installs alongside (own pin, independent upgrade) and powers only the Beautify command as a standalone 'ruff format' stdin call - Generic NodeUtils: downloadFile (streamed, progress events, sha256), extractZipFile (stdlib unzipper, restores exec bits), setExecutableBits, execFileWithInput - LSP framework: per-server workspaceConfiguration served to workspace/configuration pulls (pyrefly treats the null fallback answer as all-diagnostics-off) - Install UX shared with PHP: once-per-lifetime dialog over the find-bar prompt (terse line + benefits hover card + powered-by links + download size beside the action buttons); bar styles generalized to lsp-install-* - Fix buttons (problems panel + hover quick view) now show the LSP code action title as tooltip so users see what a fix will apply - integration:Python LSP suite (7 specs) + fixtures --- docs/API-Reference/language/CodeInspection.md | 2 + docs/API-Reference/utils/NodeUtils.md | 66 +++ gulpfile.js/index.js | 4 +- src-node/lsp-client.js | 40 +- src-node/utils.js | 217 ++++++- src/extensions/default/DefaultExtensions.json | 3 +- .../default/PHPSupport/ServerInstaller.js | 100 +++- .../default/PythonSupport/Beautifier.js | 83 +++ .../default/PythonSupport/ServerInstaller.js | 550 ++++++++++++++++++ src/extensions/default/PythonSupport/main.js | 234 ++++++++ .../default/PythonSupport/package.json | 9 + .../default/PythonSupport/unittests.js | 202 +++++++ src/htmlContent/problems-panel-table.html | 2 +- src/language/CodeInspection.js | 8 +- src/languageTools/DefaultProviders.js | 4 + src/languageTools/LSPClient.js | 7 +- src/nls/root/strings.js | 30 + src/styles/brackets.less | 45 +- src/utils/NodeUtils.js | 108 ++++ test/spec/PythonSupport-test-files/error.py | 8 + test/spec/PythonSupport-test-files/funcs.py | 9 + 21 files changed, 1699 insertions(+), 32 deletions(-) create mode 100644 src/extensions/default/PythonSupport/Beautifier.js create mode 100644 src/extensions/default/PythonSupport/ServerInstaller.js create mode 100644 src/extensions/default/PythonSupport/main.js create mode 100644 src/extensions/default/PythonSupport/package.json create mode 100644 src/extensions/default/PythonSupport/unittests.js create mode 100644 test/spec/PythonSupport-test-files/error.py create mode 100644 test/spec/PythonSupport-test-files/funcs.py diff --git a/docs/API-Reference/language/CodeInspection.md b/docs/API-Reference/language/CodeInspection.md index e73b770a91..49c0e9d862 100644 --- a/docs/API-Reference/language/CodeInspection.md +++ b/docs/API-Reference/language/CodeInspection.md @@ -164,6 +164,7 @@ Each error object in the results should have the following structure: type:?Type , fix: { // an optional fix, if present will show the fix button replaceText: "text to replace the offset given below", + title: "optional tooltip describing what the fix does", rangeOffset: { start: number, end: number @@ -195,6 +196,7 @@ Each error object in the results should have the following structure: | type | [Type](#Type) | The type of the error. Defaults to `Type.WARNING` if unspecified. | | fix | Object | An optional fix object. | | fix.replaceText | string | The text to replace the error with. | +| fix.title | string | Optional tooltip on the Fix button describing what the fix does. | | fix.rangeOffset | Object | The range within the text to replace. | | fix.rangeOffset.start | number | The start offset of the range. | | fix.rangeOffset.end | number | The end offset of the range. If no errors are found, return either `null`(treated as file is problem free) or an object with a zero-length `errors` array. Always use `message` to safely display the error as text. If you want to display HTML error message, then explicitly use `htmlMessage` to display it. Both `message` and `htmlMessage` can be used simultaneously. After scanning the file, if you need to omit the lint result, return or resolve with `{isIgnored: true}`. This prevents the file from being marked with a no errors tick mark in the status bar and excludes the linter from the problems panel. | diff --git a/docs/API-Reference/utils/NodeUtils.md b/docs/API-Reference/utils/NodeUtils.md index 9243e256a3..e9a44eb11d 100644 --- a/docs/API-Reference/utils/NodeUtils.md +++ b/docs/API-Reference/utils/NodeUtils.md @@ -56,6 +56,72 @@ This is only available in the native app. | url | string | | browserName | string | + + +## downloadFile(url, destFile, [options]) ⇒ Promise.<void> +Downloads a URL to a file on disk, fully node-side (native fetch, streamed to disk). +When an expected sha256 is given, a mismatch deletes the file and rejects - a resolved +promise means the file holds exactly the pinned bytes. +This is only available in the native app. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| url | string | download URL (redirects followed) | +| destFile | string | platform path to write (parent directories created) | +| [options] | Object | | +| [options.sha256] | string | expected hex digest of the downloaded bytes | +| [options.progress] | function | called with (transferredBytes, totalBytes) as the download advances; totalBytes is 0 if the server sent no length | + + + +## extractZipFile(zipPath, destDir) ⇒ Promise.<void> +Extracts a zip file into a directory node-side (stdlib only, no browser JSZip; creates the +directory if missing, restores unix executable bits recorded in the archive). Python wheels +are plain zips, so this installs those too. +This is only available in the native app. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| zipPath | string | platform path of the zip file | +| destDir | string | platform path of the directory to extract into | + + + +## setExecutableBits(filePath) ⇒ Promise.<void> +Marks a file as executable (chmod 755); no-op on Windows. For binaries whose archives did +not carry unix mode bits. +This is only available in the native app. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| filePath | string | platform path of the file | + + + +## execFileWithInput(command, [args], [options]) ⇒ Promise.<{code: number, stdout: string, stderr: string}> +Runs an executable with the given args, feeding it text on stdin and capturing its output - +a one-shot filter-style invocation (e.g. `ruff format -` for the Python beautifier). No +shell is involved. Resolves with the exit code rather than rejecting on non-zero, so +callers can read stderr for the reason. +This is only available in the native app. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| command | string | platform path of the executable (or a PATH command name) | +| [args] | Array.<string> | | +| [options] | Object | | +| [options.stdinText] | string | written to the process's stdin, then closed | +| [options.cwd] | string | working directory | +| [options.timeoutMs] | number | kill the process and reject after this long | + ## getEnvironmentVariable(varName) ⇒ Promise.<string> diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 961ea91419..81e69f8430 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -909,8 +909,8 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { } const minifyableExtensions = ["CloseOthers", "CodeFolding", "DebugCommands", "Git", - "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "PHPSupport", "QuickView", - "TypeScriptSupport"]; + "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "PHPSupport", "PythonSupport", + "QuickView", "TypeScriptSupport"]; // extensions that nned not be minified either coz they are single file extensions or some other reason. const nonMinifyExtensions = ["CSSAtRuleCodeHints", "CSSCodeHints", "CSSPseudoSelectorHints", "DarkTheme", "DocCommentHints", "HandlebarsSupport", "HTMLCodeHints", diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js index 0bbfd0bdce..1d3724632e 100644 --- a/src-node/lsp-client.js +++ b/src-node/lsp-client.js @@ -153,10 +153,37 @@ function handleMessage(serverId, msg) { } } +/** + * Resolve a workspace/configuration item's dotted section (e.g. "python" or "python.pyrefly") + * inside a server's registered workspaceConfiguration object. Returns null when any path + * segment is missing - the spec's "no config" answer. + * @param {?Object} config - the server's workspaceConfiguration (may be undefined) + * @param {?string} section - the requested section; no section means the whole object + * @return {*} + */ +function _lookupConfigSection(config, section) { + if (!config) { + return null; + } + if (!section) { + return config; + } + let value = config; + for (const part of section.split('.')) { + if (value === null || typeof value !== 'object' || !(part in value)) { + return null; + } + value = value[part]; + } + return value; +} + /** * Answer a server-initiated request with a benign, spec-shaped reply. We advertise minimal client * capabilities (no dynamic registration, workspace.configuration=false), so servers should rarely * send these - this is the safety net that guarantees no server hangs awaiting a reply. + * Servers that pull configuration regardless (e.g. pyrefly) get the workspaceConfiguration + * object registered at startServer; anything else gets the spec's null "no config". * @param {string} serverId - The server identifier (for logging) * @param {Object} server - The server state object * @param {Object} msg - The incoming JSON-RPC request (method + id) @@ -168,7 +195,8 @@ function _respondToServerRequest(serverId, server, msg) { // Result must be an array matching params.items length; null entries mean "no config". response = { jsonrpc: '2.0', id: msg.id, - result: ((msg.params && msg.params.items) || []).map(() => null) + result: ((msg.params && msg.params.items) || []).map( + item => _lookupConfigSection(server.workspaceConfiguration, item && item.section)) }; break; case 'client/registerCapability': @@ -205,10 +233,13 @@ exports.ping = async function ping() { * @param {string} params.command - Command used to spawn the language server * @param {string[]} [params.args=['--stdio']] - Arguments for the command * @param {string} params.rootUri - Root URI of the workspace + * @param {Object} [params.workspaceConfiguration] - settings tree served to the server's + * workspace/configuration pulls (sections resolved by dotted path); pulls answer null + * without it * @returns {Promise} Result with success status and server info */ exports.startServer = async function startServer(params) { - const { serverId, command, args = ['--stdio'], rootUri } = params; + const { serverId, command, args = ['--stdio'], rootUri, workspaceConfiguration } = params; if (!serverId || !command) { throw new Error('serverId and command are required'); @@ -224,7 +255,9 @@ exports.startServer = async function startServer(params) { // same spawn-self pattern _npmInstallInFolder and the ESLint service use. This sidesteps // node_modules/.bin shims entirely (they are sh scripts / .cmd on Windows). // 2. A server bundled in src-node/node_modules/.bin. - // 3. Fall back to PATH. + // 3. Fall back to spawning the command as given - which also covers an absolute path to a + // native binary (e.g. a user-installed server like pyrefly), spawned as-is, or a PATH + // lookup for a bare command name. let commandPath = command; let spawnArgs = args; if (path.isAbsolute(command) && command.endsWith('.js')) { @@ -245,6 +278,7 @@ exports.startServer = async function startServer(params) { process: serverProcess, pending: new Map(), rootUri, + workspaceConfiguration, stderrTail: [] // keep the last few stderr lines to attach to crash reports }; diff --git a/src-node/utils.js b/src-node/utils.js index d2e4525edc..005f166427 100644 --- a/src-node/utils.js +++ b/src-node/utils.js @@ -1,9 +1,11 @@ const NodeConnector = require("./node-connector"); -const { exec, execFile } = require('child_process'); +const { exec, execFile, spawn } = require('child_process'); const fs = require('fs'); const fsPromise = require('fs').promises; const path = require('path'); const os = require('os'); +const crypto = require('crypto'); +const zlib = require('zlib'); const { SYSTEM_SETTINGS_DIR } = require('./constants'); const { lintFile } = require("./ESLint/service"); const { addDeviceLicense, getDeviceID, isLicensedDevice, removeDeviceLicense } = require("./licence-device"); @@ -18,7 +20,7 @@ async function _importOpen() { } const UTILS_NODE_CONNECTOR = "ph_utils"; -NodeConnector.createNodeConnector(UTILS_NODE_CONNECTOR, exports); +const utilsConnector = NodeConnector.createNodeConnector(UTILS_NODE_CONNECTOR, exports); async function getURLContent({url, options}) { options = options || { @@ -149,6 +151,213 @@ async function _npmInstallInFolder({moduleNativeDir}) { }); } +// no dot in the name - EventDispatcher treats anything after a "." as a listener namespace +const DOWNLOAD_PROGRESS_EVENT = "downloadProgress"; + +/** + * Downloads a URL to a file on disk with node's native fetch (redirects followed), streaming + * `downloadProgress` events carrying {url, transferred, total} to the browser side. When an + * expected sha256 hex digest is given, a mismatch deletes the file and rejects - so a resolved + * promise means the file on disk is exactly the bytes the caller pinned. + * + * @param {string} url - download URL + * @param {string} destFile - platform path to write (parent directories created) + * @param {string} [sha256] - expected hex digest of the downloaded bytes + * @return {Promise} + */ +async function downloadFile({url, destFile, sha256}) { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error("Download failed with HTTP " + response.status + " for " + url); + } + const total = parseInt(response.headers.get("content-length"), 10) || 0; + await fsPromise.mkdir(path.dirname(destFile), { recursive: true }); + const fileHandle = await fsPromise.open(destFile, "w"); + const hash = crypto.createHash("sha256"); + let transferred = 0; + let lastReported = 0; + try { + for await (const chunk of response.body) { + await fileHandle.write(chunk); + hash.update(chunk); + transferred += chunk.length; + // throttle the websocket chatter: one event per 256KB is plenty for a progress bar + if (transferred - lastReported > 262144 || transferred === total) { + lastReported = transferred; + utilsConnector.triggerPeer(DOWNLOAD_PROGRESS_EVENT, {url, transferred, total}); + } + } + } finally { + await fileHandle.close(); + } + if (sha256) { + const actual = hash.digest("hex"); + if (actual !== sha256.toLowerCase()) { + await fsPromise.unlink(destFile).catch(() => {}); + throw new Error("sha256 mismatch for " + url + " - expected " + sha256 + " got " + actual); + } + } +} + +/* + * Minimal zip extraction on the node stdlib alone (zlib) - no external dependencies. Written for + * installing language-server archives (e.g. Python wheels, which are plain zips): supports the + * stored and deflate methods, restores unix executable bits from the central-directory external + * attributes, and refuses path traversal. Does NOT support zip64, encryption or other exotic + * compression methods - fine for our curated downloads, not a general-purpose unzipper. + */ +/* eslint-disable no-bitwise -- zip header parsing is inherently bit-twiddling */ + +const EOCD_SIG = 0x06054b50; // end of central directory +const CEN_SIG = 0x02014b50; // central directory entry +const ZIP64_MARKER = 0xffffffff; + +function _findEndOfCentralDirectory(buf) { + // EOCD is at the very end, possibly followed by a comment of up to 65535 bytes + const earliest = Math.max(0, buf.length - 22 - 65535); + for (let i = buf.length - 22; i >= earliest; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) { + return i; + } + } + throw new Error("Not a zip file: end of central directory not found"); +} + +// Extracts a zip held in a Buffer to destDir, creating directories as needed. Entries whose +// unix mode carries any execute bit are chmod 755 after writing (except on Windows). +async function _extractZipBuffer(buf, destDir) { + const eocd = _findEndOfCentralDirectory(buf); + const entryCount = buf.readUInt16LE(eocd + 10); + let offset = buf.readUInt32LE(eocd + 16); + const destRoot = path.resolve(destDir); + await fsPromise.mkdir(destRoot, { recursive: true }); + for (let i = 0; i < entryCount; i++) { + if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== CEN_SIG) { + throw new Error("zip central directory corrupt"); + } + const method = buf.readUInt16LE(offset + 10); + const compressedSize = buf.readUInt32LE(offset + 20); + const nameLen = buf.readUInt16LE(offset + 28); + const extraLen = buf.readUInt16LE(offset + 30); + const commentLen = buf.readUInt16LE(offset + 32); + const externalAttrs = buf.readUInt32LE(offset + 38); + const localOffset = buf.readUInt32LE(offset + 42); + const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen); + offset += 46 + nameLen + extraLen + commentLen; + + if (compressedSize === ZIP64_MARKER || localOffset === ZIP64_MARKER) { + throw new Error("zip64 archives are not supported: " + name); + } + const destPath = path.resolve(destRoot, name); + if (destPath !== destRoot && !destPath.startsWith(destRoot + path.sep)) { + throw new Error("zip entry escapes destination directory: " + name); + } + if (name.endsWith("/")) { + await fsPromise.mkdir(destPath, { recursive: true }); + continue; + } + // data sits after the entry's LOCAL header, whose name/extra lengths can differ from + // the central directory's copy - re-read them from the local header itself + const locNameLen = buf.readUInt16LE(localOffset + 26); + const locExtraLen = buf.readUInt16LE(localOffset + 28); + const dataStart = localOffset + 30 + locNameLen + locExtraLen; + const raw = buf.subarray(dataStart, dataStart + compressedSize); + let data; + if (method === 0) { // stored + data = raw; + } else if (method === 8) { // deflate + data = zlib.inflateRawSync(raw); + } else { + throw new Error("unsupported zip compression method " + method + " in " + name); + } + await fsPromise.mkdir(path.dirname(destPath), { recursive: true }); + await fsPromise.writeFile(destPath, data); + const unixMode = (externalAttrs >>> 16) & 0xFFFF; + if ((unixMode & 0o111) && process.platform !== "win32") { + await fsPromise.chmod(destPath, 0o755); + } + } +} +/* eslint-enable no-bitwise */ + +/** + * Extracts a zip file into destDir using the stdlib-only extractor above (creates destDir + * if missing, restores unix executable bits recorded in the archive). Python wheels are plain + * zips, so this also installs those. + * + * @param {string} zipPath - platform path of the zip file + * @param {string} destDir - platform path of the directory to extract into + * @return {Promise} + */ +async function extractZipFile({zipPath, destDir}) { + const buf = await fsPromise.readFile(zipPath); + await _extractZipBuffer(buf, destDir); +} + +/** + * Runs an executable with the given args, feeding it text on stdin and capturing its output - + * a one-shot filter-style invocation (e.g. `ruff format -` for the Python beautifier). No shell + * is involved; the command is spawned directly. Resolves with the exit code rather than + * rejecting on non-zero, so callers can read stderr for the reason. + * + * @param {string} command - platform path of the executable (or a PATH command name) + * @param {string[]} [args] + * @param {string} [stdinText] - written to the process's stdin, which is then closed + * @param {string} [cwd] - working directory (e.g. for tools that discover config upward) + * @param {number} [timeoutMs] - kill the process and reject after this long + * @return {Promise<{code: number, stdout: string, stderr: string}>} + */ +async function execFileWithInput({command, args, stdinText, cwd, timeoutMs}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args || [], { cwd: cwd || undefined, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = "", stderr = "", settled = false; + const timer = timeoutMs ? setTimeout(() => { + if (!settled) { + settled = true; + child.kill(); + reject(new Error("execFileWithInput timed out: " + command)); + } + }, timeoutMs) : null; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + child.on('error', (err) => { + if (!settled) { + settled = true; + if (timer) { + clearTimeout(timer); + } + reject(err); + } + }); + child.on('close', (code) => { + if (!settled) { + settled = true; + if (timer) { + clearTimeout(timer); + } + resolve({ code, stdout, stderr }); + } + }); + child.stdin.on('error', () => {}); // EPIPE if the process exits before reading stdin + child.stdin.write(stdinText || ""); + child.stdin.end(); + }); +} + +/** + * Marks a file as executable (chmod 755). No-op on Windows, where execute permission does not + * exist. For binaries whose archives did not carry unix mode bits. + * + * @param {string} filePath - platform path of the file + * @return {Promise} + */ +async function setExecutableBits({filePath}) { + if (process.platform === "win32") { + return; + } + await fsPromise.chmod(filePath, 0o755); +} + /** * If it's a dir that exists, returns that * If it's a file, it returns the parent directory if it exists @@ -296,3 +505,7 @@ exports.getOSUserName = getOSUserName; exports.getSystemSettingsDir = getSystemSettingsDir; exports._loadNodeExtensionModule = _loadNodeExtensionModule; exports._npmInstallInFolder = _npmInstallInFolder; +exports.downloadFile = downloadFile; +exports.extractZipFile = extractZipFile; +exports.setExecutableBits = setExecutableBits; +exports.execFileWithInput = execFileWithInput; diff --git a/src/extensions/default/DefaultExtensions.json b/src/extensions/default/DefaultExtensions.json index 68032c2994..fc78c3b13b 100644 --- a/src/extensions/default/DefaultExtensions.json +++ b/src/extensions/default/DefaultExtensions.json @@ -29,7 +29,8 @@ "desktopOnly": [ "Git", "TypeScriptSupport", - "PHPSupport" + "PHPSupport", + "PythonSupport" ], "warnExtensionStoreExtensions": { "description": "list extension ids here that you want to show this warning in extension store: 'You may not need this extension. Phoenix comes built in with this feature.'", diff --git a/src/extensions/default/PHPSupport/ServerInstaller.js b/src/extensions/default/PHPSupport/ServerInstaller.js index 82bca7b0c2..35227102ab 100644 --- a/src/extensions/default/PHPSupport/ServerInstaller.js +++ b/src/extensions/default/PHPSupport/ServerInstaller.js @@ -49,6 +49,8 @@ define(function (require, exports, module) { NativeApp = brackets.getModule("utils/NativeApp"), ModalBar = brackets.getModule("widgets/ModalBar").ModalBar, NotificationUI = brackets.getModule("widgets/NotificationUI"), + Dialogs = brackets.getModule("widgets/Dialogs"), + DefaultDialogs = brackets.getModule("widgets/DefaultDialogs"), TaskManager = brackets.getModule("features/TaskManager"), PreferencesManager = brackets.getModule("preferences/PreferencesManager"), StringUtils = brackets.getModule("utils/StringUtils"), @@ -59,6 +61,11 @@ define(function (require, exports, module) { const INTELEPHENSE_HOME_URL = "https://intelephense.com"; + // once-per-lifetime: the first time the install bar shows, a dialog is raised over it so the + // offer cannot be missed. Never shown again (even on cancel) - the bar remains the + // ongoing affordance. + const STATE_INSTALL_DIALOG_SHOWN = "php.installDialogShown"; + let _onInstalled = null; // main.js callback: ({entryPath, upgraded}) => void let _inFlight = null; // single-flight install promise // projects where the user clicked "Not Now" - the bar stops reappearing there for the @@ -218,23 +225,83 @@ define(function (require, exports, module) { } } + function _benefitRows() { + return [ + [Strings.PHP_BENEFIT_COMPLETIONS, Strings.PHP_BENEFIT_COMPLETIONS_SUB], + [Strings.PHP_BENEFIT_DOCS, Strings.PHP_BENEFIT_DOCS_SUB], + [Strings.PHP_BENEFIT_ERRORS, Strings.PHP_BENEFIT_ERRORS_SUB], + [Strings.PHP_BENEFIT_NAV, Strings.PHP_BENEFIT_NAV_SUB] + ]; + } + // Compact benefits card for the bar's (i) icon - term -> what it means, one line each. function _benefitsTipHtml() { const $tip = $("
"); $("
").text(Strings.PHP_INSTALL_TITLE).appendTo($tip); const $rows = $("
").appendTo($tip); - [ - [Strings.PHP_BENEFIT_COMPLETIONS, Strings.PHP_BENEFIT_COMPLETIONS_SUB], - [Strings.PHP_BENEFIT_DOCS, Strings.PHP_BENEFIT_DOCS_SUB], - [Strings.PHP_BENEFIT_ERRORS, Strings.PHP_BENEFIT_ERRORS_SUB], - [Strings.PHP_BENEFIT_NAV, Strings.PHP_BENEFIT_NAV_SUB] - ].forEach(function (row) { + _benefitRows().forEach(function (row) { $("").text(row[0]).appendTo($rows); $("").text(row[1]).appendTo($rows); }); return $tip.html(); } + // The very first offer ever also raises a dialog over the bar - the bar alone is easy to + // miss. One shot per install (lifetime): cancelling leaves only the bar from then on. + // Deliberately terse: one line + an (i) whose hover card lists what it provides (same + // rich tooltip as the bar's info icon). + function _maybeShowFirstTimeDialog() { + const stateManager = PreferencesManager.stateManager; + if (stateManager.get(STATE_INSTALL_DIALOG_SHOWN)) { + return; + } + stateManager.set(STATE_INSTALL_DIALOG_SHOWN, true); + const $body = $("
"); + const $text = $("

").appendTo($body); + $("").text(Strings.PHP_INSTALL_DIALOG_TEXT).appendTo($text); + $text.append(" "); + $("").appendTo($text); + $("

").text(Strings.PHP_INSTALL_LATER_INFO).appendTo($body); + // quiet colophon crediting (and linking) the server this rides on + const $credit = $("

").appendTo($body); + $("").text(Strings.LSP_INSTALL_POWERED_BY + " ").appendTo($credit); + $("").text("Intelephense").appendTo($credit); + const dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, Strings.PHP_INSTALL_DIALOG_TITLE, + $body.html(), [ + { + className: Dialogs.DIALOG_BTN_CLASS_NORMAL, + id: Dialogs.DIALOG_BTN_CANCEL, + text: Strings.PHP_INSTALL_NOT_NOW + }, + { + className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, + id: Dialogs.DIALOG_BTN_OK, + text: Strings.PHP_INSTALL_ENABLE + } + ]); + const benefitsTip = NotificationUI.attachRichTooltip( + dialog.getElement().find(".lsp-install-dialog-info"), _benefitsTipHtml(), { showDelayMs: 150 }); + // the body went in as serialized HTML, so link handling is delegated on the live dialog + dialog.getElement().on("click", "a[data-href]", function (e) { + e.preventDefault(); + NativeApp.openURLInDefaultBrowser($(e.currentTarget).attr("data-href")); + }); + // the download size sits at the decision point - in the footer, left of the buttons - + // so the cost is read exactly when the user weighs Install (store-style metadata chip) + const $size = $(""); + $("").appendTo($size); + $size.append(document.createTextNode(" " + Strings.PHP_INSTALL_DIALOG_SIZE)); + $size.prependTo(dialog.getElement().find(".modal-footer")); + dialog.done(function (id) { + benefitsTip.detach(); + if (id === Dialogs.DIALOG_BTN_OK) { + _closePromptBar(); + installNow(); + } + // on cancel the bar stays - it is the ongoing affordance + }); + } + // A find-bar-style banner across the top of the editor - impossible to miss on the file that // triggered it, but passive (autoClose false: clicking back into the code doesn't dismiss it). function _showPromptBar() { @@ -245,31 +312,31 @@ define(function (require, exports, module) { } // built as detached DOM then serialized (ModalBar takes an HTML string); all click // handling is delegated on the live bar root below - const $tpl = $("

"); - $("").text(Strings.PHP_INSTALL_MESSAGE).appendTo($tpl); - $("").appendTo($tpl); + const $tpl = $("
"); + $("").text(Strings.PHP_INSTALL_MESSAGE).appendTo($tpl); + $("").appendTo($tpl); // credit where due (and where premium lives) - not license-required, just right - $("").text(Strings.PHP_POWERED_BY_INTELEPHENSE) + $("").text(Strings.PHP_POWERED_BY_INTELEPHENSE) .appendTo($tpl); - $(" {{/fix.id}} diff --git a/src/language/CodeInspection.js b/src/language/CodeInspection.js index 8ca27655ba..c278421c7e 100644 --- a/src/language/CodeInspection.js +++ b/src/language/CodeInspection.js @@ -564,10 +564,14 @@ define(function (require, exports, module) { const fixID = `${mark.metadata}`; let errorMessageHTML = `${_.escape(mark.message)}`; if(documentFixes.get(fixID)){ + // the fix's title (when the provider gave one) tells the user what clicking + // Fix will actually do - e.g. LSP code actions can be generators/suppressions + const fixTitle = _.escape(documentFixes.get(fixID).title || ""); $problemView = $(`
- + ${errorMessageHTML}