diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index ea7bae6480..fd6249a6e1 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -329,9 +329,11 @@ define(function (require, exports, module) { * @param {Object} shell Shell profile to use. * @param {string} [cwdOverride] Directory to use instead of the project root. * @param {string} [projectPath] Owning project when restarting during a project switch. + * @param {Object} [processRun] Set when `shell` is a single process run without a shell + * (see _runProcessInNewTerminal): its env and the exit report the caller asked for. * @return {Promise} The new terminal, if a shell is available. */ - async function _createNewTerminalWithShell(shell, cwdOverride, projectPath) { + async function _createNewTerminalWithShell(shell, cwdOverride, projectPath, processRun) { if (!shell) { console.error("Terminal: No shell available"); Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", "noShell"); @@ -339,7 +341,7 @@ define(function (require, exports, module) { } // Metric: a new terminal session was created, keyed by shell family. Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", - _shellMetricLabel(shell.name)); + processRun ? "process" : _shellMetricLabel(shell.name)); // Get cwd: use override if provided, otherwise fall back to project root const projectRoot = ProjectManager.getProjectRoot(); @@ -356,6 +358,7 @@ define(function (require, exports, module) { const instance = new TerminalInstance(nodeConnector, shell, cwd); // Project ownership is independent of directories the user visits in the shell. instance.projectPath = projectPath || (projectRoot ? projectRoot.fullPath : null); + instance.processRun = processRun || null; // Set up callbacks instance.onTitleChanged = _onTerminalTitleChanged; @@ -393,10 +396,59 @@ define(function (require, exports, module) { try { instance.fitAddon.fit(); } catch (e) { /* not ready */ } // Spawn PTY process - await instance.spawn(); + const spawned = await instance.spawn(processRun ? processRun.env : undefined); + if (processRun) { + processRun.started = spawned; + if (!spawned) { + _reportProcessExit(processRun, null); + } + } return instance; } + /** + * Tell whoever started a process tab how it ended, once. + * @param {?Object} processRun The tab's run, or null for a shell tab. + * @param {?number} exitCode The process exit code, or null when it never ran or its tab was closed first. + */ + function _reportProcessExit(processRun, exitCode) { + if (!processRun || processRun.reported) { + return; + } + processRun.reported = true; + if (typeof processRun.onProcessExit === "function") { + try { + processRun.onProcessExit(exitCode); + } catch (err) { + console.error("Terminal: onProcessExit handler failed:", err); + } + } + } + + /** + * Run one executable as a new tab's own process, with no shell around it, so the + * caller learns how it ended. The tab stays open after the exit to show its output. + * @param {{path: string, args: (Array|undefined), name: (string|undefined), + * env: (Object|undefined)}} spec What to run; env is merged into the inherited environment. + * @param {function(?number)} [onProcessExit] Called once with the exit code, or null when + * the process could not start or its tab was closed before it exited. + * @return {Promise} The new tab, or null when the process did not start. + */ + async function _runProcessInNewTerminal(spec, onProcessExit) { + const processRun = {env: spec.env, onProcessExit: onProcessExit, started: false, reported: false}; + if (!spec.path || _restartingTerminals) { + _reportProcessExit(processRun, null); + return null; + } + const profile = { + name: spec.name || spec.path.split(/[\\/]/).pop(), + path: spec.path, + args: spec.args || [] + }; + const instance = await _createNewTerminalWithShell(profile, undefined, undefined, processRun); + return instance && processRun.started ? {terminalId: instance.id} : null; + } + /** Remove the project-switch notice without changing any terminal session. */ function _hideProjectBanner() { if ($contentArea) { @@ -491,12 +543,14 @@ define(function (require, exports, module) { // A restart replaces every instance, and the new ones are issued // fresh ids, so a name looked up by id would not find them. Carry // the names across with the shells: the tabs are the same tabs to - // the user, only pointed at the new project. - const carried = terminalInstances.map(inst => ({ + // the user, only pointed at the new project. A tab running a single + // process (no shell) ends with the restart rather than running again. + const shellTabs = terminalInstances.filter(inst => !inst.processRun); + const carried = shellTabs.map(inst => ({ profile: inst.shellProfile, customName: inst.customName })); - const activeIndex = terminalInstances.findIndex(inst => inst.id === activeTerminalId); + const activeIndex = shellTabs.findIndex(inst => inst.id === activeTerminalId); await _disposeAllAsync(); activeTerminalId = null; _updateFlyout(); @@ -511,6 +565,10 @@ define(function (require, exports, module) { if (replacements[activeIndex]) { _activateTerminal(replacements[activeIndex].id); } + if (!replacements.length) { + _hideProjectBanner(); + panel.hide(); + } // Keep a notice for a newer project selected during the restart. if (_projectPath !== path) { _showProjectBanner(); @@ -579,6 +637,8 @@ define(function (require, exports, module) { } } + // Disposing stops exit events, so a process still running reports as closed. + _reportProcessExit(instance.processRun, null); instance.dispose(); terminalInstances.splice(idx, 1); delete processInfo[id]; @@ -748,7 +808,9 @@ define(function (require, exports, module) { * for it to come back. */ function _saveTabNames() { + // A process tab would come back as a plain shell, so its name is not kept. StateManager.set(STATE_TAB_NAMES, terminalInstances + .filter(function (inst) { return !inst.processRun; }) .map(function (inst) { return inst.customName; }) .filter(Boolean)); } @@ -892,6 +954,10 @@ define(function (require, exports, module) { */ function _onTerminalProcessExit(id, exitCode) { delete processInfo[id]; + const instance = terminalInstances.find(inst => inst.id === id); + if (instance) { + _reportProcessExit(instance.processRun, exitCode); + } _updateFlyout(); // Metric: terminal process exited on its own (e.g. user typed // "exit"). Distinct from "user" close above, which records the @@ -901,16 +967,6 @@ define(function (require, exports, module) { exitCode === 0 ? "ok" : "err"); } - /** - * Show the terminal panel. Creates a new terminal if none exist. - * If the panel is visible and the active terminal is focused and there - * are 2+ terminals, cycles to the next one. Otherwise just shows and - * focuses the active terminal. - * - * @param {Object} [options] - Optional settings - * @param {string} [options.shellCommand] - A shell command to execute in a new terminal. - * When provided, always creates a fresh terminal and types the command into it. - */ /** * Open the tabs the user named last time, all of them, in the order they * were in. @@ -944,7 +1000,28 @@ define(function (require, exports, module) { } } + /** + * Show the terminal panel. Creates a new terminal if none exist. + * If the panel is visible and the active terminal is focused and there + * are 2+ terminals, cycles to the next one. Otherwise just shows and + * focuses the active terminal. + * + * @param {Object} [options] - Optional settings + * @param {string} [options.shellCommand] - A shell command to execute in a new terminal. + * When provided, always creates a fresh terminal and types the command into it. + * @param {{path: string, args: (Array|undefined), name: (string|undefined), + * env: (Object|undefined)}} [options.process] - Run this executable as a new tab's own + * process instead of a shell (takes precedence over shellCommand). The tab is not saved + * or restored, and a project restart closes it instead of running it again. + * @param {function(?number)} [options.onProcessExit] - With `process`: called once with + * its exit code, or null if it could not start or its tab was closed before it exited. + * @return {Promise} With `process`: the new tab, or null if the + * process did not start. Otherwise undefined. + */ async function _showTerminal(options) { + if (options && options.process) { + return _runProcessInNewTerminal(options.process, options.onProcessExit); + } if (options && options.shellCommand) { await _createNewTerminal(); const active = _getActiveTerminal(); @@ -1120,6 +1197,7 @@ define(function (require, exports, module) { */ function _disposeAll() { for (const inst of terminalInstances) { + _reportProcessExit(inst.processRun, null); inst.dispose(); } terminalInstances = []; @@ -1133,6 +1211,10 @@ define(function (require, exports, module) { * and acknowledged by the Node side. */ async function _disposeAllAsync() { + // Report first, so a killed process can never be read as having finished on its own. + for (const inst of terminalInstances) { + _reportProcessExit(inst.processRun, null); + } const killPromises = terminalInstances .filter(function (inst) { return inst.isAlive && !inst._disposed; }) .map(function (inst) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 246f6cbb32..bdeaef1344 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2549,8 +2549,8 @@ define({ "PROMO_PRO_WHATS_NEW_MESSAGE": "Thanks for supporting {0}. Here’s what’s new in this update:", "PROMO_CARD_LIVE_PREVIEW": "Edit and style in Live Preview", "PROMO_CARD_LIVE_PREVIEW_MESSAGE": "Change text, colors and fonts, rearrange elements, and try different screen sizes. Your code updates as you go.", - "PROMO_CARD_AI": "Build and refine with AI", - "PROMO_CARD_AI_MESSAGE": "Turn an idea into a page, then select an element or a piece of code and ask AI to make changes.", + "PROMO_CARD_AI": "Your AI Agents, Now Visual in Phoenix Pro", + "PROMO_CARD_AI_MESSAGE": "Bring your existing Claude Code or ChatGPT subscription into Phoenix Code, and unlock a whole new way to build and refine websites visually.", "PROMO_CARD_LAYERS": "Shape your page with Layers", "PROMO_CARD_LAYERS_MESSAGE": "Explore your page structure, insert elements, and edit classes and styles. See every change in Live Preview.", "PROMO_CARD_MARKDOWN": "Write Markdown like a document", @@ -3041,6 +3041,7 @@ define({ "AI_CHAT_AUTH_ERROR_NOTICE": "Claude Code is signed out or your login has expired.", "AI_CHAT_AUTH_ERROR_BTN": "Sign in to Claude", "AI_CHAT_AUTH_ERROR_HINT": "Finish signing in on the page that opens in your browser, then send your message again.", + "AI_CHAT_AUTH_LOGIN_FAILED": "Could not start Claude sign-in. Check your Claude Code installation and try again.", "AI_CHAT_MODEL_DEFAULT": "Default", "AI_CHAT_MODEL_DEFAULT_DESC_CURRENT": "Currently {0}", "AI_CHAT_MODEL_DEFAULT_DESC": "Recommended — uses your Claude Code model setting", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index c8a1501056..b0385486ed 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -1293,6 +1293,10 @@ } .ai-start-surprise-open { + .ai-start-surprise-dismiss { + border-left: 0; + } + .ai-start-surprise-choice { display: block; animation: ai-start-choice-open 0.22s ease-out; @@ -1450,19 +1454,12 @@ } /* Home over a chat that has started: the chat's card leads back to it, - with the same arrow as the CLIs. The Surprise Me demo would play into - that chat, so it is not offered. */ + with the same arrow as the CLIs. Demos start a separate chat. */ .ai-start-row[data-mode="chat"] .ai-start-go { display: none; } &.ai-start-chat-running { - // The compact layout shows these with more specific rules. - .ai-start-surprise, - .ai-start-footer.ai-start-demo { - display: none !important; - } - .ai-start-row[data-mode="chat"] { .ai-start-check { display: none; diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 1e64b1a1c0..f2f8f39191 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -229,6 +229,28 @@ define(function (require, exports, module) { return nativePath; } + /** + * A VIEW_TERMINAL `process` spec that runs `script` directly through the + * system's own interpreter, so its exit is the tab's exit. + * @param {string} script Command line for sh -c or cmd.exe /c. + * @return {{path: string, args: Array, name: string}} + */ + function scriptProcess(script) { + if (IS_WINDOWS) { + const ShellProfiles = testWindow.brackets.getModule("extensionsIntegrated/Terminal/ShellProfiles"); + const cmd = ShellProfiles.getShells().find(function (shell) { + return /cmd\.exe$/i.test(shell.path); + }); + return {path: cmd.path, args: ["/c", script], name: "Test process"}; + } + return {path: "/bin/sh", args: ["-c", script], name: "Test process"}; + } + + /** A process spec that keeps running until its tab is closed. */ + function longRunningProcess() { + return scriptProcess(IS_WINDOWS ? "ping -n 120 127.0.0.1" : "sleep 120"); + } + // --- Tests --- describe("Panel basics", function () { @@ -1056,6 +1078,44 @@ define(function (require, exports, module) { expect(testWindow.$(".terminal-project-banner").length).toBe(0); }, 30000); + it("closes a process tab instead of running it again when the terminals restart", async function () { + const shellTab = await openReadyTerminal(); + const exits = []; + await __PR.execCommand(__PR.Commands.VIEW_TERMINAL, { + process: longRunningProcess(), + onProcessExit: function (exitCode) { + exits.push(exitCode); + } + }); + const processTab = termModule._getActiveTerminal(); + await awaitsFor(function () { + return processTab.isAlive; + }, "the process to be running", 10000); + + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + const path = getNativeProjectPath(); + testWindow.$(".terminal-project-restart").click(); + // the running process is a reason to ask first + await awaitsFor(function () { + return isDialogOpen() || processTab._disposed; + }, "the restart to ask or proceed", 10000); + if (isDialogOpen()) { + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + } + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return getTerminalCount() === 1 && active && active.isAlive && active.id !== shellTab.id; + }, "only the shell tab to restart", 15000); + + expect(processTab._disposed).toBeTrue(); + expect(exits).toEqual([null]); + const replacement = termModule._getActiveTerminal(); + expect(replacement.processRun).toBeNull(); + expect(replacement.shellProfile).toEqual(shellTab.shellProfile); + expect(replacement.cwd).toBe(path); + }, 30000); + it("confirms active processes and leaves sessions untouched when canceled", async function () { const instance = await openReadyTerminal(); const connectorSpy = reportActiveProcess(); @@ -1399,5 +1459,126 @@ define(function (require, exports, module) { }, "terminal panel to close", 5000); }); }); + + describe("Processes run without a shell", function () { + const STATE_TAB_NAMES = "terminal.tabNames"; + let termModule, StateManager, savedNames; + + beforeAll(function () { + termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + StateManager = testWindow.brackets.getModule("preferences/StateManager"); + savedNames = StateManager.get(STATE_TAB_NAMES); + }); + + afterEach(async function () { + if (isDialogOpen()) { + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(); + } + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + }); + + afterAll(function () { + StateManager.set(STATE_TAB_NAMES, savedNames); + }); + + /** Read everything the tab has printed, including the exit line. */ + function bufferText(instance) { + const buffer = instance.terminal.buffer.active; + let text = ""; + for (let i = 0; i < buffer.length; i++) { + text += buffer.getLine(i).translateToString(); + } + return text; + } + + /** Start a tab running a single process and collect what it reports. */ + async function runProcess(spec) { + const exits = []; + const result = await __PR.execCommand(__PR.Commands.VIEW_TERMINAL, { + process: spec, + onProcessExit: function (exitCode) { + exits.push(exitCode); + } + }); + return {result, exits}; + } + + /** Close a tab, confirming the running-process prompt if one appears. */ + async function closeTab(instance) { + testWindow.$('.terminal-flyout-item[data-terminal-id="' + instance.id + '"] .terminal-flyout-close') + .click(); + await awaitsFor(function () { + return isDialogOpen() || instance._disposed; + }, "the close to ask or finish", 10000); + if (isDialogOpen()) { + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + } + await awaitsFor(function () { + return instance._disposed; + }, "tab to close", 10000); + } + + it("reports a clean exit and keeps the tab with its output", async function () { + const {result, exits} = await runProcess(scriptProcess("exit 0")); + expect(result && result.terminalId).toBeTruthy(); + await awaitsFor(function () { + return exits.length === 1; + }, "the exit to be reported", 10000); + expect(exits).toEqual([0]); + + const instance = termModule._getActiveTerminal(); + expect(instance.id).toBe(result.terminalId); + expect(instance.isAlive).toBeFalse(); + expect(getTerminalCount()).toBe(1); + await awaitsFor(function () { + return bufferText(instance).includes("[Process exited with code 0]"); + }, "the exit line to be shown", 5000); + }); + + it("reports a failing exit code", async function () { + const {exits} = await runProcess(scriptProcess("exit 3")); + await awaitsFor(function () { + return exits.length === 1; + }, "the exit to be reported", 10000); + expect(exits).toEqual([3]); + }); + + it("reports null once when the tab is closed before the process exits", async function () { + const {result, exits} = await runProcess(longRunningProcess()); + const instance = termModule._getActiveTerminal(); + expect(instance.id).toBe(result.terminalId); + await awaitsFor(function () { + return instance.isAlive; + }, "the process to be running", 10000); + + await closeTab(instance); + expect(exits).toEqual([null]); + }); + + it("reports null and returns null when the process cannot start", async function () { + const {result, exits} = await runProcess({path: "", args: []}); + expect(result).toBeNull(); + expect(exits).toEqual([null]); + }); + + it("does not keep a process tab's name for the next session", async function () { + StateManager.set(STATE_TAB_NAMES, []); + await runProcess(longRunningProcess()); + // name it, the way the pencil does + testWindow.$(".terminal-flyout-item.active .terminal-flyout-edit").click(); + const $field = testWindow.$(".terminal-flyout-rename"); + $field.val("login"); + $field.trigger(testWindow.$.Event("keydown", {key: "Enter"})); + await awaitsFor(function () { + return testWindow.$(".terminal-flyout-item.active .terminal-flyout-title").text() === "login"; + }, "tab to take the typed name", 5000); + + // it would come back as a plain shell, so it is not one of the tabs to restore + expect(StateManager.get(STATE_TAB_NAMES)).toEqual([]); + }); + }); }); }); diff --git a/tracking-repos.json b/tracking-repos.json index 6e3878ec2a..3b2cc5726d 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "7490a7fe9ec83db4dd13069d6bec64cb8866562c" + "commitID": "1183806e12e1ff7f3152fb617087a99f84a12006" } }