diff --git a/src/extensions/default/TypeScriptSupport/CodeIntelligence.js b/src/extensions/default/TypeScriptSupport/CodeIntelligence.js index 24784fd604..ceafbf2977 100644 --- a/src/extensions/default/TypeScriptSupport/CodeIntelligence.js +++ b/src/extensions/default/TypeScriptSupport/CodeIntelligence.js @@ -62,6 +62,13 @@ define(function (require, exports, module) { // row then offers to re-enable. const PREF_CREATED = "tsCodeIntel.created"; + // Global user preference: set false to stop auto-creating configs in new projects entirely + // (surfaced both in the preferences file and as a checkbox in the config settings panel). + const PREF_AUTO_CREATE = "codeIntel.autoCreateConfig"; + PreferencesManager.definePreference(PREF_AUTO_CREATE, "boolean", true, { + description: Strings.DESCRIPTION_CODE_INTEL_AUTO_CREATE + }); + // "Learn more" -> the TypeScript/JavaScript config reference (documents every compilerOption in // the jsconfig.json we generate: module, target, moduleResolution, checkJs, jsx, ...). const DOCS_URL = "https://www.typescriptlang.org/tsconfig/"; @@ -93,19 +100,37 @@ define(function (require, exports, module) { }; } - // Modern, type-error-free defaults. `jsx: "react"` only affects .jsx/.tsx, harmless elsewhere. + // One UNIVERSAL, permissive template - deliberately not a per-flavor detection matrix. It has + // to serve editor intelligence (never builds) across every JS flavor a project may use: + // - module "preserve" (TS 5.4+): resolves BOTH `import` and `require()` and never demands file + // extensions on relative imports (nodenext would, breaking go-to-def in extensionless ESM JS); + // implies bundler moduleResolution + esModuleInterop + resolveJsonModule. Per-file CJS-vs-ESM + // classification stays driven by file extension + nearest package.json "type" regardless - so + // Node-CJS, Node-ESM, bundler and browser projects all resolve under this one setting. + // - target "esnext" with no `lib` includes DOM by default -> browser globals just work. + // - jsx "react-jsx": modern automatic runtime, no "Cannot find name 'React'" friction. + // - allowUmdGlobalAccess: module files may reference UMD/browser globals (jQuery-style) without + // imports - an intelligence win for browser/UMD projects, acceptable looseness for an + // editor-only, checkJs:false-by-default config. + // - typeAcquisition.enable: THE critical line. ATA (tsserver auto-fetching @types/node and types + // for package.json deps via npm, even with no node_modules) defaults ON for jsconfig but OFF + // for tsconfig - without this, the jsconfig->tsconfig upgrade would silently kill Node + // builtin/require("pkg") IntelliSense. Explicit on both file types; degrades gracefully to + // locally installed @types when npm/network are unavailable. + // Known limitation: tsserver cannot infer AMD/RequireJS-style modules cross-file (no config + // fixes that); users can still add their own paths/baseUrl, which the preserve-merge keeps. // compilerOptions from an existing managed config being rewritten/upgraded are preserved over - // the defaults; `checkJs` (TypeScript-grade type checking of JS) is applied only when the caller - // passes it, else the preserved value stands. tsconfig.json - unlike editor-only jsconfig - is - // read by real build tools, so it additionally gets allowJs (JS files stay first-class in mixed - // projects) and noEmit (an accidental `npx tsc` must never emit files next to sources). + // the defaults; `checkJs` is applied only when the caller passes it, else the preserved value + // stands. tsconfig.json - unlike editor-only jsconfig - is read by real build tools, so it + // additionally gets allowJs (JS files stay first-class in mixed projects) and noEmit (an + // accidental `npx tsc` must never emit files next to sources). function _configContent(fileName, existingCompilerOptions, checkJs) { const compilerOptions = Object.assign({ - module: "esnext", + module: "preserve", target: "esnext", - moduleResolution: "bundler", checkJs: false, - jsx: "react" + jsx: "react-jsx", + allowUmdGlobalAccess: true }, existingCompilerOptions || {}); if (checkJs !== undefined) { compilerOptions.checkJs = !!checkJs; @@ -117,7 +142,8 @@ define(function (require, exports, module) { return { autoGeneratedByPhoenixCode: _marker(), compilerOptions: compilerOptions, - exclude: ["node_modules", "dist", "build"] + typeAcquisition: { enable: true }, + exclude: ["node_modules", "bower_components", "dist", "build"] }; } @@ -447,6 +473,9 @@ define(function (require, exports, module) { if (Phoenix.isTestWindow) { return; // never write configs from a test window - it would pollute fixtures } + if (PreferencesManager.get(PREF_AUTO_CREATE) === false) { + return; // the user opted out of auto-creation (managing existing configs is unaffected) + } if (!editor || !editor.document) { return; } @@ -563,6 +592,8 @@ define(function (require, exports, module) { exports.init = init; exports.promptEnable = promptEnable; + exports.PREF_AUTO_CREATE = PREF_AUTO_CREATE; + exports.DOCS_URL = DOCS_URL; // exposed for unit tests (pure function; the event-driven flow never runs in test windows) exports._configContent = _configContent; }); diff --git a/src/extensions/default/TypeScriptSupport/ConfigPanel.js b/src/extensions/default/TypeScriptSupport/ConfigPanel.js new file mode 100644 index 0000000000..dcdf9a9d12 --- /dev/null +++ b/src/extensions/default/TypeScriptSupport/ConfigPanel.js @@ -0,0 +1,414 @@ +/* + * 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. + * + */ + +/** + * ConfigPanel - a friendly settings UI for the project's tsconfig.json/jsconfig.json. + * + * Auto-shows as a bottom panel whenever the project-root ts/jsconfig is the file being viewed and + * hides when the user navigates away. The JSON stays the source of truth: every control change is + * applied to the OPEN document (visible + undoable in the editor above) and saved immediately, and + * hand-edits to the JSON flow back into the controls. tsserver watches config files natively, so a + * saved change re-scopes the language server without an explicit restart. + * + * Files whose text is not strict JSON (comments / custom formatting) are shown a read-only notice + * instead of controls - a JSON.stringify round-trip would destroy the user's formatting. + * + * @module extensions/default/TypeScriptSupport/ConfigPanel + */ +define(function (require, exports, module) { + + + const WorkspaceManager = brackets.getModule("view/WorkspaceManager"), + MainViewManager = brackets.getModule("view/MainViewManager"), + DocumentManager = brackets.getModule("document/DocumentManager"), + ProjectManager = brackets.getModule("project/ProjectManager"), + CommandManager = brackets.getModule("command/CommandManager"), + Commands = brackets.getModule("command/Commands"), + PreferencesManager = brackets.getModule("preferences/PreferencesManager"), + NativeApp = brackets.getModule("utils/NativeApp"), + NotificationUI = brackets.getModule("widgets/NotificationUI"), + Mustache = brackets.getModule("thirdparty/mustache/mustache"), + _ = brackets.getModule("thirdparty/lodash"), + StringUtils = brackets.getModule("utils/StringUtils"), + Strings = brackets.getModule("strings"), + CodeIntelligence = require("./CodeIntelligence"), + panelTemplate = require("text!./htmlContent/config-panel.html"); + + const PANEL_ID = "typescript.config-settings"; + const CONFIG_FILES = ["tsconfig.json", "jsconfig.json"]; + // Sync the panel this long after JSON hand-edits settle (typing in the editor above). + const REFRESH_DEBOUNCE_MS = 300; + + // Curated select options: [file value, display label]. File values are the TS-canonical + // lowercase forms our generator emits; matching against the file is case-insensitive. + const TARGET_OPTIONS = [ + ["es5", "ES5"], ["es2015", "ES2015"], ["es2017", "ES2017"], + ["es2020", "ES2020"], ["es2022", "ES2022"], ["esnext", "ESNext"] + ]; + const MODULE_OPTIONS = [ + ["preserve", "Preserve"], ["esnext", "ESNext"], ["nodenext", "NodeNext"], ["commonjs", "CommonJS"] + ]; + const JSX_OFF = ""; // "Off" option: the jsx key is removed from compilerOptions + const JSX_OPTIONS = [ + ["react-jsx", "React JSX"], ["react", "React (classic)"], ["preserve", "Preserve"] + ]; + + let panel = null; // WorkspaceManager panel + let $panel = null; + let _currentPath = null; // fullPath of the config the panel is bound to, or null + let _applying = false; // a panel-driven edit is in flight - ignore its change echo + let _refreshTimer = null; + + function _projectConfigPath(fullPath) { + const root = ProjectManager.getProjectRoot(); + if (!root || !fullPath) { + return null; + } + const isRootConfig = CONFIG_FILES.some(function (name) { + return fullPath === root.fullPath + name; + }); + return isRootConfig ? fullPath : null; + } + + function _doc() { + return _currentPath ? DocumentManager.getOpenDocumentForPath(_currentPath) : null; + } + + // Strict parse only: a file with comments/JSONC would be destroyed by a stringify round-trip, + // so anything JSON.parse rejects puts the panel in read-only mode. + function _parsedConfig() { + const doc = _doc(); + if (!doc) { + return null; + } + try { + return JSON.parse(doc.getText()); + } catch (e) { + return null; + } + } + + // ----- select population ---------------------------------------------------------------- + + // Fill a + + {{Strings.CODE_INTEL_CFG_CHECK_JS_SUB}} + + + + + + + + +
+ {{Strings.CODE_INTEL_CFG_READ_ONLY}} +
+ + + diff --git a/src/extensions/default/TypeScriptSupport/main.js b/src/extensions/default/TypeScriptSupport/main.js index 51884f8637..e6bd3af419 100644 --- a/src/extensions/default/TypeScriptSupport/main.js +++ b/src/extensions/default/TypeScriptSupport/main.js @@ -36,7 +36,8 @@ define(function (require, exports, module) { EditorManager = brackets.getModule("editor/EditorManager"), FileSystem = brackets.getModule("filesystem/FileSystem"), NodeConnector = brackets.getModule("NodeConnector"), - CodeIntelligence = require("./CodeIntelligence"); + CodeIntelligence = require("./CodeIntelligence"), + ConfigPanel = require("./ConfigPanel"); const SERVER_ID = "typescript"; const SUPPORTED_LANGUAGES = ["javascript", "typescript", "jsx", "tsx"]; @@ -334,6 +335,10 @@ define(function (require, exports, module) { } }); + // Friendly settings UI for the project's ts/jsconfig - a bottom panel that auto-shows when + // the root config file is being viewed (see ConfigPanel). + ConfigPanel.init(); + // 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); diff --git a/src/extensions/default/TypeScriptSupport/unittests.js b/src/extensions/default/TypeScriptSupport/unittests.js index 28a78e896b..59e2f73184 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, path, jsPromise */ +/*global describe, it, expect, beforeAll, afterAll, afterEach, awaitsFor, awaitsForDone, path, jsPromise */ define(function (require, exports, module) { @@ -317,18 +317,191 @@ define(function (require, exports, module) { expect(js.compilerOptions.allowJs).toBeUndefined(); expect(js.compilerOptions.noEmit).toBeUndefined(); + // The universal template: module "preserve" resolves both import and require() with no + // extension demands (moduleResolution is implied by it, so the key must be absent); + // react-jsx and UMD-global access reduce flavor friction. + expect(js.compilerOptions.module).toBe("preserve"); + expect(js.compilerOptions.moduleResolution).toBeUndefined(); + expect(js.compilerOptions.jsx).toBe("react-jsx"); + expect(js.compilerOptions.allowUmdGlobalAccess).toBe(true); + + // typeAcquisition must be explicit on BOTH file types: it defaults ON for jsconfig but + // OFF for tsconfig, so without this the jsconfig->tsconfig upgrade would silently kill + // Node builtin/@types IntelliSense (ATA). + expect(js.typeAcquisition).toEqual({ enable: true }); + // upgrade: existing compilerOptions survive; checkJs preserved when not passed - const existing = { checkJs: true, target: "es2015" }; + const existing = { checkJs: true, target: "es2015", module: "nodenext" }; const ts = content("tsconfig.json", existing); expect(ts.compilerOptions.checkJs).toBe(true); // preserved expect(ts.compilerOptions.target).toBe("es2015"); // user's edit preserved + expect(ts.compilerOptions.module).toBe("nodenext"); // user's module choice preserved expect(ts.compilerOptions.allowJs).toBe(true); // build-safety additions expect(ts.compilerOptions.noEmit).toBe(true); + expect(ts.typeAcquisition).toEqual({ enable: true }); // explicit checkJs wins over the preserved value expect(content("jsconfig.json", { checkJs: true }, false).compilerOptions.checkJs).toBe(false); }); + // ----- config settings panel (friendly UI over the root ts/jsconfig) ----- + describe("config settings panel", function () { + + async function _setupConfigProject(configText, extraFiles) { + const FileSystem = testWindow.brackets.test.FileSystem; + const projectPath = await SpecRunnerUtils.getTempTestDirectory(testRootSpec + "js-plain", true); + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(projectPath, "jsconfig.json"), configText, FileSystem)); + for (const name of Object.keys(extraFiles || {})) { + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(projectPath, name), extraFiles[name], FileSystem)); + } + await SpecRunnerUtils.loadProjectInTestWindow(projectPath); + return projectPath; + } + + async function _generatedConfigText() { + const ExtensionLoader = testWindow.brackets.getModule("utils/ExtensionLoader"); + const CodeIntelligence = await new Promise(function (resolve, reject) { + ExtensionLoader.getRequireContextForExtension("TypeScriptSupport")( + ["CodeIntelligence"], resolve, reject); + }); + return JSON.stringify(CodeIntelligence._configContent("jsconfig.json", null, false), null, 4); + } + + function _panelVisible() { + return $("#ts-config-settings-panel").is(":visible"); + } + + afterEach(async function () { + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "close config panel project files"); + }); + + afterAll(async function () { + await SpecRunnerUtils.removeTempDirectory(); + }, 30000); + + it("auto-shows on the root config and hides when navigating away", async function () { + await _setupConfigProject(await _generatedConfigText(), { "app.js": "var a = 1;\n" }); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["jsconfig.json"]), "open jsconfig"); + await awaitsFor(_panelVisible, "config panel to auto-show", 30000); + // the generated config carries the marker, so the managed-only bits are visible + expect($("#ts-config-settings-panel .ts-cfg-auto-manage-wrap").hasClass("forced-hidden")).toBe(false); + expect($("#ts-config-settings-panel .ts-cfg-origin").hasClass("forced-hidden")).toBe(false); + + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["app.js"]), "open app.js"); + await awaitsFor(function () { + return !_panelVisible(); + }, "config panel to hide on navigating away", 30000); + }, 45000); + + it("toggling Check JavaScript edits and saves the file", async function () { + await _setupConfigProject(await _generatedConfigText(), {}); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["jsconfig.json"]), "open jsconfig"); + await awaitsFor(_panelVisible, "config panel to auto-show", 30000); + + const DocumentManager = testWindow.brackets.test.DocumentManager; + const doc = DocumentManager.getCurrentDocument(); + expect(doc.getText().indexOf("\"checkJs\": false")).not.toBe(-1); + + const $check = $("#ts-config-settings-panel .ts-cfg-check-js"); + $check.prop("checked", true).trigger("change"); + await awaitsFor(function () { + return doc.getText().indexOf("\"checkJs\": true") !== -1 && !doc.isDirty; + }, "checkJs true to be written and saved", 30000); + }, 45000); + + it("drops to read-only for configs with comments (JSONC)", async function () { + await _setupConfigProject( + "// user's commented config\n{\n \"compilerOptions\": { \"checkJs\": true }\n}\n", {}); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["jsconfig.json"]), "open jsconfig"); + await awaitsFor(_panelVisible, "config panel to auto-show", 30000); + await awaitsFor(function () { + return !$("#ts-config-settings-panel .ts-cfg-read-only").hasClass("forced-hidden"); + }, "read-only notice to show for JSONC", 30000); + expect($("#ts-config-settings-panel .ts-cfg-controls").hasClass("forced-hidden")).toBe(true); + }, 45000); + }); + + // ----- module flavors: the generated config must "just work" for each ----- + // Demo projects are generated per flavor with the REAL config content our generator emits + // (also proving vtsls accepts module "preserve"), then cross-file intelligence is asserted + // through the live server via hover (focus-independent, unlike the code-hint menu). + // AMD/RequireJS is deliberately absent: tsserver cannot infer those modules cross-file - + // a documented limitation, not a config problem. + describe("module flavors served by the generated config", function () { + + async function _setupFlavorProject(files) { + const FileSystem = testWindow.brackets.test.FileSystem; + const ExtensionLoader = testWindow.brackets.getModule("utils/ExtensionLoader"); + const CodeIntelligence = await new Promise(function (resolve, reject) { + ExtensionLoader.getRequireContextForExtension("TypeScriptSupport")( + ["CodeIntelligence"], resolve, reject); + }); + // randomize: a unique project path per test. Reusing a fixed temp path collides + // with per-project persisted state - Phoenix would try to restore files an earlier + // suite's getTempTestDirectory() wipe already deleted ("Error Opening File" dialog). + const projectPath = await SpecRunnerUtils.getTempTestDirectory(testRootSpec + "js-plain", true); + const cfg = CodeIntelligence._configContent("jsconfig.json", null, false); + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(projectPath, "jsconfig.json"), JSON.stringify(cfg, null, 4), FileSystem)); + for (const name of Object.keys(files)) { + await jsPromise(SpecRunnerUtils.createTextFile( + path.join(projectPath, name), files[name], FileSystem)); + } + await SpecRunnerUtils.loadProjectInTestWindow(projectPath); + await awaitsForDone(SpecRunnerUtils.openProjectFiles(["main.js"]), "open main.js"); + return EditorManager.getCurrentFullEditor(); + } + + // Hover over (line, ch) until the popover's text contains `expected` - proves the + // symbol resolved through the server (an unresolved symbol hovers as nothing/any). + async function _expectHoverContains(editor, line, ch, expected) { + await awaitsFor(async function () { + const popover = await _hoverPopoverAt(editor, line, ch); + return !!(popover && popover.content && popover.content.text().indexOf(expected) !== -1); + }, "hover at " + line + ":" + ch + " to contain '" + expected + "'", 30000, 500); + } + + afterEach(async function () { + await awaitsForDone(CommandManager.execute(Commands.FILE_CLOSE_ALL, { _forceClose: true }), + "close flavor project files"); + }); + + afterAll(async function () { + await SpecRunnerUtils.removeTempDirectory(); + }, 30000); + + it("resolves CommonJS require() across files (Node-CJS flavor)", async function () { + const editor = await _setupFlavorProject({ + "local.js": "module.exports = { greetCjs: function () { return \"hi\"; } };\n", + "main.js": "const util = require(\"./local\");\nutil.greetCjs();\n" + }); + // hover `greetCjs` in `util.greetCjs();` - only resolvable if require() resolved + await _expectHoverContains(editor, 1, 8, "greetCjs"); + }, 45000); + + it("resolves extensionless ESM imports (type:module flavor)", async function () { + const editor = await _setupFlavorProject({ + "package.json": JSON.stringify({ name: "esm-demo", type: "module" }, null, 4), + "lib.js": "export function greetEsm() { return \"hi\"; }\n", + "main.js": "import { greetEsm } from \"./lib\";\ngreetEsm();\n" + }); + // `./lib` has no extension - nodenext-style resolution would fail here; the + // template's module "preserve" must resolve it. + await _expectHoverContains(editor, 1, 3, "greetEsm"); + }, 45000); + + it("gives DOM intelligence to plain browser scripts (no lib configured)", async function () { + const editor = await _setupFlavorProject({ + "main.js": "const el = document.querySelector(\".x\");\nel.click();\n" + }); + // `document` types only exist because the default lib includes DOM + await _expectHoverContains(editor, 0, 15, "Document"); + }, 45000); + }); + // LSP quickfixes: diagnostics and fixes are separate channels - after diagnostics land, the // LintingProvider idle-fetches textDocument/codeAction quickfixes, decorates the cached // errors, and re-runs inspection so the Fix All button appears. `consol.log(1)` produces diff --git a/src/languageTools/DocumentSync.js b/src/languageTools/DocumentSync.js index ac97172264..f6b8084a18 100644 --- a/src/languageTools/DocumentSync.js +++ b/src/languageTools/DocumentSync.js @@ -86,28 +86,42 @@ define(function (require, exports, module) { return changes; } - // Offset (UTF-16 code units) of an LSP position within text. Lines split on "\n", matching what - // doc.getText() and the change records use. + // Offset (UTF-16 code units) of an LSP position within text, STRICT: returns -1 if the position + // lies outside the text (line beyond the last line, or character beyond that line's length). + // Strictness matters: a stale/raced edit can reference positions past the document's end, and a + // lenient clamp here would let it replay to the "right" text by coincidence and pass + // verification - while the server (tsserver crashes on out-of-range lines rather than clamping) + // receives the raw invalid range. Out-of-range must fail verification -> full-text resync. function _offsetAt(text, pos) { let line = 0, i = 0; while (line < pos.line) { const nl = text.indexOf("\n", i); if (nl === -1) { - return text.length; + return -1; } i = nl + 1; line++; } + const nextNl = text.indexOf("\n", i); + const lineEnd = nextNl === -1 ? text.length : nextNl; + if (i + pos.character > lineEnd) { + return -1; + } return i + pos.character; } // Replay LSP incremental contentChanges onto a string, in order. Used to verify the accumulated - // edits reproduce the current document before we trust them (see _contentChangesFor). + // edits reproduce the current document before we trust them (see _contentChangesFor). Returns + // null if any edit references a position outside the evolving text - the caller must treat that + // as a verification failure. function _applyIncremental(text, changes) { for (let i = 0; i < changes.length; i++) { const ch = changes[i]; const s = _offsetAt(text, ch.range.start); const e = _offsetAt(text, ch.range.end); + if (s === -1 || e === -1 || e < s) { + return null; + } text = text.slice(0, s) + ch.text + text.slice(e); } return text; @@ -166,7 +180,15 @@ define(function (require, exports, module) { fullResync: false // set when an unmappable change forces a full-text send }; tracked.set(vfsPath, state); - client.notifyDidOpen(client.uriForPath(vfsPath), _lspLanguageId(client, doc), state.version, text); + const opened = client.notifyDidOpen( + client.uriForPath(vfsPath), _lspLanguageId(client, doc), state.version, text); + if (opened && opened.catch) { + opened.catch(function () { + // The server never saw this file (e.g. it was mid-restart). Mark it not-open so the + // next change/flush re-sends a fresh didOpen instead of didChange-ing into a void. + state.open = false; + }); + } } function _change(client, doc) { @@ -183,7 +205,15 @@ define(function (require, exports, module) { state.lastSentText = text; state.pendingChanges = []; state.fullResync = false; - client.notifyDidChange(client.uriForPath(vfsPath), state.version, contentChanges); + const sent = client.notifyDidChange(client.uriForPath(vfsPath), state.version, contentChanges); + if (sent && sent.catch) { + sent.catch(function () { + // The server never received this change, so lastSentText no longer reflects its + // copy - incremental edits computed against it would desync (or crash) the server. + // Force the next send to carry full text. + state.fullResync = true; + }); + } } function _close(doc) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 6ba9b2a279..72d075a6ff 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1660,6 +1660,7 @@ define({ "DESCRIPTION_JSHINT_DISABLE": "true to disable JSHints linter in problems panel", "DESCRIPTION_ESLINT_DISABLE": "true to disable ESLint linter in problems panel", "DESCRIPTION_HTML_LINT_DISABLE": "true to disable HTML linter in problems panel", + "DESCRIPTION_CODE_INTEL_AUTO_CREATE": "false to stop {APP_NAME} from creating a jsconfig.json/tsconfig.json for code intelligence when opening projects. If you delete a created config, you can re-enable code intelligence from the Problems panel.", "DESCRIPTION_ESLINT_FAILED": "ESLint Failed ({0}). Make sure the project contains valid Configuration Files", "DESCRIPTION_ESLINT_USE_NATIVE_APP": "ESLint is only available in the Desktop app. Download it from phcode.io", "DESCRIPTION_ESLINT_LOAD_FAILED": "Failed to load ESLint for this project. {APP_NAME} supports only ESLint versions above 7.", @@ -1799,6 +1800,47 @@ define({ "CODE_INTEL_PANEL_TEXT": "Project-wide code intelligence is off. Enable it for Find Usages, Rename, and Go to Definition across every file — adds a jsconfig.json to the project root.", "CODE_INTEL_PANEL_ENABLE": "Enable", "CODE_INTEL_PANEL_DISMISS": "Dismiss", + // Code Intelligence config settings panel (shown when a root ts/jsconfig is the active file) + "CODE_INTEL_CFG_TITLE": "Code Intelligence", + "CODE_INTEL_CFG_ALL_OPTIONS": "All options…", + "CODE_INTEL_CFG_CHECK_JS": "Type-check JavaScript", + "CODE_INTEL_CFG_CHECK_JS_SUB": "Finds type errors in JS files", + "CODE_INTEL_CFG_CHECK_JS_INFO": "Finds typos and type mistakes in your JavaScript and shows them in the Problems panel. Warnings only — your code runs unchanged.", + "CODE_INTEL_CFG_CHECK_JS_EG_CODE": "console.lgo(\"hi\")", + "CODE_INTEL_CFG_CHECK_JS_EG_TEXT": "gets flagged: Did you mean 'log'?", + "CODE_INTEL_CFG_AUTO_TYPES": "Auto-download types", + "CODE_INTEL_CFG_AUTO_TYPES_SUB": "Fetches typings via npm", + "CODE_INTEL_CFG_AUTO_TYPES_INFO": "Code hints for npm packages start working even before you run npm install. Needs npm and internet; downloads are cached outside your project.", + "CODE_INTEL_CFG_AUTO_MANAGE": "Managed by {APP_NAME}", + "CODE_INTEL_CFG_AUTO_MANAGE_SUB": "Keeps this file updated automatically", + "CODE_INTEL_CFG_AUTO_MANAGE_INFO": "{APP_NAME} keeps this file tuned so code intelligence just works. Turn off to customize the file yourself — {APP_NAME} will then never modify it.", + "CODE_INTEL_CFG_TARGET": "Target", + "CODE_INTEL_CFG_TARGET_SUB": "The JS version your code uses", + "CODE_INTEL_CFG_TARGET_ES5": "legacy browsers (IE11-era)", + "CODE_INTEL_CFG_TARGET_ES2015": "classes, arrow functions, let/const", + "CODE_INTEL_CFG_TARGET_ES2017": "async/await", + "CODE_INTEL_CFG_TARGET_ES2020": "optional chaining a?.b, nullish ??", + "CODE_INTEL_CFG_TARGET_ES2022": "class fields, top-level await", + "CODE_INTEL_CFG_TARGET_ESNEXT": "everything current", + "CODE_INTEL_CFG_TARGET_FOOT": "Older targets flag newer syntax as errors — pick the newest unless your code must run on old engines.", + "CODE_INTEL_CFG_MODULE": "Module", + "CODE_INTEL_CFG_MODULE_SUB": "How import and require() resolve", + "CODE_INTEL_CFG_MODULE_PRESERVE": "understands both import and require(), no file-extension rules — best for editing", + "CODE_INTEL_CFG_MODULE_ESNEXT": "modern import/export semantics", + "CODE_INTEL_CFG_MODULE_NODENEXT": "Node's strict ESM rules — required file extensions, honors package.json \"type\"", + "CODE_INTEL_CFG_MODULE_COMMONJS": "classic require()/module.exports projects", + "CODE_INTEL_CFG_JSX": "JSX", + "CODE_INTEL_CFG_JSX_SUB": "How JSX in .jsx and .tsx files is treated", + "CODE_INTEL_CFG_JSX_REACT_JSX": "modern runtime (React 17+) — no import React needed", + "CODE_INTEL_CFG_JSX_REACT": "compiles to React.createElement — requires the React import", + "CODE_INTEL_CFG_JSX_PRESERVE": "leaves JSX untouched for another tool, e.g. a bundler", + "CODE_INTEL_CFG_JSX_OFF_INFO": "no JSX handling", + "CODE_INTEL_CFG_JSX_OFF": "Off", + "CODE_INTEL_CFG_CURRENT_VALUE": "(current: {0})", + "CODE_INTEL_CFG_ORIGIN_NOTE": "{APP_NAME} created this file to power project-wide code intelligence. Deleting it turns that off — re-enable anytime from the {0}.", + "CODE_INTEL_CFG_PROBLEMS_PANEL": "Problems panel", + "CODE_INTEL_CFG_AUTO_CREATE": "Create a config automatically in new projects", + "CODE_INTEL_CFG_READ_ONLY": "This file has custom formatting or comments — edit the JSON directly.", "REFERENCES_IN_FILES": "references", "REFERENCE_IN_FILES": "reference", "REFERENCES_NO_RESULTS": "No References available for current cursor position", diff --git a/src/styles/Extn-TypeScriptSupport.less b/src/styles/Extn-TypeScriptSupport.less new file mode 100644 index 0000000000..28a35bcce9 --- /dev/null +++ b/src/styles/Extn-TypeScriptSupport.less @@ -0,0 +1,273 @@ +// Code Intelligence config settings panel (TypeScriptSupport extension - shown when a root +// ts/jsconfig is the active file). Layout: a disciplined grid of contained "setting cards" - +// toggles as instant-apply switches, selects with stacked labels - over the standard +// .bottom-panel chrome. Compiled as part of brackets.less, so core variables apply. + +#ts-config-settings-panel { + + .toolbar { + .title .fa-solid { + margin-right: 6px; + opacity: 0.7; + } + .ts-cfg-file-name { + font-weight: normal; + opacity: 0.55; + margin-left: 8px; + font-size: 13px; + } + .ts-cfg-all-options { + position: absolute; + right: 34px; // clear of the tab-bar close affordance + top: 50%; + transform: translateY(-50%); + font-size: 13px; + cursor: pointer; + opacity: 0.9; + .fa-solid { + font-size: 9px; + margin-left: 3px; + opacity: 0.8; + } + &:hover { + opacity: 1; + text-decoration: none; + } + } + } + + .ts-cfg-body { + height: ~"calc(100% - 36px)"; + overflow-y: auto; + padding: 14px 16px 10px; + box-sizing: border-box; + display: flex; + flex-direction: column; + } + + // ----- the setting-card grid --------------------------------------------------------------- + // Uniform column counts at every width - all rows always show the same number of columns. + // All SIX cards live in ONE grid: 6 divides evenly by 3, 2 and 1, so every column count gives + // full rows with no orphan cards (at 3 columns the toggles/selects rows group naturally). The + // ts-cfg-cols-N class is set on the body by a ResizeObserver in ConfigPanel.js (container + // queries would do this in pure CSS, but the bundled less compiler is v3 - no @container). + .ts-cfg-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + .ts-cfg-body.ts-cfg-cols-2 .ts-cfg-grid { + grid-template-columns: repeat(2, 1fr); + } + .ts-cfg-body.ts-cfg-cols-1 .ts-cfg-grid { + grid-template-columns: 1fr; + } + + .ts-cfg-card { + display: flex; + flex-direction: column; + justify-content: center; + gap: 5px; + margin: 0; + padding: 10px 12px; + min-height: 52px; + box-sizing: border-box; + border-radius: 6px; + background: rgba(0, 0, 0, 0.035); + border: 1px solid rgba(0, 0, 0, 0.07); + cursor: pointer; + transition: border-color 0.12s ease, background-color 0.12s ease; + + &:hover { + border-color: rgba(0, 0, 0, 0.16); + } + .dark & { + background: rgba(255, 255, 255, 0.045); + border-color: rgba(255, 255, 255, 0.07); + &:hover { + border-color: rgba(255, 255, 255, 0.18); + } + } + } + + .ts-cfg-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .ts-cfg-label { + color: @bc-text; + font-size: 14px; + font-weight: @font-weight-semibold; + white-space: nowrap; + .dark & { + color: @dark-bc-text; + } + } + + // (i) icons: muted until hovered; the rich tooltip carries the real explanation. + .ts-cfg-info { + font-size: 12px; + opacity: 0.4; + margin-left: 2px; + transition: opacity 0.12s ease; + &:hover { + opacity: 0.85; + } + } + .ts-cfg-sublabel { + color: @bc-text; + opacity: 0.72; + font-size: 13px; + line-height: 1.35; + .dark & { + color: @dark-bc-text; + } + } + + // ----- switch-style toggles (instant apply -> switch semantics, Phoenix accent) -------------- + input[type="checkbox"].ts-switch { + appearance: none; + -webkit-appearance: none; + position: relative; + flex: none; + width: 30px; + height: 17px; + margin: 0; + border-radius: 17px; + border: 1px solid rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.12); + box-shadow: none; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; + // Phoenix's global checkbox styling draws a ✓ glyph via `:checked:before` (content '\2713') + // - suppress it, or the tick renders on top of the switch. Our knob is the ::after. + &::before, + &:checked::before { + content: none; + } + + &::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + transition: transform 0.15s ease; + } + &:checked { + background-color: @bc-primary-btn-bg; + border-color: @bc-primary-btn-border; + &::after { + transform: translateX(13px); + } + } + &:focus { + outline: none; + border-color: @bc-btn-border-focused; + } + .dark & { + border-color: rgba(255, 255, 255, 0.22); + background-color: rgba(255, 255, 255, 0.12); + &:checked { + background-color: @bc-primary-btn-bg; + border-color: @bc-primary-btn-border; + } + } + } + + // ----- selects: stacked label + full-width themed native select ------------------------------ + .ts-cfg-select select { + width: 100%; + height: 26px; + font-size: 13px; + padding: 1px 22px 1px 8px; + cursor: pointer; + box-sizing: border-box; + } + + // ----- read-only notice (file has comments/custom formatting) -------------------------------- + .ts-cfg-read-only { + color: @bc-text; + opacity: 0.7; + font-size: 13px; + padding: 4px 2px 12px; + .dark & { + color: @dark-bc-text; + } + .fa-solid { + margin-right: 7px; + opacity: 0.7; + } + } + + // ----- footer: origin note + auto-create preference ------------------------------------------ + .ts-cfg-footer { + // pinned to the bottom of the panel body (the body is a flex column) rather than hugging + // whatever content sits above - the divider reads as the panel's baseline. + margin-top: auto; + border-top: 1px solid @bc-panel-border; + padding: 10px 2px 0; + display: flex; + flex-wrap: wrap; + gap: 6px 36px; + align-items: center; + justify-content: space-between; + .dark & { + border-top-color: rgba(255, 255, 255, 0.08); + } + } + .ts-cfg-origin { + color: @bc-text; + opacity: 0.75; + font-size: 13px; + line-height: 1.5; + flex: 1 1 380px; + min-width: 260px; + .dark & { + color: @dark-bc-text; + } + .fa-solid { + margin-right: 6px; + } + } + .ts-cfg-auto-create { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + flex: none; + cursor: pointer; + color: @bc-text; + opacity: 0.9; + font-size: 13px; + .dark & { + color: @dark-bc-text; + } + input[type="checkbox"] { + cursor: pointer; + margin: 0; + } + } +} + +// Hovering the panel's "Problems panel" link pulses the status-bar problems indicator, teaching the +// user where that panel is opened from. Global selector on purpose - the indicator lives in the +// status bar, not inside our panel. The accent ring (Phoenix primary blue) reads on both themes. +@keyframes ts-cfg-locate-pulse-anim { + 0% { box-shadow: 0 0 0 0 rgba(40, 142, 223, 0.85); } + 70% { box-shadow: 0 0 0 7px rgba(40, 142, 223, 0); } + 100% { box-shadow: 0 0 0 0 rgba(40, 142, 223, 0); } +} + +#status-inspection.ts-cfg-locate-pulse { + animation: ts-cfg-locate-pulse-anim 1.1s ease-out infinite; + border-radius: 3px; + background-color: rgba(40, 142, 223, 0.25); +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 1eee29d54b..4992f0bdfc 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -52,6 +52,7 @@ @import "CentralControlBar.less"; @import "Extn-PhoenixTour.less"; @import "Extn-Terminal.less"; +@import "Extn-TypeScriptSupport.less"; @import "UserProfile.less"; @import "phoenix-pro.less"; @import "VideoPlayer.less"; @@ -4084,6 +4085,102 @@ label input { width: auto; } +// Rich hover tooltip (NotificationUI.attachRichTooltip) - a shared, body-attached, theme-aware +// tooltip surface. position:fixed viewport coordinates are set by NotificationUI when shown. +.phoenix-rich-tooltip { + position: fixed; + z-index: 10000; + max-width: 400px; + padding: 10px 13px; + border-radius: 6px; + font-size: 13px; + line-height: 1.55; + color: @bc-text; + background: #fff; + border: 1px solid @bc-panel-border; + box-shadow: 0 4px 16px @bc-shadow-large; + // Deliberately interactive: the pointer may travel onto the tooltip to read along or select + // text (NotificationUI keeps it open while hovered, with a short grace period on leave). + user-select: text; + cursor: default; + + .dark & { + color: @dark-bc-text; + background: #1e1e1e; + border-color: rgba(255, 255, 255, 0.12); + box-shadow: 0 4px 16px @dark-bc-shadow-large; + } + + code { + font-family: 'SourceCodePro', 'SF Mono', Menlo, Consolas, monospace; + font-size: 12px; + padding: 0 4px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.06); + .dark & { + background: rgba(255, 255, 255, 0.09); + } + } + + // Optional structured-content helpers, so feature tooltips share one typography: + // title / muted subtitle / aligned term->definition rows / footnote / code-example card. + .ph-tip-title { + font-weight: @font-weight-semibold; + font-size: 14px; + } + .ph-tip-sub { + opacity: 0.68; + font-size: 12.5px; + margin: 1px 0 8px; + } + .ph-tip-body { + font-size: 13px; + } + .ph-tip-rows { + display: grid; + grid-template-columns: max-content 1fr; + gap: 5px 10px; + font-size: 13px; + } + .ph-tip-term { + font-weight: @font-weight-semibold; + white-space: nowrap; + color: @bc-primary-btn-bg; + .dark & { + color: lighten(@bc-primary-btn-bg, 12%); + } + } + .ph-tip-def { + opacity: 0.9; + } + .ph-tip-foot { + margin-top: 7px; + padding-top: 6px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + opacity: 0.72; + font-size: 12.5px; + .dark & { + border-top-color: rgba(255, 255, 255, 0.1); + } + } + .ph-tip-example { + margin-top: 7px; + padding: 6px 8px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.045); + font-size: 12.5px; + .dark & { + background: rgba(255, 255, 255, 0.055); + } + code { + margin-right: 6px; + } + span { + opacity: 0.75; + } + } +} + // notification popup #toast-notification-container { diff --git a/src/styles/images/panel-icon-code-intel.svg b/src/styles/images/panel-icon-code-intel.svg new file mode 100644 index 0000000000..93b686efaf --- /dev/null +++ b/src/styles/images/panel-icon-code-intel.svg @@ -0,0 +1 @@ + diff --git a/src/widgets/NotificationUI.js b/src/widgets/NotificationUI.js index 6592d6fcb8..121aa45024 100644 --- a/src/widgets/NotificationUI.js +++ b/src/widgets/NotificationUI.js @@ -541,10 +541,139 @@ define(function (require, exports, module) { return notification; } + // ------------------------------------------------------------------------------------------ + // Rich hover tooltips + // ------------------------------------------------------------------------------------------ + + let $richTooltip = null, // shared singleton, body-attached so container overflow can't clip it + _tooltipTimer = null, + _tooltipHideTimer = null; + + // Leaving the trigger starts a short grace period instead of hiding instantly, so the user can + // move the pointer ONTO the tooltip (to read along / select text) without it vanishing. + const TOOLTIP_HIDE_GRACE_MS = 300; + + function _cancelTooltipHide() { + if (_tooltipHideTimer) { + clearTimeout(_tooltipHideTimer); + _tooltipHideTimer = null; + } + } + + function _scheduleTooltipHide() { + _cancelTooltipHide(); + _tooltipHideTimer = setTimeout(hideRichTooltip, TOOLTIP_HIDE_GRACE_MS); + } + + function _ensureRichTooltip() { + if (!$richTooltip) { + $richTooltip = $("") + .appendTo("body").hide(); + $richTooltip + .on("mouseenter", _cancelTooltipHide) // pointer reached the tooltip - keep it + .on("mouseleave", _scheduleTooltipHide); + } + return $richTooltip; + } + + // Position near the element: centered below, clamped to the viewport, flipped above when there + // is no room beneath. Uses position:fixed viewport coordinates. + function _positionRichTooltip($tip, element) { + const rect = element.getBoundingClientRect(), + tipWidth = $tip.outerWidth(), + tipHeight = $tip.outerHeight(), + winWidth = $(window).width(), + winHeight = $(window).height(); + let left = rect.left + (rect.width / 2) - (tipWidth / 2); + left = Math.max(8, Math.min(left, winWidth - tipWidth - 8)); + let top = rect.bottom + 8; + if (top + tipHeight > winHeight - 8) { + top = Math.max(8, rect.top - tipHeight - 8); + } + $tip.css({ left: left, top: top }); + } + + /** + * Hide the currently showing rich tooltip (if any). + * @type {function} + */ + function hideRichTooltip() { + if (_tooltipTimer) { + clearTimeout(_tooltipTimer); + _tooltipTimer = null; + } + _cancelTooltipHide(); + if ($richTooltip) { + $richTooltip.hide().empty(); + } + } + + /** + * Attaches a rich (HTML-capable) hover tooltip to the given element(s). The tooltip is + * Phoenix-themed for both light and dark themes, positioned beside the element, clamped to the + * viewport, and attached to `` so scrolling containers cannot clip it. + * + * ```js + * NotificationUI.attachRichTooltip($(".my-info-icon"), "Hello world"); + * // or compute content per element on show: + * NotificationUI.attachRichTooltip($(".my-info-icon"), el => $(el).attr("data-info")); + * ``` + * + * @param {jQuery|Element|string} elements - element(s) or selector to attach to + * @param {string|function(Element):string} html - TRUSTED html string (escape untrusted parts + * yourself), or a function returning it for the hovered element + * @param {Object} [options] optional, supported options: + * * `showDelayMs` - hover delay before the tooltip appears. Default 250. + * @return {{detach: function}} call `detach()` to unbind the handlers and hide the tooltip + * @type {function} + */ + function attachRichTooltip(elements, html, options = {}) { + const showDelayMs = options.showDelayMs === undefined ? 250 : options.showDelayMs; + const $elements = $(elements); + + function _show(element) { + const $tip = _ensureRichTooltip(); + const content = (typeof html === "function") ? html(element) : html; + if (!content) { + return; + } + $tip.html(content).show(); + _positionRichTooltip($tip, element); + } + + $elements + .on("mouseenter.phRichTooltip", function (event) { + const element = event.currentTarget; + _cancelTooltipHide(); + if (_tooltipTimer) { + clearTimeout(_tooltipTimer); + } + _tooltipTimer = setTimeout(function () { + _tooltipTimer = null; + _show(element); + }, showDelayMs); + }) + // grace period on leave (the pointer may be travelling to the tooltip itself); + // mousedown on the trigger dismisses immediately. + .on("mouseleave.phRichTooltip", _scheduleTooltipHide) + .on("mousedown.phRichTooltip", function () { + hideRichTooltip(); + }); + + return { + detach: function () { + $elements.off(".phRichTooltip"); + hideRichTooltip(); + } + }; + } + exports.createFromTemplate = createFromTemplate; exports.createToastFromTemplate = createToastFromTemplate; exports.showToastOn = showToastOn; exports.showHUD = showHUD; + exports.attachRichTooltip = attachRichTooltip; + exports.hideRichTooltip = hideRichTooltip; exports.CLOSE_REASON = CLOSE_REASON; exports.NOTIFICATION_STYLES_CSS_CLASS = NOTIFICATION_STYLES_CSS_CLASS; }); diff --git a/test/spec/DocumentSync-test.js b/test/spec/DocumentSync-test.js index f8cbb18562..eedf84f2e7 100644 --- a/test/spec/DocumentSync-test.js +++ b/test/spec/DocumentSync-test.js @@ -94,6 +94,17 @@ define(function (require, exports, module) { const batch = [edit(0, 8, 0, 9, "9"), edit(0, 4, 0, 5, "abc")]; expect(DocumentSync._applyIncremental("let a = 0;", batch)).toBe("let abc = 9;"); }); + + it("returns null for a position on a line beyond the text (STRICT, no clamping)", function () { + // Regression: a stale edit referencing a line past the end used to be clamped to + // end-of-text, letting it pass verification while the raw out-of-range line went to + // the server - tsserver crashes on that ("Debug Failure. Bad line number"). + expect(DocumentSync._applyIncremental("a\nb", [edit(2, 0, 2, 0, "x")])).toBe(null); + }); + + it("returns null for a character beyond the line's length", function () { + expect(DocumentSync._applyIncremental("ab\ncd", [edit(0, 5, 0, 5, "x")])).toBe(null); + }); }); describe("_contentChangesFor - the divergence safety net", function () { @@ -125,6 +136,16 @@ define(function (require, exports, module) { const out = DocumentSync._contentChangesFor(INCREMENTAL, "ab", [], false, "abc"); expect(out).toEqual([{ text: "abc" }]); }); + + it("falls back to full text when a pending edit is out of range for the base text", function () { + // The tsserver-crash regression: with lenient clamping this edit replayed to exactly + // the final text ("a\nb" + append = "a\nbx") and the invalid line-2 range shipped to + // the server. Strict replay must reject it and resync with full text instead. + const stale = [{ range: { start: { line: 2, character: 0 }, end: { line: 2, character: 0 } }, + text: "x" }]; + const out = DocumentSync._contentChangesFor(INCREMENTAL, "a\nb", stale, false, "a\nbx"); + expect(out).toEqual([{ text: "a\nbx" }]); + }); }); }); }); diff --git a/test/spec/NotificationUI-test.js b/test/spec/NotificationUI-test.js index 7b8418b333..6cbbd63729 100644 --- a/test/spec/NotificationUI-test.js +++ b/test/spec/NotificationUI-test.js @@ -218,5 +218,90 @@ define(function (require, exports, module) { }, "waiting for inline toast to close"); }); }); + + describe("attachRichTooltip", function () { + let $target, binding; + + beforeAll(function () { + $target = $("
").appendTo("body"); + }); + + afterAll(function () { + if (binding) { + binding.detach(); + } + $target.remove(); + NotificationUI.hideRichTooltip(); + }); + + function tooltip() { + return $(".phoenix-rich-tooltip"); + } + + it("Should show rich HTML on hover and hide on mouseleave", async function () { + binding = NotificationUI.attachRichTooltip($target, "rich content", + { showDelayMs: 0 }); + $target.trigger("mouseenter"); + await awaitsFor(function () { + return tooltip().is(":visible"); + }, "tooltip to appear on hover"); + expect(tooltip().find("b").text()).toBe("rich"); + + $target.trigger("mouseleave"); + await awaitsFor(function () { + return !tooltip().is(":visible"); + }, "tooltip to hide on mouseleave"); + }); + + it("Should compute content per element from a function", async function () { + binding.detach(); + $target.attr("data-info", "computed!"); + binding = NotificationUI.attachRichTooltip($target, function (el) { + return $(el).attr("data-info"); + }, { showDelayMs: 0 }); + $target.trigger("mouseenter"); + await awaitsFor(function () { + return tooltip().is(":visible") && tooltip().text() === "computed!"; + }, "tooltip to show computed content"); + $target.trigger("mouseleave"); + await awaitsFor(function () { + return !tooltip().is(":visible"); + }, "tooltip to hide"); + }); + + it("Should hide on mousedown and stop showing after detach", async function () { + $target.trigger("mouseenter"); + await awaitsFor(function () { + return tooltip().is(":visible"); + }, "tooltip to appear before mousedown"); + $target.trigger("mousedown"); + await awaitsFor(function () { + return !tooltip().is(":visible"); + }, "tooltip to hide on mousedown"); + + binding.detach(); + binding = null; + $target.trigger("mouseenter"); + await awaits(50); // give a (detached) show any chance to fire + expect(tooltip().is(":visible")).toBe(false); + }); + + it("Should stay within the viewport", async function () { + // park the target at the bottom-right corner - the tooltip must clamp/flip inside + $target.css({ top: ($(window).height() - 22) + "px", left: ($(window).width() - 22) + "px" }); + binding = NotificationUI.attachRichTooltip($target, "clamp me", { showDelayMs: 0 }); + $target.trigger("mouseenter"); + await awaitsFor(function () { + return tooltip().is(":visible"); + }, "tooltip to appear at screen edge"); + const rect = tooltip()[0].getBoundingClientRect(); + expect(rect.right).toBeLessThanOrEqual($(window).width()); + expect(rect.bottom).toBeLessThanOrEqual($(window).height()); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual(0); + $target.trigger("mouseleave"); + }); + }); }); });