diff --git a/CLAUDE.md b/CLAUDE.md index f127fea18c..011f189c80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,9 @@ - No trailing whitespace. - Use `const` and `let` instead of `var`. +## Build artifacts — do not hand-edit +- **`src/cacheManifest.json`** is a generated build artifact (gitignored, produced by `gulpfile.js/index.js`). It lists files + hashes for the service-worker cache. Never hand-edit or commit it — it is regenerated by the build, so edits are overwritten and won't be tracked anyway. When you add/remove/rename source files, just let the build regenerate it. + ## Translations / i18n - All user-visible strings must go in `src/nls/root/strings.js` — never hardcode English in source files. - Use `const Strings = require("strings");` then `Strings.KEY_NAME`. diff --git a/build/api-docs-generator.js b/build/api-docs-generator.js index 126c9ccbd1..73fec93b67 100644 --- a/build/api-docs-generator.js +++ b/build/api-docs-generator.js @@ -205,8 +205,34 @@ async function generateMarkdown(file, relativePath) { const modifiedContent = modifyJs(content, fileName); - // Generate markdown using jsdoc-to-markdown as a library - const markdownContent = await jsdoc2md.render({ source: modifiedContent }); + // Generate markdown using jsdoc-to-markdown as a library. jsdoc-api spawns + // a child `jsdoc` process per call; under our BATCH_SIZE parallelism the + // child occasionally produces truncated/empty stdout (manifesting as + // "Unexpected end of JSON input"/"Unterminated string in JSON"). Retry + // serially so a single transient failure doesn't abort the whole batch. + let markdownContent; + let lastErr; + for (let attempt = 0; attempt < 3; attempt++) { + try { + markdownContent = await jsdoc2md.render({ source: modifiedContent }); + lastErr = undefined; + break; + } catch (err) { + lastErr = err; + const causeMsg = err && err.cause && err.cause.message + ? err.cause.message + : ''; + const transient = /Unexpected end of JSON input|Unterminated string in JSON|Jsdoc failed/.test( + (err && err.message) || '' + ) || /JSON/.test(causeMsg); + if (!transient) break; + await new Promise(r => setTimeout(r, 200 * (attempt + 1))); + } + } + if (lastErr) { + lastErr.message = `[${file}] ${lastErr.message}`; + throw lastErr; + } const newContent = normalizeLineEndings( modifyMarkdown(markdownContent, path.join(relativePath, fileName)) ); diff --git a/docs/API-Reference/view/WorkspaceManager.md b/docs/API-Reference/view/WorkspaceManager.md index bb0c11ed12..eff290c760 100644 --- a/docs/API-Reference/view/WorkspaceManager.md +++ b/docs/API-Reference/view/WorkspaceManager.md @@ -111,7 +111,7 @@ The panel's size & visibility are automatically saved & restored as a view-state | $panel | jQueryObject | DOM content to use as the panel. Need not be in the document yet. Must have an id attribute, for use as a preferences key. | | [minSize] | number | @deprecated No longer used. Pass `undefined`. | | [title] | string | Display title shown in the bottom panel tab bar. | -| [options] | Object | Optional settings: - {string} iconSvg Path to an SVG icon for the panel tab (e.g. "styles/images/icon.svg"). If omitted, a generic default icon is used. | +| [options] | Object | Optional settings: - `iconSvg` (string): Path to an SVG icon for the panel tab (e.g. "styles/images/icon.svg"). If omitted, a generic default icon is used. | diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index 9a8ee23d3a..8d1b186de1 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -909,7 +909,7 @@ function _renameExtensionConcatAsExtensionJSInDist(extensionName) { } const minifyableExtensions = ["CloseOthers", "CodeFolding", "DebugCommands", "Git", - "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "QuickView"]; + "HealthData", "JavaScriptCodeHints", "JavaScriptRefactoring", "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", "HandlebarsSupport", "HTMLCodeHints", "HtmlEntityCodeHints", diff --git a/gulpfile.js/thirdparty-lib-copy.js b/gulpfile.js/thirdparty-lib-copy.js index e23579f6b3..cc064520a2 100644 --- a/gulpfile.js/thirdparty-lib-copy.js +++ b/gulpfile.js/thirdparty-lib-copy.js @@ -266,7 +266,10 @@ let copyThirdPartyLibs = series( renameFile.bind(renameFile, 'node_modules/@xterm/addon-webgl/lib/addon-webgl.js.map', 'addon-webgl.js.map', 'src/thirdparty/xterm'), copyLicence.bind(copyLicence, 'node_modules/@xterm/xterm/LICENSE', 'xterm') - + // vtsls language server (bundled in src-node is not installed in pipline tests as the destop app building + // does it for desktop LSP). we ran it once and copied the license here. + // copyLicence.bind(copyLicence, 'src-node/node_modules/@vtsls/language-server/LICENSE', 'vtsls'), + // copyLicence.bind(copyLicence, 'src-node/node_modules/typescript/LICENSE.txt', 'typescript') ); /** diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js index 95417a7adf..d3911a7492 100644 --- a/gulpfile.js/validate-build.js +++ b/gulpfile.js/validate-build.js @@ -35,9 +35,12 @@ const LARGE_FILE_LIST_DEV = { // Size limits for production/staging builds (in MB) const PROD_MAX_FILE_SIZE_MB = 2; const PROD_MAX_TOTAL_SIZE_MB = 80; -// Custom size limits for known large files (size in MB) For staging/production builds +// Custom size limits for known large files (size in MB) For staging/production builds. +// Margin policy: keep ~1 MB of headroom over a file's current size for individual files (and ~5 MB +// for the aggregate/total). Bump a limit only enough to restore that margin when a file legitimately +// grows - don't pad it large, so unexpected size jumps still get caught. const LARGE_FILE_LIST_PROD = { - 'dist/brackets.js': 10, // this is the full minified file itself renamed in prod + 'dist/brackets.js': 11, // this is the full minified file itself renamed in prod (~10 MB + 1 MB margin) 'dist/phoenix/virtualfs.js.map': 3 }; diff --git a/package.json b/package.json index 5b00324382..356dd413f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phoenix", - "version": "5.1.22-0", - "apiVersion": "5.1.22", + "version": "5.2.0-0", + "apiVersion": "5.2.0", "homepage": "https://core.ai", "issues": { "url": "https://github.com/phcode-dev/phoenix/issues" diff --git a/src-node/claude-code-agent.js b/src-node/claude-code-agent.js index 3bda06f8b9..94adaa99c6 100644 --- a/src-node/claude-code-agent.js +++ b/src-node/claude-code-agent.js @@ -673,8 +673,11 @@ exports.resumeSession = async function (params) { * Destroy the current session (clear session ID). */ exports.destroySession = async function () { + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } currentSessionId = null; - currentAbortController = null; _queuedClarification = null; return { success: true }; }; diff --git a/src-node/index.js b/src-node/index.js index a40bc1c678..4ad5376e00 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -70,6 +70,9 @@ require("./test-connection"); require("./utils"); require("./terminal"); require("./git/cli"); +// Note: "./lsp-client" is intentionally NOT required here. It is lazy-loaded on demand the +// first time the desktop LSP client is used (via NodeUtils._loadNodeExtensionModule), so the +// LSP framework adds nothing to node boot time. See src/languageTools/LSPClient.js. require("./claude-code-agent"); function randomNonce(byteLength) { const randomBuffer = new Uint8Array(byteLength); diff --git a/src-node/lsp-client.js b/src-node/lsp-client.js new file mode 100644 index 0000000000..f8929ec9f2 --- /dev/null +++ b/src-node/lsp-client.js @@ -0,0 +1,344 @@ +/* + * 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. + * + */ + +/** + * Pluggable LSP Client Infrastructure (Node side) + * + * Provides a reusable Language Server Protocol (LSP) client that supports multiple + * language servers simultaneously via a serverId-based registry. Browser extensions only + * configure their language server and make high-level calls; this module owns the process + * spawning and JSON-RPC framing. + * + * ``` + * BROWSER (Phoenix) NODE (this module) + * TypeScript/Python/Rust ─NodeConnector──▶ lsp-client.js ──stdio──▶ vtsls / pylsp / rust-analyzer + * extensions ("ph-lsp") (spawn + JSON-RPC, Content-Length framing) + * ``` + * + * API (called from the browser via `lspConnector.execPeer(, params)`): + * - startServer({ serverId, command, args=['--stdio'], rootUri }) -> { success, serverId, pid } + * - sendRequest({ serverId, method, params }) -> LSP result (awaits response) + * - sendNotification({ serverId, method, params }) -> { success } (fire and forget) + * - stopServer({ serverId }) -> { success, serverId } + * - listServers() -> [{ serverId, pid, rootUri }] + * - ping() -> { status: "pong", activeServers } + * + * Events emitted to the browser (`lspConnector.on(, ...)`): + * - 'lspNotification' { serverId, method, params } (e.g. textDocument/publishDiagnostics) + * - 'serverExit' { serverId, code } + * - 'serverError' { serverId, error } + * + * Server resolution order when starting: `src-node/node_modules/.bin/` (bundled), + * then the system PATH. Messages use JSON-RPC 2.0 over stdio with Content-Length headers. + */ + +// Create connector at module load time (same pattern as src-node/git/cli.js) +const nodeConnector = global.createNodeConnector("ph-lsp", exports); + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +// Path to node_modules/.bin for bundled LSP servers +const NODE_MODULES_BIN = path.join(__dirname, 'node_modules', '.bin'); + +// Registry of active servers: serverId -> serverState +const servers = new Map(); +let globalRequestId = 0; + +// Timeout for LSP requests (2 minutes) +const LSP_REQUEST_TIMEOUT = 120000; + +/** + * Encode a JSON-RPC message with an LSP Content-Length header. + * @param {Object} message - The JSON-RPC message object + * @returns {string} The encoded message with headers + */ +function encode(message) { + const content = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n${content}`; +} + +/** + * Create a stream parser for LSP messages from a specific server. + * Uses Buffer operations because Content-Length is measured in bytes. + * @param {string} serverId - The server identifier + * @returns {Function} Parser function that processes incoming data chunks + */ +function createParser(serverId) { + let buffer = Buffer.alloc(0); + const HEADER_DELIMITER = Buffer.from('\r\n\r\n'); + + return (data) => { + buffer = Buffer.concat([buffer, data]); + + while (true) { + const headerEnd = buffer.indexOf(HEADER_DELIMITER); + if (headerEnd === -1) { + break; + } + + const header = buffer.slice(0, headerEnd).toString('utf8'); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) { + // Invalid header - skip a byte and resync. + buffer = buffer.slice(1); + continue; + } + + const contentLength = parseInt(match[1], 10); + const contentStart = headerEnd + HEADER_DELIMITER.length; + + if (buffer.length < contentStart + contentLength) { + break; // Wait for more data. + } + + const json = buffer.slice(contentStart, contentStart + contentLength).toString('utf8'); + buffer = buffer.slice(contentStart + contentLength); + + try { + handleMessage(serverId, JSON.parse(json)); + } catch (e) { + console.error(`[lsp-client][${serverId}] parse error:`, e.message); + } + } + }; +} + +/** + * Handle a single incoming LSP message from a server. + * @param {string} serverId - The server identifier + * @param {Object} msg - The parsed JSON-RPC message + */ +function handleMessage(serverId, msg) { + const server = servers.get(serverId); + if (!server) { + return; + } + + if (msg.id !== undefined && server.pending.has(msg.id)) { + // Response to a request we sent. + const { resolve, reject } = server.pending.get(msg.id); + server.pending.delete(msg.id); + if (msg.error) { + reject(msg.error); + } else { + resolve(msg.result); + } + } else if (msg.method) { + // Notification or server-initiated request - forward to the browser. + nodeConnector.triggerPeer('lspNotification', { serverId, ...msg }); + } +} + +/** + * Ping endpoint to verify the LSP connector is alive. + * @returns {Promise} Status and list of active servers + */ +exports.ping = async function ping() { + return { status: "pong", activeServers: Array.from(servers.keys()) }; +}; + +/** + * Start a new language server. + * @param {Object} params - Server configuration + * @param {string} params.serverId - Unique identifier for this server instance + * @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 + * @returns {Promise} Result with success status and server info + */ +exports.startServer = async function startServer(params) { + const { serverId, command, args = ['--stdio'], rootUri } = params; + + if (!serverId || !command) { + throw new Error('serverId and command are required'); + } + + if (servers.has(serverId)) { + return { success: true, message: "already running", serverId }; + } + + // Prefer a server bundled in node_modules/.bin, otherwise fall back to PATH. + let commandPath = command; + const localBinPath = path.join(NODE_MODULES_BIN, command); + if (fs.existsSync(localBinPath)) { + commandPath = localBinPath; + } + + return new Promise((resolve, reject) => { + const serverProcess = spawn(commandPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const parser = createParser(serverId); + + const serverState = { + process: serverProcess, + pending: new Map(), + rootUri, + stderrTail: [] // keep the last few stderr lines to attach to crash reports + }; + + let hasResolved = false; + + serverProcess.stdout.on('data', parser); + serverProcess.stderr.on('data', (data) => { + const text = data.toString(); + serverState.stderrTail.push(text); + if (serverState.stderrTail.length > 50) { + serverState.stderrTail.shift(); + } + console.error(`[lsp-client][${serverId} stderr]`, text.trimEnd()); + }); + + serverProcess.on('spawn', () => { + servers.set(serverId, serverState); + hasResolved = true; + resolve({ success: true, serverId, pid: serverProcess.pid }); + }); + + serverProcess.on('exit', (code, signal) => { + servers.delete(serverId); + const stderr = serverState.stderrTail.join(''); + if (code) { + console.error(`[lsp-client][${serverId}] exited code=${code} signal=${signal || 'none'}`); + } + nodeConnector.triggerPeer('serverExit', { serverId, code, signal, stderr }); + if (!hasResolved) { + hasResolved = true; + reject(new Error(`Server ${serverId} exited immediately with code ${code}` + + (stderr ? `\n${stderr}` : ''))); + } + }); + + serverProcess.on('error', (err) => { + console.error(`[lsp-client][${serverId}] spawn error:`, err.message); + servers.delete(serverId); + nodeConnector.triggerPeer('serverError', { serverId, error: err.message }); + if (!hasResolved) { + hasResolved = true; + reject(new Error(`Failed to spawn ${serverId}: ${err.message}`)); + } + }); + + // Guard in case the 'spawn' event never fires. + setTimeout(() => { + if (!hasResolved) { + hasResolved = true; + reject(new Error(`Timeout waiting for ${serverId} to start`)); + } + }, 10000); + }); +}; + +/** + * Send an LSP request to a server and wait for the response. + * @param {Object} params - Request parameters + * @param {string} params.serverId - Target server identifier + * @param {string} params.method - LSP method name + * @param {Object} params.params - LSP request parameters + * @returns {Promise} The LSP response result + */ +exports.sendRequest = async function sendRequest(params) { + const { serverId, method, params: lspParams } = params; + const server = servers.get(serverId); + + if (!server) { + throw new Error(`Server ${serverId} not running`); + } + + const id = ++globalRequestId; + const msg = { jsonrpc: '2.0', id, method, params: lspParams }; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (server.pending.has(id)) { + server.pending.delete(id); + reject(new Error(`Request ${method} timed out after ${LSP_REQUEST_TIMEOUT}ms`)); + } + }, LSP_REQUEST_TIMEOUT); + + server.pending.set(id, { + resolve: (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + } + }); + + server.process.stdin.write(encode(msg)); + }); +}; + +/** + * Send an LSP notification to a server (no response expected). + * @param {Object} params - Notification parameters + * @param {string} params.serverId - Target server identifier + * @param {string} params.method - LSP method name + * @param {Object} params.params - LSP notification parameters + * @returns {Promise} Success confirmation + */ +exports.sendNotification = async function sendNotification(params) { + const { serverId, method, params: lspParams } = params; + const server = servers.get(serverId); + + if (!server) { + throw new Error(`Server ${serverId} not running`); + } + + const msg = { jsonrpc: '2.0', method, params: lspParams }; + server.process.stdin.write(encode(msg)); + return { success: true }; +}; + +/** + * Stop a running language server. + * @param {Object} params - Stop parameters + * @param {string} params.serverId - Server identifier to stop + * @returns {Promise} Success confirmation + */ +exports.stopServer = async function stopServer(params) { + const { serverId } = params; + const server = servers.get(serverId); + + if (server) { + // Reject any in-flight requests so browser-side promises do not hang. + for (const { reject } of server.pending.values()) { + reject(new Error(`Server ${serverId} stopped`)); + } + server.pending.clear(); + server.process.kill(); + servers.delete(serverId); + } + return { success: true, serverId }; +}; + +/** + * List all active language servers. + * @returns {Promise} Array of server info objects + */ +exports.listServers = async function listServers() { + return Array.from(servers.entries()).map(([id, state]) => ({ + serverId: id, + pid: state.process.pid, + rootUri: state.rootUri + })); +}; diff --git a/src-node/package-lock.json b/src-node/package-lock.json index b65ecac73d..b97c6c92be 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,18 +1,19 @@ { "name": "@phcode/node-core", - "version": "5.1.21-0", + "version": "5.2.0-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.21-0", + "version": "5.2.0-0", "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", + "@vtsls/language-server": "^0.3.0", "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", @@ -440,6 +441,54 @@ "ws": "^8.13.0" } }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "license": "MIT" + }, + "node_modules/@vtsls/language-server": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@vtsls/language-server/-/language-server-0.3.0.tgz", + "integrity": "sha512-EYTkCHNGz3MFSP7z0DZ5+WBQY5CWEH7bCUu53EaDloBTjghoi2vfZqSrS0+7WsRG03MhBhjGG9ifNee/2kixvQ==", + "license": "MIT", + "dependencies": { + "@vtsls/language-service": "0.3.0", + "vscode-languageserver": "^9.0.1", + "vscode-uri": "^3.1.0" + }, + "bin": { + "vtsls": "bin/vtsls.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vtsls/language-service": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@vtsls/language-service/-/language-service-0.3.0.tgz", + "integrity": "sha512-u2Z5oY64953CvG1SdI6Zimlbnaqj7OT/sj9zcw/gr/f6pl3e0ryh8lsa376MeC9T/SW7q+Aw0YMyUZIIIZOyng==", + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "@vtsls/vscode-fuzzy": "0.1.0", + "jsonc-parser": "^3.2.0", + "semver": "7.5.2", + "typescript": "5.9.3", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vtsls/vscode-fuzzy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vtsls/vscode-fuzzy/-/vscode-fuzzy-0.1.0.tgz", + "integrity": "sha512-jpJ6pFyi152BZ65j1D7otCf1YA9xaMqXV6nn2MOF8BNx0mkwIp2lTj26xvGk/mvEtiVvsGN3vMiBNXMxcv6bIA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1350,6 +1399,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/lmdb": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.1.tgz", @@ -1377,6 +1432,18 @@ "@lmdb/lmdb-win32-x64": "3.5.1" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3672,6 +3739,21 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -3930,6 +4012,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3948,6 +4043,80 @@ "node": ">= 0.8" } }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0.tgz", + "integrity": "sha512-+VvMmQPJhtvJ+8O+zu2JKIRiLxXF8NW7krWgyMGeOHrp4Cn23T5hc0v2LknNeopDOB70wghHAds7mKtcZ0I4Sg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.0.tgz", + "integrity": "sha512-Zdz+kJ12Iz6tc11xfZyEo501bBATHXrCjmMfnaR3pMnf1CoqZBKIynba3P+/bi9VEdrMbNtAVKYpKhbODvqy+Q==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.0", + "vscode-languageserver-types": "3.18.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "license": "MIT" + }, + "node_modules/vscode-languageserver/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "license": "MIT" + }, "node_modules/weak-lru-cache": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", @@ -3994,6 +4163,12 @@ } } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/src-node/package.json b/src-node/package.json index 2890e219a4..24f0a1d0f3 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -1,8 +1,8 @@ { "name": "@phcode/node-core", "description": "Phoenix Node Core", - "version": "5.1.22-0", - "apiVersion": "5.1.22", + "version": "5.2.0-0", + "apiVersion": "5.2.0", "keywords": [], "author": "arun@core.ai", "homepage": "https://github.com/phcode-dev/phoenix", @@ -23,6 +23,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.126", "@expo/sudo-prompt": "^9.3.2", "@phcode/fs": "^4.0.2", + "@vtsls/language-server": "^0.3.0", "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", diff --git a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js index 3ae76fb3c8..b79eae6201 100644 --- a/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js +++ b/src/LiveDevelopment/BrowserScripts/RemoteFunctions.js @@ -595,14 +595,13 @@ function RemoteFunctions(config = {}) { } const element = event.target; - if(!LivePreviewView.isElementInspectable(element) || element.nodeType !== Node.ELEMENT_NODE) { - return; - } - // Same element as last hover — nothing changed, skip entirely if (element === _lastHoverTarget) { return; } + if(!LivePreviewView.isElementInspectable(element) || element.nodeType !== Node.ELEMENT_NODE) { + return; + } _lastHoverTarget = element; // if _hoverHighlight is uninitialized, initialize it @@ -615,21 +614,30 @@ function RemoteFunctions(config = {}) { } } - function onElementHoverOut(event) { - // don't want highlighting and stuff when auto scrolling - if (SHARED_STATE.isAutoScrolling) { return; } + function _clearHoverState() { + if (SHARED_STATE.isAutoScrolling) { + return; + } + if (_hoverHighlight && shouldShowHighlightOnHover()) { + _lastHoverTarget = null; + _scheduleHoverUpdate(); + } + } + function onElementHoverOut(event) { const element = event.target; // Use isElementInspectable (not isElementEditable) so that JS-rendered // elements also get their hover highlight and hover box properly dismissed. if(LivePreviewView.isElementInspectable(element) && element.nodeType === Node.ELEMENT_NODE) { - if (_hoverHighlight && shouldShowHighlightOnHover()) { - _lastHoverTarget = null; - _scheduleHoverUpdate(); - } + _clearHoverState(); } } + // for popped out window: the in-panel iframe case is forwarded parent-side via _LD.clearHoverState(). + function onDocumentMouseLeave() { + _clearHoverState(); + } + function scrollElementToViewPort(element) { if (!element) { return; @@ -711,7 +719,9 @@ function RemoteFunctions(config = {}) { function disableHoverListeners() { window.document.removeEventListener("mouseover", onElementHover); + window.document.removeEventListener("mousemove", onElementHover); window.document.removeEventListener("mouseout", onElementHoverOut); + window.document.documentElement.removeEventListener("mouseleave", onDocumentMouseLeave); // Cancel any pending rAF hover update so stale callbacks don't fire if (_pendingHoverRAF) { cancelAnimationFrame(_pendingHoverRAF); @@ -732,7 +742,9 @@ function RemoteFunctions(config = {}) { if (config.mode === 'edit' && shouldShowHighlightOnHover()) { disableHoverListeners(); window.document.addEventListener("mouseover", onElementHover); + window.document.addEventListener("mousemove", onElementHover); window.document.addEventListener("mouseout", onElementHoverOut); + window.document.documentElement.addEventListener("mouseleave", onDocumentMouseLeave); } } @@ -1714,7 +1726,8 @@ function RemoteFunctions(config = {}) { "getHighlightCount": getHighlightCount, "getHighlightTrackingElement": getHighlightTrackingElement, "getHighlightStyle": getHighlightStyle, - "setHotCornerHidden": setHotCornerHidden + "setHotCornerHidden": setHotCornerHidden, + "clearHoverState": _clearHoverState }; // the below code comment is replaced by added scripts for extensibility diff --git a/src/LiveDevelopment/LiveDevMultiBrowser.js b/src/LiveDevelopment/LiveDevMultiBrowser.js index 2b0f8bc417..6a0f36deb2 100644 --- a/src/LiveDevelopment/LiveDevMultiBrowser.js +++ b/src/LiveDevelopment/LiveDevMultiBrowser.js @@ -733,6 +733,16 @@ define(function (require, exports, module) { } } + /** + * Clear hover highlight and hover box in the preview. Forwarded from the parent because the + * previewed iframe does not reliably receive mouseout/mouseleave on a slow pointer exit. + */ + function clearHoverState() { + if (_protocol) { + _protocol.evaluate("_LD.clearHoverState && _LD.clearHoverState()"); + } + } + /** * Update configuration in the remote browser */ @@ -860,6 +870,7 @@ define(function (require, exports, module) { exports.showHighlight = showHighlight; exports.hideHighlight = hideHighlight; exports.redrawHighlight = redrawHighlight; + exports.clearHoverState = clearHoverState; exports.getConfig = getConfig; exports.updateConfig = updateConfig; exports.refreshConfig = refreshConfig; diff --git a/src/LiveDevelopment/LivePreviewConstants.js b/src/LiveDevelopment/LivePreviewConstants.js index ecfc9a95db..3a99822b77 100644 --- a/src/LiveDevelopment/LivePreviewConstants.js +++ b/src/LiveDevelopment/LivePreviewConstants.js @@ -41,4 +41,8 @@ define(function main(require, exports, module) { exports.HIGHLIGHT_CLICK = "click"; exports.PREFERENCE_SHOW_RULER_LINES = "livePreviewShowMeasurements"; + + exports.PREFERENCE_SHOW_STYLES_BAR = "livePreviewShowStylesBar"; + + exports.PREFERENCE_STYLES_BAR_DOCK = "livePreviewStylesBarDock"; }); diff --git a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js index 4ff3a1fadc..9686ad405c 100644 --- a/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js +++ b/src/LiveDevelopment/MultiBrowserImpl/documents/LiveHTMLDocument.js @@ -127,8 +127,21 @@ define(function (require, exports, module) { body = HTMLInstrumentation.generateInstrumentedHTML(this.editor, this.protocol.getRemoteScript()); } + if (!body) { + // generateInstrumentedHTML() returns null when the document is empty or its + // HTML cannot be parsed into a DOM (no instrumentable content). In that case it + // also never injected the remote +