diff --git a/.gitignore b/.gitignore index 09a8da198..19949dfd1 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ test/thirdparty/jasmine-reporters /src/thirdparty/bugsnag-performance.min.js /src/thirdparty/bugsnag-performance.min.js.map /src/thirdparty/no-minify/ +/src/thirdparty/xterm # ignore files copied from node_modules to src/thirdparty # https://github.com/phcode-dev/phoenix/issues/10 diff --git a/docs/API-Reference/project/ProjectManager.md b/docs/API-Reference/project/ProjectManager.md index da68b0505..7406d91fa 100644 --- a/docs/API-Reference/project/ProjectManager.md +++ b/docs/API-Reference/project/ProjectManager.md @@ -206,6 +206,12 @@ If the provided path is to an old welcome project, returns the current one inste Initial project path is stored in prefs, which defaults to the welcome project on first launch. +**Kind**: global function + + +## \_continueLoadProject() +Internal: continue loading a project after beforeProjectClose handlers have resolved. + **Kind**: global function diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index b83279042..04d1825d4 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -142,7 +142,8 @@ function makeDistAll() { function makeJSDist() { return src(['src/**/*.js', '!src/**/unittest-files/**/*', "!src/thirdparty/prettier/**/*", - "!src/thirdparty/no-minify/**/*", "!src/LiveDevelopment/BrowserScripts/RemoteFunctions.js", + "!src/thirdparty/no-minify/**/*", "!src/thirdparty/xterm/**/*", + "!src/LiveDevelopment/BrowserScripts/RemoteFunctions.js", "!src/extensionsIntegrated/phoenix-pro/onboarding/**/*"]) .pipe(minify({ ext:{ @@ -175,6 +176,7 @@ function makeNonMinifyDist() { // we dont minify remote functions as its in live preview context and the prod minify is stripping variables // used by plugins in live preview. so we dont minify this for now. return src(["src/thirdparty/no-minify/**/*", + "src/thirdparty/xterm/**/*", "src/LiveDevelopment/BrowserScripts/RemoteFunctions.js", "src/extensionsIntegrated/phoenix-pro/onboarding/**/*"], {base: 'src'}) .pipe(dest('dist')); diff --git a/gulpfile.js/thirdparty-lib-copy.js b/gulpfile.js/thirdparty-lib-copy.js index 3e35b9380..e23579f6b 100644 --- a/gulpfile.js/thirdparty-lib-copy.js +++ b/gulpfile.js/thirdparty-lib-copy.js @@ -244,7 +244,28 @@ let copyThirdPartyLibs = series( 'test/thirdparty/jasmine-reporters/'), copyLicence.bind(copyLicence, 'node_modules/jasmine-reporters/LICENSE', 'jasmine-reporters'), // lmdb - copyLicence.bind(copyLicence, 'node_modules/lmdb/LICENSE', 'lmdb') + copyLicence.bind(copyLicence, 'node_modules/lmdb/LICENSE', 'lmdb'), + // xterm.js terminal emulator (@xterm/* scoped packages, v6) + copyFiles.bind(copyFiles, ['node_modules/@xterm/xterm/lib/xterm.js', + 'node_modules/@xterm/xterm/lib/xterm.js.map'], 'src/thirdparty/xterm'), + copyFiles.bind(copyFiles, ['node_modules/@xterm/xterm/css/xterm.css'], 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-fit/lib/addon-fit.js', + 'addon-fit.js', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-fit/lib/addon-fit.js.map', + 'addon-fit.js.map', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-search/lib/addon-search.js', + 'addon-search.js', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-search/lib/addon-search.js.map', + 'addon-search.js.map', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-web-links/lib/addon-web-links.js', + 'addon-web-links.js', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-web-links/lib/addon-web-links.js.map', + 'addon-web-links.js.map', 'src/thirdparty/xterm'), + renameFile.bind(renameFile, 'node_modules/@xterm/addon-webgl/lib/addon-webgl.js', + 'addon-webgl.js', 'src/thirdparty/xterm'), + 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') ); diff --git a/package-lock.json b/package-lock.json index 2d1ac373e..924d52bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "phoenix", - "version": "5.1.4-0", + "version": "5.1.5-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "phoenix", - "version": "5.1.4-0", + "version": "5.1.5-0", "hasInstallScript": true, "dependencies": { "@bugsnag/js": "^7.18.0", @@ -18,6 +18,11 @@ "@pixelbrackets/gfm-stylesheet": "^1.1.0", "@prettier/plugin-php": "^0.22.2", "@uiw/file-icons": "^1.3.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", + "@xterm/xterm": "^6.0.0", "bootstrap": "^5.1.3", "browser-mime": "^1.0.1", "codemirror": "^5.65.16", @@ -1324,6 +1329,39 @@ "node": ">=10.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -14199,6 +14237,31 @@ "integrity": "sha512-PrJx38EfpitFhwmILRl37jAdBlsww6AZ6rRVK4QS7T7RHLhX7mSs647sTmgr9GIxe3qjXdesmomEgbgaokrVFg==", "dev": true }, + "@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==" + }, + "@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==" + }, + "@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==" + }, + "@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==" + }, + "@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==" + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", diff --git a/package.json b/package.json index 1a010e938..fdb60370f 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,12 @@ "jasmine-core": "^4.2.0", "jasmine-reporters": "^2.5.0", "jsdoc-to-markdown": "^9.1.1", + "lmdb": "^3.5.1", "readable-stream": "^3.6.0", - "through2": "^4.0.2", - "lmdb": "^3.5.1" + "through2": "^4.0.2" }, "scripts": { - "postinstall": "npm install --prefix phoenix-builder-mcp", + "postinstall": "cd phoenix-builder-mcp && npm install", "lint": "eslint --quiet src test", "lint:fix": "eslint --quiet --fix src test", "prepare": "husky install", @@ -121,6 +121,11 @@ "requirejs": "^2.3.7", "tern": "^0.24.3", "tinycolor2": "^1.4.2", - "underscore": "^1.13.4" + "underscore": "^1.13.4", + "@xterm/xterm": "^6.0.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-search": "^0.16.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0" } -} \ No newline at end of file +} diff --git a/src-node/index.js b/src-node/index.js index 95472485a..a40bc1c67 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -68,6 +68,7 @@ const NodeConnector = require("./node-connector"); const LivePreview = require("./live-preview"); require("./test-connection"); require("./utils"); +require("./terminal"); require("./git/cli"); require("./claude-code-agent"); function randomNonce(byteLength) { diff --git a/src-node/package-lock.json b/src-node/package-lock.json index aa06a7edb..eaf532e16 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@phcode/node-core", - "version": "5.1.4-0", + "version": "5.1.5-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@phcode/node-core", - "version": "5.1.4-0", + "version": "5.1.5-0", "license": "GNU-AGPL3.0", "dependencies": { "@anthropic-ai/claude-code": "^1.0.0", @@ -15,6 +15,7 @@ "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", + "node-pty": "^1.1.0", "npm": "11.8.0", "open": "^10.1.0", "which": "^2.0.1", @@ -801,6 +802,22 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/node-pty/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/src-node/package.json b/src-node/package.json index 6d48ee666..927962655 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -25,10 +25,11 @@ "cross-spawn": "^7.0.6", "lmdb": "^3.5.1", "mime-types": "^2.1.35", + "node-pty": "^1.1.0", "npm": "11.8.0", "open": "^10.1.0", "which": "^2.0.1", "ws": "^8.17.1", "zod": "^3.25.76" } -} \ No newline at end of file +} diff --git a/src-node/terminal.js b/src-node/terminal.js new file mode 100644 index 000000000..40ec651cd --- /dev/null +++ b/src-node/terminal.js @@ -0,0 +1,445 @@ +/* + * 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. + * + */ + +const pty = require("node-pty"); +const os = require("os"); +const path = require("path"); +const which = require("which"); +const {execFile} = require("child_process"); +const NodeConnector = require("./node-connector"); + +const CONNECTOR_ID = "phoenix_terminal"; +const nodeConnector = NodeConnector.createNodeConnector(CONNECTOR_ID, exports); + +// Active terminal instances: id -> { pty, buffer, flushTimer, paused } +const terminals = {}; + +// Flow control config: rate-limit data sent to browser, discard excess on overflow. +// This keeps the UI responsive even under extreme output (e.g. `yes | head -n 500000`). +const FLUSH_INTERVAL = 16; // ms - max one send per interval +const MAX_CHUNK_SIZE = 16384; // 16KB - max bytes sent per flush +const BUFFER_PAUSE_THRESHOLD = 65536; // 64KB - pause PTY when buffer exceeds this +const BUFFER_RESUME_THRESHOLD = 16384; // 16KB - resume PTY when buffer drains below this +const BUFFER_TRUNCATE_THRESHOLD = 524288; // 512KB - discard oldest data if buffer exceeds this + +function _scheduleFlush(id) { + const term = terminals[id]; + if (!term || term.flushTimer) { + return; + } + term.flushTimer = setTimeout(() => { + term.flushTimer = null; + _flushBuffer(id); + }, FLUSH_INTERVAL); +} + +function _flushBuffer(id) { + const term = terminals[id]; + if (!term || !term.buffer.length) { + return; + } + + // If buffer is way too large, discard oldest data (keep tail) + if (term.buffer.length > BUFFER_TRUNCATE_THRESHOLD) { + term.buffer = term.buffer.slice(-MAX_CHUNK_SIZE); + } + + // Send at most MAX_CHUNK_SIZE bytes + let data; + if (term.buffer.length <= MAX_CHUNK_SIZE) { + data = term.buffer; + term.buffer = ""; + } else { + data = term.buffer.slice(0, MAX_CHUNK_SIZE); + term.buffer = term.buffer.slice(MAX_CHUNK_SIZE); + } + + nodeConnector.triggerPeer("terminalData", {id, data}); + + // Resume PTY if buffer drained enough + if (term.paused && term.buffer.length < BUFFER_RESUME_THRESHOLD) { + term.paused = false; + try { term.pty.resume(); } catch (e) { /* ignore */ } + } + + // Schedule next flush if buffer still has data + if (term.buffer.length > 0) { + _scheduleFlush(id); + } +} + +function _appendBuffer(id, data) { + const term = terminals[id]; + if (!term) { + return; + } + term.buffer += data; + + // Pause PTY if buffer is growing too large + if (!term.paused && term.buffer.length >= BUFFER_PAUSE_THRESHOLD) { + term.paused = true; + try { term.pty.pause(); } catch (e) { /* ignore */ } + } + + // Schedule a flush (no-op if already scheduled) + _scheduleFlush(id); +} + +/** + * Spawn a new PTY process + * @param {Object} params + * @param {string} params.id - Unique terminal ID + * @param {string} params.shell - Shell executable path + * @param {string[]} params.args - Shell arguments + * @param {string} params.cwd - Working directory + * @param {number} params.cols - Column count + * @param {number} params.rows - Row count + * @param {Object} params.env - Additional environment variables + * @returns {{id: string, pid: number, shell: string}} + */ +exports.createTerminal = async function ({id, shell, args, cwd, cols, rows, env}) { + if (terminals[id]) { + throw new Error(`Terminal with id ${id} already exists`); + } + + // Build environment + const termEnv = Object.assign({}, process.env, { + TERM: "xterm-256color", + COLORTERM: "truecolor", + TERM_PROGRAM: "Phoenix-Code" + }, env || {}); + + // Ensure LANG is set for proper Unicode support + if (!termEnv.LANG) { + termEnv.LANG = "en_US.UTF-8"; + } + + let ptyProcess; + try { + ptyProcess = pty.spawn(shell, args || [], { + name: "xterm-256color", + cols: cols || 80, + rows: rows || 24, + cwd: cwd || process.env.HOME || os.homedir(), + env: termEnv + }); + } catch (spawnErr) { + console.error("Terminal: pty.spawn failed:", spawnErr.message, spawnErr.stack); + throw spawnErr; + } + + terminals[id] = { + pty: ptyProcess, + shellPath: shell, + buffer: "", + flushTimer: null, + paused: false + }; + + ptyProcess.onData(function (data) { + _appendBuffer(id, data); + }); + + ptyProcess.onExit(function ({exitCode, signal}) { + const exitingTerm = terminals[id]; + if (exitingTerm) { + // Flush any remaining buffered output + clearTimeout(exitingTerm.flushTimer); + exitingTerm.flushTimer = null; + _flushBuffer(id); + delete terminals[id]; + } + nodeConnector.triggerPeer("terminalExit", {id, exitCode, signal}); + }); + + return {id, pid: ptyProcess.pid, shell}; +}; + +/** + * Write data to a terminal's PTY + * @param {Object} params + * @param {string} params.id - Terminal ID + * @param {string} params.data - Data to write + * @returns {{ok: boolean}} + */ +exports.writeTerminal = async function ({id, data}) { + const term = terminals[id]; + if (!term) { + throw new Error(`Terminal ${id} not found`); + } + term.pty.write(data); + return {ok: true}; +}; + +/** + * Resize a terminal's PTY + * @param {Object} params + * @param {string} params.id - Terminal ID + * @param {number} params.cols - New column count + * @param {number} params.rows - New row count + * @returns {{ok: boolean}} + */ +exports.resizeTerminal = async function ({id, cols, rows}) { + const term = terminals[id]; + if (!term) { + throw new Error(`Terminal ${id} not found`); + } + term.pty.resize(cols, rows); + return {ok: true}; +}; + +/** + * Kill a terminal's PTY process + * @param {Object} params + * @param {string} params.id - Terminal ID + * @returns {{ok: boolean}} + */ +exports.killTerminal = async function ({id}) { + const term = terminals[id]; + if (!term) { + return {ok: true}; // already dead + } + // Just kill the process; the onExit handler will clean up the terminal entry + try { + if (process.platform === "win32") { + // On Windows, use taskkill for process tree kill + const {execSync} = require("child_process"); + try { + execSync(`taskkill /pid ${term.pty.pid} /T /F`, {stdio: "ignore"}); + } catch (e) { + // Process may already be dead + } + } else { + term.pty.kill(); + } + } catch (e) { + // Process may already be dead — ensure cleanup still happens + clearTimeout(term.flushTimer); + term.flushTimer = null; + delete terminals[id]; + } + return {ok: true}; +}; + +/** + * Detect available shells on this OS + * @returns {{shells: Array<{name: string, path: string, args: string[], platform: string}>}} + */ +exports.getDefaultShells = async function () { + const platform = process.platform; + const shells = []; + + if (platform === "darwin") { + // macOS + const defaultShell = process.env.SHELL || "/bin/zsh"; + const candidates = [ + {name: "zsh", path: "/bin/zsh", args: ["--login"]}, + {name: "bash", path: "/bin/bash", args: ["--login"]}, + {name: "fish", path: "/usr/local/bin/fish", args: ["--login"]}, + {name: "fish", path: "/opt/homebrew/bin/fish", args: ["--login"]} + ]; + // Put default shell first + const defaultEntry = candidates.find(c => c.path === defaultShell); + if (defaultEntry) { + shells.push(Object.assign({}, defaultEntry, {isDefault: true, platform})); + } else { + shells.push({name: path.basename(defaultShell), path: defaultShell, args: ["--login"], isDefault: true, platform}); + } + for (const c of candidates) { + if (c.path !== defaultShell) { + try { + require("fs").accessSync(c.path, require("fs").constants.X_OK); + shells.push(Object.assign({}, c, {platform})); + } catch (e) { + // not available + } + } + } + } else if (platform === "linux") { + // Linux + const defaultShell = process.env.SHELL || "/bin/bash"; + const candidates = [ + {name: "bash", path: "/bin/bash", args: ["--login"]}, + {name: "zsh", path: "/usr/bin/zsh", args: ["--login"]}, + {name: "fish", path: "/usr/bin/fish", args: ["--login"]} + ]; + const defaultEntry = candidates.find(c => c.path === defaultShell); + if (defaultEntry) { + shells.push(Object.assign({}, defaultEntry, {isDefault: true, platform})); + } else { + shells.push({name: path.basename(defaultShell), path: defaultShell, args: ["--login"], isDefault: true, platform}); + } + for (const c of candidates) { + if (c.path !== defaultShell) { + try { + require("fs").accessSync(c.path, require("fs").constants.X_OK); + shells.push(Object.assign({}, c, {platform})); + } catch (e) { + // not available + } + } + } + } else if (platform === "win32") { + // Windows + const comspec = process.env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"; + shells.push({name: "Command Prompt", path: comspec, args: [], isDefault: false, platform}); + + // PowerShell + try { + const psPath = await which("powershell.exe"); + shells.push({name: "PowerShell", path: psPath, args: ["-ExecutionPolicy", "Bypass", "-NoLogo"], isDefault: true, platform}); + } catch (e) { + // not available + } + + // PowerShell Core + try { + const pwshPath = await which("pwsh.exe"); + shells.push({name: "PowerShell Core", path: pwshPath, args: ["-ExecutionPolicy", "Bypass", "-NoLogo"], platform}); + } catch (e) { + // not available + } + + // Git Bash + const gitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe"; + try { + require("fs").accessSync(gitBashPath, require("fs").constants.X_OK); + shells.push({name: "Git Bash", path: gitBashPath, args: ["--login"], platform}); + } catch (e) { + // not available + } + + // WSL + try { + const wslPath = await which("wsl.exe"); + shells.push({name: "WSL", path: wslPath, args: [], platform}); + } catch (e) { + // not available + } + + // If no default was set, mark first as default + if (!shells.find(s => s.isDefault)) { + shells[0].isDefault = true; + } + } + + return {shells}; +}; + +/** + * On Windows, node-pty's .process returns the terminal name (e.g. "xterm-256color") + * instead of the actual foreground process. This helper queries the process tree + * via PowerShell's Get-CimInstance to find the deepest child process name. + * Falls back gracefully if PowerShell is unavailable or returns unexpected output. + * @param {number} pid - The shell PID to look up children for + * @returns {Promise} The leaf child process name, or empty string + */ +function _getWindowsForegroundProcess(pid) { + return new Promise((resolve) => { + const psCommand = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}'` + + ` | Select-Object Name,ProcessId | ConvertTo-Json -Compress`; + let settled = false; + function done(val) { + if (!settled) { + settled = true; + resolve(val); + } + } + + // Hard 2-second deadline — don't block the UI waiting for PowerShell + const deadline = setTimeout(() => done(""), 2000); + + let child; + try { + child = execFile("powershell.exe", [ + "-NoProfile", "-NoLogo", "-Command", psCommand + ], {timeout: 2000, windowsHide: true}, (err, stdout) => { + clearTimeout(deadline); + if (err || !stdout || !stdout.trim()) { + done(""); + return; + } + try { + let parsed = JSON.parse(stdout.trim()); + // PowerShell returns a single object if one result, an array if multiple + if (!Array.isArray(parsed)) { + parsed = [parsed]; + } + const leaf = parsed.length > 0 ? parsed[parsed.length - 1] : null; + done(leaf && typeof leaf.Name === "string" ? leaf.Name : ""); + } catch (e) { + done(""); + } + }); + } catch (e) { + // powershell.exe not found or execFile threw synchronously + clearTimeout(deadline); + done(""); + return; + } + + child.on("error", () => { + clearTimeout(deadline); + done(""); + }); + }); +} + +/** + * Get foreground process info for a terminal + * @param {Object} params + * @param {string} params.id - Terminal ID + * @returns {{process: string, pid: number}} + */ +exports.getTerminalProcess = async function ({id}) { + const term = terminals[id]; + if (!term) { + throw new Error(`Terminal ${id} not found`); + } + + // On Mac/Linux, node-pty .process returns the actual foreground process name + if (process.platform !== "win32") { + return { + process: term.pty.process, + pid: term.pty.pid + }; + } + + // On Windows, resolve the actual process from the PID tree + const shellPid = term.pty.pid; + const childName = await _getWindowsForegroundProcess(shellPid); + // If a child process exists, return it; otherwise return the shell executable name + const processName = childName || path.basename(term.shellPath || ""); + return { + process: processName, + pid: shellPid + }; +}; + +// Clean up all terminals on process exit +process.on("exit", function () { + for (const id of Object.keys(terminals)) { + try { + terminals[id].pty.kill(); + } catch (e) { + // ignore + } + } +}); diff --git a/src/extensions/default/HealthData/main.js b/src/extensions/default/HealthData/main.js index 687211509..b4af617af 100644 --- a/src/extensions/default/HealthData/main.js +++ b/src/extensions/default/HealthData/main.js @@ -57,7 +57,9 @@ define(function (require, exports, module) { } AppInit.appReady(function () { - initTest(); + if (brackets.test) { + initTest(); + } }); addCommand(); diff --git a/src/extensionsIntegrated/Terminal/ShellProfiles.js b/src/extensionsIntegrated/Terminal/ShellProfiles.js new file mode 100644 index 000000000..200b1c43d --- /dev/null +++ b/src/extensionsIntegrated/Terminal/ShellProfiles.js @@ -0,0 +1,93 @@ +/* + * 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. + * + */ + +/** + * ShellProfiles - manages available shell profiles for the terminal extension. + * Detects shells via the Node-side getDefaultShells API and caches them. + */ +define(function (require, exports, module) { + + let _shells = []; + let _defaultShell = null; + + /** + * Initialize shell profiles from the Node-side detection + * @param {Object} nodeConnector - The NodeConnector instance + */ + async function init(nodeConnector) { + try { + const result = await nodeConnector.execPeer("getDefaultShells", {}); + _shells = result.shells || []; + _defaultShell = _shells.find(s => s.isDefault) || _shells[0] || null; + } catch (e) { + console.error("Terminal: Failed to detect shells:", e); + _shells = []; + _defaultShell = null; + } + } + + /** + * Get all available shells + * @returns {Array} List of shell profiles + */ + function getShells() { + return _shells; + } + + /** + * Get the default shell profile + * @returns {Object|null} Default shell profile + */ + function getDefaultShell() { + return _defaultShell; + } + + /** + * Get a shell by name + * @param {string} name - Shell name + * @returns {Object|null} Shell profile or null + */ + function getShellByName(name) { + return _shells.find(s => s.name === name) || null; + } + + /** + * Set a shell as the default by name + * @param {string} name - Shell name to set as default + */ + function setDefaultShell(name) { + const shell = _shells.find(s => s.name === name); + if (!shell) { + return; + } + // Clear old default + for (const s of _shells) { + s.isDefault = false; + } + shell.isDefault = true; + _defaultShell = shell; + } + + exports.init = init; + exports.getShells = getShells; + exports.getDefaultShell = getDefaultShell; + exports.getShellByName = getShellByName; + exports.setDefaultShell = setDefaultShell; +}); diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js new file mode 100644 index 000000000..b196efc81 --- /dev/null +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -0,0 +1,374 @@ +/* + * 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. + * + */ + +/** + * TerminalInstance - manages a single xterm.js terminal instance and its + * connection to a PTY process via NodeConnector. + */ +define(function (require, exports, module) { + + // xterm.js and addons (loaded as AMD modules from thirdparty) + const xtermModule = require("thirdparty/xterm/xterm"); + const FitAddonModule = require("thirdparty/xterm/addon-fit"); + const WebLinksAddonModule = require("thirdparty/xterm/addon-web-links"); + const SearchAddonModule = require("thirdparty/xterm/addon-search"); + const WebglAddonModule = require("thirdparty/xterm/addon-webgl"); + + const Terminal = xtermModule.Terminal; + const FitAddon = FitAddonModule.FitAddon; + const WebLinksAddon = WebLinksAddonModule.WebLinksAddon; + const SearchAddon = SearchAddonModule.SearchAddon; + const WebglAddon = WebglAddonModule.WebglAddon; + + let _nextId = 0; + + // Shortcuts that should be passed to the editor, not the terminal + const EDITOR_SHORTCUTS = [ + {ctrlKey: true, shiftKey: true, key: "p"}, // Command Palette + {ctrlKey: true, key: "p"}, // Quick Open + {ctrlKey: true, key: "b"}, // Toggle sidebar + {ctrlKey: true, key: "Tab"}, // Next tab + {ctrlKey: true, shiftKey: true, key: "Tab"} // Previous tab + ]; + + /** + * Read terminal theme colors from CSS variables + * @returns {Object} xterm.js theme object + */ + function _getThemeFromCSS() { + const panelEl = document.querySelector('.terminal-panel-container') || document.documentElement; + const style = getComputedStyle(panelEl); + function v(name) { + return style.getPropertyValue(name).trim() || undefined; + } + return { + background: v("--terminal-background"), + foreground: v("--terminal-foreground"), + cursor: v("--terminal-cursor"), + cursorAccent: v("--terminal-background"), + selectionBackground: v("--terminal-selection"), + selectionForeground: undefined, + black: v("--terminal-ansi-black"), + red: v("--terminal-ansi-red"), + green: v("--terminal-ansi-green"), + yellow: v("--terminal-ansi-yellow"), + blue: v("--terminal-ansi-blue"), + magenta: v("--terminal-ansi-magenta"), + cyan: v("--terminal-ansi-cyan"), + white: v("--terminal-ansi-white"), + brightBlack: v("--terminal-ansi-bright-black"), + brightRed: v("--terminal-ansi-bright-red"), + brightGreen: v("--terminal-ansi-bright-green"), + brightYellow: v("--terminal-ansi-bright-yellow"), + brightBlue: v("--terminal-ansi-bright-blue"), + brightMagenta: v("--terminal-ansi-bright-magenta"), + brightCyan: v("--terminal-ansi-bright-cyan"), + brightWhite: v("--terminal-ansi-bright-white") + }; + } + + /** + * Create a new TerminalInstance + * @param {Object} nodeConnector - NodeConnector for communication + * @param {Object} shellProfile - Shell profile {name, path, args} + * @param {string} cwd - Working directory + * @constructor + */ + function TerminalInstance(nodeConnector, shellProfile, cwd) { + this.id = "term_" + (++_nextId); + this.nodeConnector = nodeConnector; + this.shellProfile = shellProfile; + this.cwd = cwd; + this.title = shellProfile.name; + this.pid = null; + this.isAlive = false; + this.terminal = null; + this.fitAddon = null; + this.searchAddon = null; + this.$container = null; + this._resizeTimeout = null; + this._disposed = false; + + // Bound event handlers for cleanup + this._onTerminalData = this._onTerminalData.bind(this); + this._onTerminalExit = this._onTerminalExit.bind(this); + } + + /** + * Create the xterm.js terminal and attach it to the DOM + * @param {jQuery} $parentContainer - The container to attach the terminal to + */ + TerminalInstance.prototype.create = function ($parentContainer) { + // Create DOM container + this.$container = $('
'); + $parentContainer.append(this.$container); + + // Create xterm.js instance + this.terminal = new Terminal({ + theme: _getThemeFromCSS(), + fontFamily: "'Menlo', 'DejaVu Sans Mono', 'Consolas', 'Lucida Console', monospace", + fontSize: 13, + lineHeight: 1.2, + cursorBlink: true, + cursorStyle: "block", + scrollback: 10000, + allowProposedApi: true + }); + + // Load addons + this.fitAddon = new FitAddon(); + this.searchAddon = new SearchAddon(); + + this.terminal.loadAddon(this.fitAddon); + this.terminal.loadAddon(new WebLinksAddon()); + this.terminal.loadAddon(this.searchAddon); + + // Open terminal in DOM + this.terminal.open(this.$container[0]); + + // Load WebGL renderer for better performance + try { + this.terminal.loadAddon(new WebglAddon()); + } catch (e) { + console.warn("Terminal: WebglAddon failed to load, using default renderer:", e); + } + + // Fit to container + this._fit(); + + // Set up custom key handler to intercept editor shortcuts + this.terminal.attachCustomKeyEventHandler(this._customKeyHandler.bind(this)); + + // Wire input: terminal -> PTY + this.terminal.onData((data) => { + if (this.isAlive) { + this.nodeConnector.execPeer("writeTerminal", {id: this.id, data}).catch((err) => { + console.error("Terminal: write error:", err); + }); + } + }); + + // Wire resize: terminal -> PTY + this.terminal.onResize(({cols, rows}) => { + if (this.isAlive) { + this.nodeConnector.execPeer("resizeTerminal", {id: this.id, cols, rows}).catch((err) => { + console.error("Terminal: resize error:", err); + }); + } + }); + + // Listen for title changes from xterm + this.terminal.onTitleChange((newTitle) => { + if (newTitle) { + this.title = newTitle; + if (this.onTitleChanged) { + this.onTitleChanged(this.id, newTitle); + } + } + }); + + // Listen for NodeConnector events + this.nodeConnector.on("terminalData", this._onTerminalData); + this.nodeConnector.on("terminalExit", this._onTerminalExit); + }; + + /** + * Spawn the PTY process on the Node side + */ + TerminalInstance.prototype.spawn = async function () { + const dims = this.fitAddon.proposeDimensions(); + try { + const result = await this.nodeConnector.execPeer("createTerminal", { + id: this.id, + shell: this.shellProfile.path, + args: this.shellProfile.args || [], + cwd: this.cwd, + cols: dims ? dims.cols : 80, + rows: dims ? dims.rows : 24 + }); + this.pid = result.pid; + this.isAlive = true; + } catch (err) { + console.error("Terminal: Failed to spawn PTY:", err); + this.terminal.write("\r\n\x1b[31mFailed to start terminal: " + err.message + "\x1b[0m\r\n"); + } + }; + + /** + * Handle data from PTY (NodeConnector event) + */ + TerminalInstance.prototype._onTerminalData = function (_event, eventData) { + if (eventData.id === this.id && this.terminal) { + this.terminal.write(eventData.data); + } + }; + + /** + * Handle PTY exit (NodeConnector event) + */ + TerminalInstance.prototype._onTerminalExit = function (_event, eventData) { + if (eventData.id === this.id) { + this.isAlive = false; + if (this.terminal) { + this.terminal.write("\r\n\x1b[90m[Process exited with code " + eventData.exitCode + "]\x1b[0m\r\n"); + } + if (this.onProcessExit) { + this.onProcessExit(this.id, eventData.exitCode); + } + } + }; + + /** + * Custom key event handler - intercept editor shortcuts and clipboard keys + * Returns true to allow xterm to handle, false to prevent + */ + TerminalInstance.prototype._customKeyHandler = function (event) { + // Only intercept keydown events + if (event.type !== "keydown") { + return true; + } + + const ctrlOrMeta = event.ctrlKey || event.metaKey; + + // Ctrl+C with a selection should copy to clipboard, not send SIGINT + if (ctrlOrMeta && !event.shiftKey && event.key.toLowerCase() === "c" && this.terminal.hasSelection()) { + return false; + } + + for (const shortcut of EDITOR_SHORTCUTS) { + const ctrlMatch = shortcut.ctrlKey ? ctrlOrMeta : !ctrlOrMeta; + const shiftMatch = shortcut.shiftKey ? event.shiftKey : !event.shiftKey; + const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase(); + + if (ctrlMatch && shiftMatch && keyMatch) { + return false; // Don't let xterm handle it + } + } + + return true; // Let xterm handle it + }; + + /** + * Fit the terminal to its container + */ + TerminalInstance.prototype._fit = function () { + if (this.fitAddon && this.$container && this.$container.is(":visible")) { + try { + this.fitAddon.fit(); + } catch (e) { + // Container might not be visible yet + } + } + }; + + /** + * Handle container resize - debounced + */ + TerminalInstance.prototype.handleResize = function () { + clearTimeout(this._resizeTimeout); + this._resizeTimeout = setTimeout(() => { + this._fit(); + }, 50); + }; + + /** + * Show this terminal (make its container visible) + */ + TerminalInstance.prototype.show = function () { + if (this.$container) { + this.$container.addClass("active"); + this._fit(); + this.terminal.focus(); + } + }; + + /** + * Hide this terminal + */ + TerminalInstance.prototype.hide = function () { + if (this.$container) { + this.$container.removeClass("active"); + } + }; + + /** + * Focus the terminal + */ + TerminalInstance.prototype.focus = function () { + if (this.terminal) { + this.terminal.focus(); + } + }; + + /** + * Clear the terminal screen + */ + TerminalInstance.prototype.clear = function () { + if (this.terminal) { + this.terminal.clear(); + } + }; + + /** + * Update the terminal theme (e.g., after theme change) + */ + TerminalInstance.prototype.updateTheme = function () { + if (this.terminal) { + this.terminal.options.theme = _getThemeFromCSS(); + } + }; + + /** + * Kill the PTY process and dispose of the terminal + */ + TerminalInstance.prototype.dispose = function () { + if (this._disposed) { + return; + } + this._disposed = true; + + // Remove event listeners + this.nodeConnector.off("terminalData", this._onTerminalData); + this.nodeConnector.off("terminalExit", this._onTerminalExit); + + // Kill PTY + if (this.isAlive) { + this.nodeConnector.execPeer("killTerminal", {id: this.id}).catch((err) => { + console.error("Terminal: kill error:", err); + }); + this.isAlive = false; + } + + // Dispose xterm + clearTimeout(this._resizeTimeout); + if (this.terminal) { + this.terminal.dispose(); + this.terminal = null; + } + + // Remove DOM + if (this.$container) { + this.$container.remove(); + this.$container = null; + } + }; + + module.exports = TerminalInstance; +}); diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js new file mode 100644 index 000000000..dbe2b75b3 --- /dev/null +++ b/src/extensionsIntegrated/Terminal/main.js @@ -0,0 +1,586 @@ +/* + * 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. + * + */ + +/** + * Terminal Extension - Integrates a terminal panel into Phoenix Code. + * Only available in native (desktop) builds where node-pty is available. + */ +define(function (require, exports, module) { + + if (!Phoenix.isNativeApp) { + return; // Terminal requires Node.js (node-pty) + } + + const AppInit = require("utils/AppInit"); + const CommandManager = require("command/CommandManager"); + const Menus = require("command/Menus"); + const WorkspaceManager = require("view/WorkspaceManager"); + const ProjectManager = require("project/ProjectManager"); + const ExtensionUtils = require("utils/ExtensionUtils"); + const NodeConnector = require("NodeConnector"); + const Mustache = require("thirdparty/mustache/mustache"); + const Dialogs = require("widgets/Dialogs"); + const Strings = require("strings"); + const StringUtils = require("utils/StringUtils"); + + const TerminalInstance = require("./TerminalInstance"); + const ShellProfiles = require("./ShellProfiles"); + const panelHTML = require("text!./terminal-panel.html"); + + // Load xterm.css (terminal panel styles are in src/styles/Extn-Terminal.less) + ExtensionUtils.loadStyleSheet(module, "../../thirdparty/xterm/xterm.css"); + + // Constants + const CMD_TOGGLE_TERMINAL = "terminal.toggle"; + const CMD_NEW_TERMINAL = "terminal.new"; + const PANEL_ID = "terminal-panel"; + const PANEL_MIN_SIZE = 100; + + // Shell process names — if the foreground process is one of these, no child is running + const SHELL_NAMES = new Set([ + "bash", "zsh", "fish", "sh", "dash", "ksh", "csh", "tcsh", + "pwsh", "powershell", "cmd.exe", "nu", "elvish", "xonsh", + "login", + // Windows shell executables (returned with .exe suffix) + "bash.exe", "pwsh.exe", "powershell.exe", "nu.exe", + "fish.exe", "elvish.exe", "xonsh.exe", "wsl.exe" + ]); + + /** + * Check if a process name is a shell (handles full paths like /bin/bash) + */ + function _isShellProcess(processName) { + if (!processName) { + return true; + } + const basename = processName.split("/").pop().split("\\").pop(); + return SHELL_NAMES.has(basename); + } + + // State + let panel = null; + let nodeConnector = null; + let terminalInstances = []; // All terminal instances + let activeTerminalId = null; // Currently visible terminal + let processInfo = {}; // id -> processName from PTY + let originalDefaultShellName = null; // System-detected default shell name + let $panel, $contentArea, $shellDropdown, $flyoutList; + + /** + * Create a new NodeConnector for terminal communication + */ + function _initNodeConnector() { + nodeConnector = NodeConnector.createNodeConnector("phoenix_terminal", exports); + } + + /** + * Create the bottom panel + */ + function _createPanel() { + const templateVars = { + Strings: { + CMD_NEW_TERMINAL: "New Terminal", + TERMINAL_CLEAR: "Clear", + TERMINAL_KILL: "Kill", + CMD_HIDE_TERMINAL: "Close Panel" + } + }; + + $panel = $(Mustache.render(panelHTML, templateVars)); + panel = WorkspaceManager.createBottomPanel(PANEL_ID, $panel, PANEL_MIN_SIZE); + + // Cache DOM references + $contentArea = $panel.find(".terminal-content-area"); + $shellDropdown = $panel.find(".terminal-shell-dropdown"); + $flyoutList = $panel.find(".terminal-flyout-list"); + + // "+" button creates a new terminal with the default shell + $panel.find(".terminal-flyout-new-btn").on("click", function (e) { + e.stopPropagation(); + _createNewTerminal(); + }); + + // Dropdown chevron button toggles shell selector + $panel.find(".terminal-flyout-dropdown-btn").on("click", _onDropdownButtonClick); + + // Refresh process info when user hovers over the flyout + $panel.find(".terminal-tab-flyout").on("mouseenter", _refreshAllProcesses); + + // Listen for panel resize + WorkspaceManager.on("workspaceUpdateLayout", _handleResize); + + // Listen for theme changes via MutationObserver on body class + const observer = new MutationObserver(function () { + _updateAllThemes(); + }); + observer.observe(document.body, {attributes: true, attributeFilter: ["class"]}); + } + + /** + * Populate the shell dropdown menu with checkmark on current default + */ + function _populateShellDropdown() { + const shells = ShellProfiles.getShells(); + const defaultShell = ShellProfiles.getDefaultShell(); + $shellDropdown.empty(); + for (const shell of shells) { + const isSelected = defaultShell && defaultShell.name === shell.name; + const $check = $(''); + if (isSelected) { + $check.append(''); + } + const $item = $('
') + .attr("data-shell-name", shell.name) + .append($check) + .append($('').text(shell.name)); + $item.on("click", function () { + _hideShellDropdown(); + ShellProfiles.setDefaultShell(shell.name); + _populateShellDropdown(); + _updateNewTerminalButtonLabel(); + _createNewTerminalWithShell(shell); + }); + $shellDropdown.append($item); + } + } + + /** + * Update the "+ New Terminal" button label. + * Shows "Terminal" when using the system default, or the shell name when user switched. + */ + function _updateNewTerminalButtonLabel() { + const defaultShell = ShellProfiles.getDefaultShell(); + const isOriginal = defaultShell && defaultShell.name === originalDefaultShellName; + const label = !defaultShell || isOriginal ? "Terminal" : defaultShell.name; + $panel.find(".terminal-btn-label").text(label); + $panel.find(".terminal-flyout-new-btn").attr("title", label); + } + + /** + * Show/hide the shell dropdown + */ + function _showShellDropdown() { + $shellDropdown.removeClass("forced-hidden"); + // Close on outside click + setTimeout(function () { + $(document).one("click", _hideShellDropdown); + }, 0); + } + + function _hideShellDropdown() { + $shellDropdown.addClass("forced-hidden"); + } + + /** + * Handle dropdown chevron button click: toggle shell selector + */ + function _onDropdownButtonClick(e) { + e.stopPropagation(); + if ($shellDropdown.hasClass("forced-hidden")) { + _populateShellDropdown(); + _showShellDropdown(); + } else { + _hideShellDropdown(); + } + } + + /** + * Create a new terminal with the default shell + */ + async function _createNewTerminal() { + const shell = ShellProfiles.getDefaultShell(); + return _createNewTerminalWithShell(shell); + } + + /** + * Create a new terminal with a specific shell profile + */ + async function _createNewTerminalWithShell(shell) { + if (!shell) { + console.error("Terminal: No shell available"); + return; + } + + // Get project root as cwd, converting VFS path to native platform path + const projectRoot = ProjectManager.getProjectRoot(); + let cwd; + if (projectRoot) { + const fullPath = projectRoot.fullPath; + const tauriPrefix = Phoenix.VFS.getTauriDir(); + if (fullPath.startsWith(tauriPrefix)) { + cwd = Phoenix.fs.getTauriPlatformPath(fullPath); + } else { + cwd = fullPath; + } + // Remove trailing slash/backslash (posix_spawnp can fail with trailing slashes) + if (cwd.length > 1 && (cwd.endsWith("/") || cwd.endsWith("\\"))) { + cwd = cwd.slice(0, -1); + } + } + + // Create instance + const instance = new TerminalInstance(nodeConnector, shell, cwd); + + // Set up callbacks + instance.onTitleChanged = _onTerminalTitleChanged; + instance.onProcessExit = _onTerminalProcessExit; + + // Create xterm UI + instance.create($contentArea); + + // Add to list + terminalInstances.push(instance); + + // Activate this terminal (also updates flyout) + _activateTerminal(instance.id); + + // Show panel if hidden + if (!panel.isVisible()) { + panel.show(); + _updateToolbarIcon(true); + } + + // Spawn PTY process + await instance.spawn(); + } + + /** + * Activate a terminal tab (show it, hide others) + */ + function _activateTerminal(id) { + activeTerminalId = id; + + // Show/hide terminal containers + for (const inst of terminalInstances) { + if (inst.id === id) { + inst.show(); + } else { + inst.hide(); + } + } + + _updateFlyout(); + } + + /** + * Close a terminal instance, confirming first if a child process is running + */ + async function _closeTerminal(id) { + const idx = terminalInstances.findIndex(t => t.id === id); + if (idx === -1) { + return; + } + + const instance = terminalInstances[idx]; + + // Check for active child process before closing + if (instance.isAlive) { + try { + const result = await nodeConnector.execPeer("getTerminalProcess", {id}); + const processName = result.process || ""; + if (processName && !_isShellProcess(processName)) { + const message = StringUtils.format( + Strings.TERMINAL_CLOSE_CONFIRM_MSG, _escapeHtml(processName) + ); + const dialog = Dialogs.showConfirmDialog( + Strings.TERMINAL_CLOSE_CONFIRM_TITLE, message + ); + const buttonId = await dialog.getPromise(); + if (buttonId !== Dialogs.DIALOG_BTN_OK) { + return; + } + } + } catch (e) { + // Terminal may already be dead; proceed with close + } + } + + instance.dispose(); + terminalInstances.splice(idx, 1); + delete processInfo[id]; + + // If we closed the active terminal, activate another + if (activeTerminalId === id) { + if (terminalInstances.length > 0) { + const newActive = terminalInstances[Math.min(idx, terminalInstances.length - 1)]; + _activateTerminal(newActive.id); + } else { + activeTerminalId = null; + } + } + + // If no terminals left, hide the panel + if (terminalInstances.length === 0) { + panel.hide(); + _updateToolbarIcon(false); + } + + _updateFlyout(); + } + + /** + * Get the active terminal instance + */ + function _getActiveTerminal() { + return terminalInstances.find(t => t.id === activeTerminalId) || null; + } + + /** + * Clear the active terminal + */ + function _clearActiveTerminal() { + const active = _getActiveTerminal(); + if (active) { + active.clear(); + } + } + + /** + * Kill the active terminal's process + */ + function _killActiveTerminal() { + const active = _getActiveTerminal(); + if (active && active.isAlive) { + nodeConnector.execPeer("killTerminal", {id: active.id}).catch((err) => { + console.error("Terminal: kill error:", err); + }); + } + } + + /** + * Handle terminal title change — also fetches and displays the foreground process + */ + function _onTerminalTitleChanged(id, title) { + _updateFlyout(); + _updateTabProcess(id); + } + + /** + * Fetch and display the foreground process for a terminal tab + */ + function _updateTabProcess(id) { + const instance = terminalInstances.find(t => t.id === id); + if (!instance || !instance.isAlive) { + return; + } + nodeConnector.execPeer("getTerminalProcess", {id}).then(function (result) { + const newProc = result.process || ""; + if (processInfo[id] !== newProc) { + processInfo[id] = newProc; + _updateFlyout(); + } + }).catch(function () { + // Terminal may have been closed; ignore + }); + } + + /** + * Refresh process info for all alive terminals. + * Called on flyout hover so the tab bar is up-to-date when the user looks. + */ + function _refreshAllProcesses() { + for (const inst of terminalInstances) { + if (inst.isAlive) { + _updateTabProcess(inst.id); + } + } + } + + /** + * Rebuild the flyout panel to reflect current tabs + */ + /** + * Extract the last directory name from a terminal title. + * Title format is typically "user@host: /path/to/dir" or "user@host: ~/path/to/dir". + */ + function _extractCwdBasename(title) { + const colonIdx = title.indexOf(": "); + const pathPart = colonIdx >= 0 ? title.slice(colonIdx + 2) : title; + const trimmed = pathPart.replace(/\/+$/, ""); + const lastSlash = trimmed.lastIndexOf("/"); + return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed; + } + + function _updateFlyout() { + $flyoutList.empty(); + for (const inst of terminalInstances) { + const proc = processInfo[inst.id] || ""; + const basename = proc ? proc.split("/").pop().split("\\").pop() : ""; + + // Label: process basename; right side: cwd basename; tooltip: full title + const label = basename || "Terminal"; + const cwdName = _extractCwdBasename(inst.title); + + const $item = $('
') + .attr("data-terminal-id", inst.id) + .attr("title", inst.title) + .toggleClass("active", inst.id === activeTerminalId); + + if (!inst.isAlive) { + $item.css("opacity", "0.6"); + } + + $item.append(''); + $item.append(''); + $item.append($('').text(label)); + if (cwdName) { + $item.append($('').text(cwdName)); + } + + $item.on("click", function (e) { + if (!$(e.target).closest(".terminal-flyout-close").length) { + _activateTerminal(inst.id); + } + }); + $item.find(".terminal-flyout-close").on("click", function (e) { + e.stopPropagation(); + _closeTerminal(inst.id); + }); + + $flyoutList.append($item); + } + } + + /** + * Handle terminal process exit + */ + function _onTerminalProcessExit(id, exitCode) { + delete processInfo[id]; + _updateFlyout(); + } + + /** + * Toggle the terminal panel visibility + */ + function _togglePanel() { + if (panel.isVisible()) { + panel.hide(); + _updateToolbarIcon(false); + } else { + if (terminalInstances.length === 0) { + _createNewTerminal(); + } else { + panel.show(); + _updateToolbarIcon(true); + const active = _getActiveTerminal(); + if (active) { + active.handleResize(); + active.focus(); + } + } + } + } + + /** + * Handle workspace resize + */ + function _handleResize() { + const active = _getActiveTerminal(); + if (active) { + active.handleResize(); + } + } + + /** + * Update all terminal themes (after editor theme change) + */ + function _updateAllThemes() { + for (const inst of terminalInstances) { + inst.updateTheme(); + } + } + + /** + * Update toolbar icon active state + */ + function _updateToolbarIcon(isActive) { + const $icon = $("#toolbar-terminal"); + if (isActive) { + $icon.addClass("selected-button"); + } else { + $icon.removeClass("selected-button"); + } + } + + /** + * Escape HTML special characters + */ + function _escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } + + /** + * Clean up all terminals (on app quit) + */ + function _disposeAll() { + for (const inst of terminalInstances) { + inst.dispose(); + } + terminalInstances = []; + processInfo = {}; + } + + // Register commands + CommandManager.register("New Terminal", CMD_NEW_TERMINAL, _createNewTerminal); + CommandManager.register("Toggle Terminal", CMD_TOGGLE_TERMINAL, _togglePanel); + + // Add menu item + const fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU); + if (fileMenu) { + fileMenu.addMenuItem(CMD_NEW_TERMINAL, null, Menus.AFTER, "file.close"); + } + + // Initialize on app ready + AppInit.appReady(function () { + if (Phoenix.isSpecRunnerWindow) { + return; + } + + _initNodeConnector(); + _createPanel(); + + // Set up toolbar icon click handler + const $toolbarIcon = $("#toolbar-terminal"); + $toolbarIcon.html(''); + $toolbarIcon.removeClass("forced-hidden"); + $toolbarIcon.on("click", _togglePanel); + + // Detect shells + ShellProfiles.init(nodeConnector).then(function () { + const shells = ShellProfiles.getShells(); + const systemDefault = ShellProfiles.getDefaultShell(); + originalDefaultShellName = systemDefault ? systemDefault.name : null; + if (shells.length <= 1) { + $panel.find(".terminal-flyout-dropdown-btn").addClass("forced-hidden"); + } + _populateShellDropdown(); + _updateNewTerminalButtonLabel(); + }); + + // Clean up on window unload + window.addEventListener("beforeunload", _disposeAll); + }); + + // Export for testing + exports.CMD_TOGGLE_TERMINAL = CMD_TOGGLE_TERMINAL; + exports.CMD_NEW_TERMINAL = CMD_NEW_TERMINAL; +}); diff --git a/src/extensionsIntegrated/Terminal/terminal-panel.html b/src/extensionsIntegrated/Terminal/terminal-panel.html new file mode 100644 index 000000000..b6a63fb50 --- /dev/null +++ b/src/extensionsIntegrated/Terminal/terminal-panel.html @@ -0,0 +1,22 @@ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
diff --git a/src/extensionsIntegrated/loader.js b/src/extensionsIntegrated/loader.js index c98a86242..751353f9d 100644 --- a/src/extensionsIntegrated/loader.js +++ b/src/extensionsIntegrated/loader.js @@ -45,5 +45,6 @@ define(function (require, exports, module) { require("./TabBar/main"); require("./CustomSnippets/main"); require("./CollapseFolders/main"); + require("./Terminal/main"); require("./pro-loader"); }); diff --git a/src/index.html b/src/index.html index 5300d7030..700df9f3c 100644 --- a/src/index.html +++ b/src/index.html @@ -995,6 +995,7 @@
+
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 68a7d5db1..5ff0846eb 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1484,6 +1484,8 @@ define({ "ERROR_NOTHING_SELECTED": "Nothing is selected!", "ERROR_SAVE_FIRST": "Save the document first!", "ERROR_TERMINAL_NOT_FOUND": "Terminal was not found for your OS, you can define a custom Terminal command in the settings", + "TERMINAL_CLOSE_CONFIRM_TITLE": "Active Process Running", + "TERMINAL_CLOSE_CONFIRM_MSG": "Terminal has an active process running: {0}.
Are you sure you want to close it?", "EXTENDED_COMMIT_MESSAGE": "EXTENDED", "GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026", "GIT_COMMIT": "Git commit\u2026", diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less new file mode 100644 index 000000000..68512dcdb --- /dev/null +++ b/src/styles/Extn-Terminal.less @@ -0,0 +1,418 @@ +/* + * 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. + * + */ + +/* Terminal Extension Styles */ + +/* Dark theme (default) */ +.dark .terminal-panel-container, +.darkTheme .terminal-panel-container { + --terminal-background: #1e1e1e; + --terminal-foreground: #cccccc; + --terminal-cursor: #ffffff; + --terminal-selection: rgba(255, 255, 255, 0.2); + --terminal-border: #333333; + --terminal-toolbar-bg: #1e1e1e; + --terminal-tab-bg: #181818; + --terminal-tab-text: #999999; + --terminal-tab-active-bg: rgba(255, 255, 255, 0.05); + --terminal-tab-active-text: #cccccc; + --terminal-ansi-black: #000000; + --terminal-ansi-red: #cd3131; + --terminal-ansi-green: #0dbc79; + --terminal-ansi-yellow: #e5e510; + --terminal-ansi-blue: #2472c8; + --terminal-ansi-magenta: #bc3fbc; + --terminal-ansi-cyan: #11a8cd; + --terminal-ansi-white: #e5e5e5; + --terminal-ansi-bright-black: #666666; + --terminal-ansi-bright-red: #f14c4c; + --terminal-ansi-bright-green: #23d18b; + --terminal-ansi-bright-yellow: #f5f543; + --terminal-ansi-bright-blue: #3b8eea; + --terminal-ansi-bright-magenta: #d670d6; + --terminal-ansi-bright-cyan: #29b8db; + --terminal-ansi-bright-white: #ffffff; +} + +/* Light theme */ +.terminal-panel-container { + --terminal-background: #ffffff; + --terminal-foreground: #383a42; + --terminal-cursor: #383a42; + --terminal-selection: rgba(0, 0, 0, 0.12); + --terminal-border: #e0e0e0; + --terminal-toolbar-bg: #f5f5f5; + --terminal-tab-bg: #eeeeee; + --terminal-tab-text: #666666; + --terminal-tab-active-bg: rgba(0, 0, 0, 0.05); + --terminal-tab-active-text: #333333; + --terminal-ansi-black: #383a42; + --terminal-ansi-red: #e45649; + --terminal-ansi-green: #50a14f; + --terminal-ansi-yellow: #c18401; + --terminal-ansi-blue: #4078f2; + --terminal-ansi-magenta: #a626a4; + --terminal-ansi-cyan: #0184bc; + --terminal-ansi-white: #a0a1a7; + --terminal-ansi-bright-black: #4f525e; + --terminal-ansi-bright-red: #e06c75; + --terminal-ansi-bright-green: #98c379; + --terminal-ansi-bright-yellow: #d19a66; + --terminal-ansi-bright-blue: #61afef; + --terminal-ansi-bright-magenta: #c678dd; + --terminal-ansi-bright-cyan: #56b6c2; + --terminal-ansi-bright-white: #ffffff; +} + +.terminal-panel-container { + display: flex; + flex-direction: column; + height: 100%; + background: var(--terminal-toolbar-bg); +} + +/* Body: content + tab bar spacer */ +.terminal-body { + display: flex; + flex: 1; + min-height: 0; + position: relative; +} + +/* Tab bar: 30px flex spacer, flyout sits inside absolutely */ +.terminal-tab-bar { + position: relative; + width: 30px; + min-width: 30px; + overflow: visible; +} + +/* ─── Unified flyout: collapses to 30px, expands on hover ─── */ +.terminal-tab-flyout { + display: flex; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 30px; + background: var(--terminal-tab-bg); + border-left: 1px solid var(--terminal-border); + z-index: 20; + flex-direction: column; + transition: width 0.08s ease; +} + +.terminal-tab-flyout:hover { + width: 170px; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3); +} + +/* Flyout list: scrollable, scrollbar hidden in collapsed mode */ +.terminal-tab-flyout .terminal-flyout-list { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +.terminal-tab-flyout:hover .terminal-flyout-list { + scrollbar-width: auto; + &::-webkit-scrollbar { + display: block; + } +} + +/* Flyout item */ +.terminal-flyout-item { + position: relative; + display: flex; + align-items: center; + height: 28px; + min-height: 28px; + cursor: pointer; + color: var(--terminal-tab-text); + font-size: 11px; + white-space: nowrap; + overflow: hidden; +} + +.terminal-flyout-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.terminal-flyout-item.active { + background: var(--terminal-tab-active-bg); + color: var(--terminal-tab-active-text); + box-shadow: inset 2px 0 0 #007acc; +} + +/* Icon column: fixed 30px slot, always visible in collapsed mode. + In expanded mode, CSS order moves it to the right end. */ +.terminal-flyout-icon { + width: 30px; + min-width: 30px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + flex-shrink: 0; +} + +/* Title: fills remaining space */ +.terminal-flyout-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +/* CWD basename shown on the right */ +.terminal-flyout-cwd { + font-size: 11px; + color: var(--terminal-tab-text); + flex-shrink: 1; + min-width: 0; + max-width: 50%; + margin-left: 4px; + margin-right: 2px; + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Expanded mode: reorder so icon is at the right end, + add left padding for close button area */ +.terminal-tab-flyout:hover .terminal-flyout-item { + padding-left: 30px; +} + +.terminal-tab-flyout:hover .terminal-flyout-icon { + order: 99; +} + +/* Close button: overlays left padding area, visible only when + flyout is expanded AND the specific item is hovered */ +.terminal-flyout-close { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 30px; + display: none; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--terminal-tab-text); + background: transparent; + z-index: 1; +} + +.terminal-tab-flyout:hover .terminal-flyout-item:hover .terminal-flyout-close { + display: flex; +} + +.terminal-flyout-close:hover { + color: var(--terminal-tab-active-text); + background: rgba(255, 255, 255, 0.1); +} + +/* ─── Flyout bottom actions ─── */ +.terminal-flyout-actions { + border-top: 1px solid var(--terminal-border); + position: relative; +} + +/* New terminal row: + button fills, dropdown chevron on the right */ +.terminal-flyout-new-row { + display: flex; + align-items: center; + height: 28px; +} + +.terminal-flyout-new-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex: 1; + height: 100%; + padding: 0; + cursor: pointer; + color: var(--terminal-tab-text); + font-size: 11px; + background: transparent; + border: none; + overflow: hidden; + white-space: nowrap; +} + +.terminal-flyout-new-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--terminal-tab-active-text); +} + +/* Dropdown chevron: hidden in collapsed mode, shown inline when expanded */ +.terminal-flyout-dropdown-btn { + display: none; + align-items: center; + justify-content: center; + width: 30px; + height: 100%; + padding: 0; + cursor: pointer; + color: var(--terminal-tab-text); + font-size: 9px; + background: transparent; + border: none; + border-left: 1px solid var(--terminal-border); + flex-shrink: 0; +} + +.terminal-tab-flyout:hover .terminal-flyout-dropdown-btn { + display: flex; +} + +.terminal-flyout-dropdown-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--terminal-tab-active-text); +} + +/* Icon in action buttons */ +.terminal-flyout-btn-icon { + font-size: 12px; + flex-shrink: 0; +} + +/* Hide button labels in collapsed mode; show when expanded */ +.terminal-tab-flyout .terminal-btn-label { + display: none; +} + +.terminal-tab-flyout:hover .terminal-btn-label { + display: inline; +} + +/* ─── Shell dropdown (pops above the actions row) ─── */ +.terminal-shell-dropdown { + position: absolute; + bottom: 100%; + right: 0; + min-width: 180px; + background: var(--terminal-tab-bg); + border: 1px solid var(--terminal-border); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + z-index: 100; + padding: 4px 0; +} + +.terminal-shell-dropdown .shell-option { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + color: var(--terminal-tab-text); + font-size: 12px; + white-space: nowrap; +} + +.terminal-shell-dropdown .shell-option:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--terminal-tab-active-text); +} + +.terminal-shell-dropdown .shell-option .shell-default-badge { + font-size: 10px; + opacity: 0.5; + margin-left: auto; +} + +.terminal-shell-dropdown .shell-option .shell-check { + width: 14px; + font-size: 11px; + text-align: center; + flex-shrink: 0; +} + +/* ─── Terminal Content Area ─── */ +.terminal-content-area { + flex: 1; + position: relative; + background: var(--terminal-background); + min-width: 0; +} + +.terminal-instance-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: none; + box-sizing: border-box; + background: var(--terminal-background); +} + +.terminal-instance-container.active { + display: block; +} + +/* xterm.js overrides */ +.terminal-instance-container .xterm { + height: 100%; + padding-left: 4px; +} + +.terminal-instance-container .xterm-viewport { + overflow-y: auto; + background-color: var(--terminal-background) !important; +} + +/* ─── Toolbar icon in right sidebar ─── */ +#toolbar-terminal { + display: flex !important; + align-items: center; + justify-content: center; +} + +#toolbar-terminal > i { + font-size: 14px; + line-height: 24px; + color: #bbb; +} + +#toolbar-terminal:hover > i { + color: #fff; +} + +/* Empty state */ +.terminal-empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--terminal-tab-text); + font-size: 13px; +} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 72fbe7031..358093536 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -49,6 +49,7 @@ @import "Extn-SidebarTabs.less"; @import "Extn-BottomPanelTabs.less"; @import "Extn-AIChatPanel.less"; +@import "Extn-Terminal.less"; @import "UserProfile.less"; @import "phoenix-pro.less"; diff --git a/src/thirdparty/licences/xterm.markdown b/src/thirdparty/licences/xterm.markdown new file mode 100644 index 000000000..4472336c9 --- /dev/null +++ b/src/thirdparty/licences/xterm.markdown @@ -0,0 +1,21 @@ +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) +Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) +Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 353fc5b10..f6e6edda5 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -66,6 +66,13 @@ define(function (require, exports, module) { icon: "fa-solid fa-keyboard", label: Strings.KEYBOARD_SHORTCUT_PANEL_TITLE || "Keyboard Shortcuts", commandID: Commands.HELP_TOGGLE_SHORTCUTS_PANEL + }, + { + id: "terminal", + icon: "fa-solid fa-terminal", + label: "Terminal", + commandID: "terminal.toggle", + nativeOnly: true } ]; @@ -90,6 +97,9 @@ define(function (require, exports, module) { let $buttonsRow = $('
'); _panelButtons.forEach(function (btn) { + if (btn.nativeOnly && !Phoenix.isNativeApp) { + return; + } let $button = $('') .attr("data-command", btn.commandID) .attr("data-btn-id", btn.id)