From b49f21ccbd4b94ae7d765aa67c10a3176dc91cf0 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 17 Sep 2026 13:09:44 +0530 Subject: [PATCH 1/5] fix(terminal): forward shortcuts in AI terminals Phoenix intercepted shortcuts such as Alt+Up in the AI panel because terminal keyboard routing only recognized the bottom terminal panel. Recognize focused terminal instances in either panel so xterm receives their input. Preserve Phoenix's allowed shortcuts, and keep the clear scrollback shortcut and clear-buffer hint specific to the bottom panel. Add bottom-terminal integration tests for Alt+Up forwarding and the Copy/Paste/Clear context menu. The new menu test checks visibility and enabled states without accessing the clipboard, and restores command states during cleanup. Validation: Terminal integration suite 15/15 and AI Chat CLI integration suite 22/22 passed in the connected Linux Electron runner; ESLint passed. Windows and macOS execution was not verified. --- src/extensionsIntegrated/Terminal/main.js | 7 ++- test/spec/Terminal-integ-test.js | 73 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 060136b47f..e95e79b0c6 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -708,22 +708,23 @@ define(function (require, exports, module) { return false; } const el = document.activeElement; - if (!el || !$contentArea[0].contains(el)) { + if (!el || !$(el).closest(".terminal-instance-container").length) { return false; } + const inBottomPanel = $contentArea[0].contains(el); const ctrlOrMeta = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); // Ctrl+K (Cmd+K on mac): clear terminal scrollback - if (ctrlOrMeta && !event.shiftKey && key === "k") { + if (inBottomPanel && ctrlOrMeta && !event.shiftKey && key === "k") { event.preventDefault(); _clearActiveTerminal(); return true; } // Show clear buffer hint on Ctrl+L - if (ctrlOrMeta && !event.shiftKey && key === "l") { + if (inBottomPanel && ctrlOrMeta && !event.shiftKey && key === "l") { _showClearBufferHintToast(); } diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 9ebca685f8..6528d91f9c 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -597,6 +597,42 @@ define(function (require, exports, module) { }); }); + it("should forward Alt+Up to the terminal instead of a Phoenix command", async function () { + const termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + let inputListener; + try { + await openTerminal(); + await waitForShellReady(); + const instance = termModule._getActiveTerminal(); + const KeyBindingManager = testWindow.brackets.getModule("command/KeyBindingManager"); + const binding = KeyBindingManager.getKeymap()["Alt-Up"]; + const command = testWindow.brackets.test.CommandManager.get(binding.commandID); + const execute = spyOn(command, "execute") + .and.returnValue(testWindow.$.Deferred().resolve().promise()); + const input = []; + inputListener = instance.terminal.onData(function (data) { input.push(data); }); + instance.focus(); + await awaitsFor(function () { + return testWindow.document.activeElement === instance.terminal.textarea; + }, "terminal input to have focus", 3000); + + const key = {key: "ArrowUp", code: "ArrowUp", keyCode: 38, which: 38, + altKey: true, bubbles: true, cancelable: true}; + instance.terminal.textarea.dispatchEvent(new testWindow.KeyboardEvent("keydown", key)); + instance.terminal.textarea.dispatchEvent(new testWindow.KeyboardEvent("keyup", key)); + await awaitsFor(function () { + return input.includes("\x1b[1;3A"); + }, "Alt+Up escape sequence to reach the terminal", 3000); + expect(execute).not.toHaveBeenCalled(); + } finally { + if (inputListener) { + inputListener.dispose(); + } + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + } + }); + describe("Context menu commands", function () { let CommandManager; @@ -632,6 +668,43 @@ define(function (require, exports, module) { testWindow.brackets.test.CommandManager; }); + it("should open Copy, Paste and Clear Terminal on right-click", + async function () { + const Menus = testWindow.brackets.test.Menus; + const ctxMenu = Menus.getContextMenu("terminal-context-menu"); + const termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + const commandStates = ["terminal.copy", "terminal.paste", "terminal.clear"].map(function (id) { + const command = CommandManager.get(id); + return {command, enabled: command.getEnabled()}; + }); + try { + await openTerminal(); + await waitForShellReady(); + const active = getActiveTerminal(); + active.terminal.clearSelection(); + active.$container.find(".xterm-screen").trigger(testWindow.$.Event("contextmenu", { + pageX: 100, + pageY: 100 + })); + await awaitsFor(function () { + return testWindow.$("#terminal-context-menu.open > .dropdown-menu").is(":visible"); + }, "terminal context menu to open on right-click", 3000); + + const labels = testWindow.$("#terminal-context-menu .menu-name").map(function () { + return testWindow.$(this).text(); + }).get(); + expect(labels).toEqual([Strings.CMD_COPY, Strings.CMD_PASTE, Strings.TERMINAL_CLEAR]); + expect(CommandManager.get("terminal.copy").getEnabled()).toBeFalse(); + expect(CommandManager.get("terminal.paste").getEnabled()).toBeTrue(); + expect(CommandManager.get("terminal.clear").getEnabled()).toBeTrue(); + } finally { + ctxMenu.close(); + commandStates.forEach(function (state) { state.command.setEnabled(state.enabled); }); + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + } + }); + it("should clear the terminal screen", async function () { await openTerminal(); From 6477cdcbc2d9814eb1d51d3051e9f391ccd3a061 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 17 Sep 2026 13:16:29 +0530 Subject: [PATCH 2/5] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index d2a173a22c..30759735f7 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "ac067fcd943e145b0ba3f92782678f661371b9a1" + "commitID": "4983d20cc5eb34a4cda14bd94c3599a24e56b6d1" } } From 21cd89bcd926d8c01dc2971ac2fafb6a2b9c4941 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 17 Sep 2026 13:59:53 +0530 Subject: [PATCH 3/5] fix(terminal): offer restart after project switches Bottom-panel terminals kept their working directories when the project changed, leaving users unaware they could still run commands elsewhere. Show a project-switch banner with actions to keep existing sessions or restart every bottom-panel terminal in the current project folder. Preserve shell profiles, tab order and selection on restart, confirm active processes, and cancel a pending restart if the project changes during confirmation. Keep the notice across panel hide/show and remove it when all terminals close. Use the existing native path conversion and PTY lifecycle on all desktop platforms, preserving Windows drive roots. Add localized banner text, theme-aware styling and six integration tests covering the banner flows. Validation: all 21 Terminal integration tests passed in the connected Linux Electron runner. ESLint, CSS compilation and diff checks passed; dark/light themes and a narrower layout were visually checked. Windows and macOS native execution was not verified. --- src/extensionsIntegrated/Terminal/main.js | 160 +++++++++++++++-- src/nls/root/strings.js | 6 + src/styles/Extn-Terminal.less | 34 ++++ test/spec/Terminal-integ-test.js | 204 +++++++++++++++++++++- 4 files changed, 384 insertions(+), 20 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index e95e79b0c6..95a9fa79d8 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -93,6 +93,8 @@ define(function (require, exports, module) { let originalDefaultShellName = null; // System-detected default shell name let _focusToastShown = false; // Show focus hint toast only once per session let _clearHintShown = false; // Show clear buffer hint toast only once per session + let _projectPath = null; + let _restartingTerminals = false; let $panel, $contentArea, $shellDropdown, $flyoutList; /** @@ -186,7 +188,7 @@ define(function (require, exports, module) { const shells = ShellProfiles.getShells(); const defaultShell = ShellProfiles.getDefaultShell(); $shellDropdown.empty(); - for (const shell of shells) { + shells.forEach(function (shell) { const isSelected = defaultShell && defaultShell.name === shell.name; const $check = $(''); if (isSelected) { @@ -197,6 +199,9 @@ define(function (require, exports, module) { .append($check) .append($('').text(shell.name)); $item.on("click", function () { + if (_restartingTerminals) { + return; + } _hideShellDropdown(); ShellProfiles.setDefaultShell(shell.name); _populateShellDropdown(); @@ -209,7 +214,7 @@ define(function (require, exports, module) { _createNewTerminalWithShell(shell); }); $shellDropdown.append($item); - } + }); } /** @@ -272,6 +277,9 @@ define(function (require, exports, module) { * Create a new terminal with the default shell */ async function _createNewTerminal(cwdOverride) { + if (_restartingTerminals) { + return; + } const shell = ShellProfiles.getDefaultShell(); return _createNewTerminalWithShell(shell, cwdOverride); } @@ -286,17 +294,12 @@ define(function (require, exports, module) { if (cwd.startsWith(tauriPrefix)) { cwd = Phoenix.fs.getTauriPlatformPath(cwd); } - if (cwd.length > 1 && (cwd.endsWith("/") || cwd.endsWith("\\"))) { + if (cwd.length > 1 && !/^[a-z]:[\\/]$/i.test(cwd) && (cwd.endsWith("/") || cwd.endsWith("\\"))) { cwd = cwd.slice(0, -1); } return cwd; } - /** - * Create a new terminal with a specific shell profile - * @param {Object} shell - Shell profile to use - * @param {string} [cwdOverride] - Optional VFS path to use as cwd instead of project root - */ /** * Map an OS shell name (e.g. "powershell.exe", "bash.exe") to a short * family label so the metrics server's per-event length budget stays @@ -315,6 +318,12 @@ define(function (require, exports, module) { return n || "unknown"; } + /** + * Create a terminal using a shell profile and an optional VFS directory. + * @param {Object} shell Shell profile to use. + * @param {string} [cwdOverride] Directory to use instead of the project root. + * @return {Promise} The new terminal, if a shell is available. + */ async function _createNewTerminalWithShell(shell, cwdOverride) { if (!shell) { console.error("Terminal: No shell available"); @@ -376,6 +385,119 @@ define(function (require, exports, module) { // Spawn PTY process await instance.spawn(); + return instance; + } + + /** Remove the project-switch notice without changing any terminal session. */ + function _hideProjectBanner() { + if ($contentArea) { + $contentArea.find(".terminal-project-banner").remove(); + } + } + + /** Show one notice for all bottom-panel terminals, naming the current project. */ + function _showProjectBanner() { + _hideProjectBanner(); + const root = ProjectManager.getProjectRoot(); + if (!root || !terminalInstances.length) { + return; + } + const $banner = $('
'); + $banner.append($('
') + .text(StringUtils.format(Strings.TERMINAL_PROJECT_CHANGED, root.name))); + const path = _toNativePath(root.fullPath); + $banner.append($('
').text(path).attr("title", path)); + $banner.append($('
').text(Strings.TERMINAL_PROJECT_RESTART_WARNING)); + const $actions = $('
'); + $actions.append($('') + .text(Strings.TERMINAL_PROJECT_KEEP).on("click", function () { + _hideProjectBanner(); + const active = _getActiveTerminal(); + if (active) { + active.focus(); + } + })); + $actions.append($('') + .text(Strings.TERMINAL_PROJECT_RESTART).on("click", _restartTerminalsInProject)); + $actions.find("button").prop("disabled", _restartingTerminals); + $banner.append($actions); + $contentArea.append($banner); + } + + /** Notify on actual project changes; shell navigation and panel toggles leave sessions alone. */ + function _onProjectOpen() { + const root = ProjectManager.getProjectRoot(); + const path = root ? root.fullPath : null; + if (path !== _projectPath) { + _projectPath = path; + _showProjectBanner(); + } + } + + /** + * Query child processes using the same platform-specific lookup as terminal tabs. + * @return {Promise} Active child process names. + */ + async function _getActiveProcesses() { + const results = await Promise.all(terminalInstances.filter(inst => inst.isAlive).map(function (inst) { + return nodeConnector.execPeer("getTerminalProcess", {id: inst.id}) + .catch(function () { return {process: ""}; }); + })); + return results.filter(result => result.process && !_isShellProcess(result.process)) + .map(result => result.process); + } + + /** Restart all bottom-panel tabs in the selected project, preserving shells, order and selection. */ + async function _restartTerminalsInProject() { + const root = ProjectManager.getProjectRoot(); + if (_restartingTerminals || !root || !terminalInstances.length) { + return; + } + const path = root.fullPath; + _restartingTerminals = true; + $contentArea.find(".terminal-project-actions button").prop("disabled", true); + try { + const activeProcesses = await _getActiveProcesses(); + if (activeProcesses.length) { + const dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, + Strings.TERMINAL_RESTART_CONFIRM_TITLE, + Strings.TERMINAL_RESTART_CONFIRM_MSG, [ + {className: Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL}, + {className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, + text: Strings.TERMINAL_PROJECT_RESTART} + ]); + if (await dialog.getPromise() !== Dialogs.DIALOG_BTN_OK) { + return; + } + } + // A project can change while process lookup or confirmation is pending. + const currentRoot = ProjectManager.getProjectRoot(); + if (!currentRoot || currentRoot.fullPath !== path) { + return; + } + const profiles = terminalInstances.map(inst => inst.shellProfile); + const activeIndex = terminalInstances.findIndex(inst => inst.id === activeTerminalId); + await _disposeAllAsync(); + activeTerminalId = null; + _updateFlyout(); + const replacements = []; + for (const profile of profiles) { + replacements.push(await _createNewTerminalWithShell(profile, path)); + } + if (replacements[activeIndex]) { + _activateTerminal(replacements[activeIndex].id); + } + // Keep a notice for a newer project selected during the restart. + if (_projectPath !== path) { + _showProjectBanner(); + } + } catch (err) { + console.error("Terminal: Failed to restart terminals:", err); + _showProjectBanner(); + } finally { + _restartingTerminals = false; + $contentArea.find(".terminal-project-actions button").prop("disabled", false); + } } /** @@ -401,6 +523,9 @@ define(function (require, exports, module) { * Close a terminal instance, confirming first if a child process is running */ async function _closeTerminal(id) { + if (_restartingTerminals) { + return; + } const idx = terminalInstances.findIndex(t => t.id === id); if (idx === -1) { return; @@ -447,6 +572,7 @@ define(function (require, exports, module) { // If no terminals left, hide the panel if (terminalInstances.length === 0) { + _hideProjectBanner(); panel.hide(); } @@ -801,6 +927,7 @@ define(function (require, exports, module) { } terminalInstances = []; processInfo = {}; + _hideProjectBanner(); } /** @@ -923,23 +1050,18 @@ define(function (require, exports, module) { _initNodeConnector(); _createPanel(); _createToolbarButton(); + const root = ProjectManager.getProjectRoot(); + _projectPath = root ? root.fullPath : null; + ProjectManager.on("projectOpen.terminal", _onProjectOpen); // Gate user-initiated panel close (X button): confirm if needed, then // dispose all terminals. Programmatic hide() just collapses the panel // without disposing terminals. panel.registerOnCloseRequestedHandler(async function () { - // Query all terminals in parallel to avoid sequential 2s waits on Windows - const aliveInstances = terminalInstances.filter(inst => inst.isAlive); - const results = await Promise.all(aliveInstances.map(function (inst) { - return nodeConnector.execPeer("getTerminalProcess", {id: inst.id}) - .catch(function () { return {process: ""}; }); - })); - const activeProcesses = []; - for (const result of results) { - if (result.process && !_isShellProcess(result.process)) { - activeProcesses.push(result.process); - } + if (_restartingTerminals) { + return false; } + const activeProcesses = await _getActiveProcesses(); let title, message, confirmText; const count = terminalInstances.length; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index e22cc483c0..2be2f24c67 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2230,6 +2230,12 @@ define({ "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", "TERMINAL_CLEAR": "Clear Terminal", "TERMINAL_CLEAR_BUFFER_HINT": "💡 Press {0} to clear terminal buffer", + "TERMINAL_PROJECT_CHANGED": "Project changed to {0}. Existing terminals have kept their locations.", + "TERMINAL_PROJECT_RESTART_WARNING": "Restarting opens every terminal in this folder, stops running processes and clears terminal output.", + "TERMINAL_PROJECT_KEEP": "Keep Terminals", + "TERMINAL_PROJECT_RESTART": "Restart All in This Project", + "TERMINAL_RESTART_CONFIRM_TITLE": "Restart All Terminals?", + "TERMINAL_RESTART_CONFIRM_MSG": "Terminals have active processes. Restarting will stop them and clear terminal output. Continue?", "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 index ed54b2dd50..83524a34ae 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -365,6 +365,40 @@ overflow: hidden; } +.terminal-project-banner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 5; + box-sizing: border-box; + max-height: 100%; + overflow: auto; + padding: 8px 10px; + background: var(--terminal-toolbar-bg); + color: var(--terminal-foreground); + border-top: 1px solid var(--terminal-border); + border-left: 3px solid var(--terminal-ansi-yellow); + box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.2); + white-space: normal; + + .terminal-project-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 4px 0; + font-weight: 600; + } + + .terminal-project-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + } +} + .terminal-instance-container { position: absolute; top: 0; diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 6528d91f9c..18716e6435 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, afterEach, awaitsFor, spyOn */ +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, spyOn */ define(function (require, exports, module) { @@ -597,6 +597,208 @@ define(function (require, exports, module) { }); }); + describe("Project-switch banner", function () { + let termModule, secondProjectPath; + + beforeAll(async function () { + termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + await SpecRunnerUtils.createTempDirectory(); + secondProjectPath = SpecRunnerUtils.getTempDirectory(); + }); + + beforeEach(async function () { + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterEach(async function () { + if (isDialogOpen()) { + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(); + } + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterAll(async function () { + await SpecRunnerUtils.removeTempDirectory(); + }); + + /** + * Open a real shell and wait for its first prompt output. + * @return {Promise} The ready terminal. + */ + async function openReadyTerminal() { + await openTerminal(); + await waitForShellReady(); + const instance = termModule._getActiveTerminal(); + await instance.firstDataReceived; + return instance; + } + + /** + * Verify the shell's actual directory without depending on its prompt format. + * @param {TerminalInstance} instance The shell to query. + * @param {string} path Expected native directory. + */ + async function expectWorkingDirectory(instance, path) { + const shell = instance.shellProfile.path.split(/[\\/]/).pop().toLowerCase(); + const command = shell === "cmd.exe" ? "cd" + : /^(powershell|pwsh)(\.exe)?$/.test(shell) ? "(Get-Location).Path" : "pwd"; + await instance.firstDataReceived; + await termModule.getNodeConnector().execPeer("writeTerminal", {id: instance.id, data: command + "\r"}); + await awaitsFor(function () { + const buffer = instance.terminal.buffer.active; + let text = ""; + for (let i = 0; i < buffer.length; i++) { + text += buffer.getLine(i).translateToString(); + } + return text.includes(path); + }, "shell to report the new project directory", 10000); + } + + /** + * Report a busy terminal while keeping real PTY creation, input and disposal. + * @return {jasmine.Spy} Connector spy for checking restart calls. + */ + function reportActiveProcess() { + const connector = termModule.getNodeConnector(); + const execPeer = connector.execPeer.bind(connector); + return spyOn(connector, "execPeer").and.callFake(function (method, params) { + if (method === "getTerminalProcess") { + return Promise.resolve({process: "test-running-task"}); + } + return execPeer(method, params); + }); + } + + it("does not show a banner when no terminals exist", async function () { + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + const instance = await openReadyTerminal(); + expect(instance.cwd).toBe(getNativeProjectPath()); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + }, 30000); + + it("keeps sessions and dismisses the notice until the next project switch", async function () { + const instance = await openReadyTerminal(); + await writeToTerminal("cd ..\r"); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + expect(testWindow.$(".terminal-project-path").text()).toBe(getNativeProjectPath()); + testWindow.$(".terminal-project-keep").click(); + + const panel = WorkspaceManager.getPanelForID(PANEL_ID); + panel.hide(); + panel.show(); + testWindow.brackets.test.ProjectManager.trigger("projectOpen"); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + }, 30000); + + it("restarts every tab in the new project and preserves its shell and selection", async function () { + const first = await openReadyTerminal(); + const ShellProfiles = testWindow.brackets.getModule("extensionsIntegrated/Terminal/ShellProfiles"); + // Distinct profiles using an installed shell keep this portable to machines with only one shell. + const profileSpy = spyOn(ShellProfiles, "getDefaultShell").and.returnValue( + Object.assign({}, first.shellProfile, {name: "Secondary test shell"}) + ); + await __PR.execCommand(termModule.CMD_NEW_TERMINAL); + profileSpy.and.callThrough(); + const second = termModule._getActiveTerminal(); + await second.firstDataReceived; + testWindow.$('.terminal-flyout-item[data-terminal-id="' + first.id + '"]').click(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + const path = getNativeProjectPath(); + testWindow.$(".terminal-project-restart").click(); + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return getTerminalCount() === 2 && active && active.isAlive && active.id !== first.id + && testWindow.$(".terminal-flyout-item.active").index() === 0; + }, "both terminals to restart and the first tab to remain selected", 15000); + + expect(first._disposed).toBeTrue(); + expect(second._disposed).toBeTrue(); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + const replacements = testWindow.$(".terminal-flyout-item").map(function () { + return testWindow.$(this).attr("data-terminal-id"); + }).get(); + const originals = [first, second]; + for (let i = 0; i < replacements.length; i++) { + testWindow.$('.terminal-flyout-item[data-terminal-id="' + replacements[i] + '"]').click(); + const instance = termModule._getActiveTerminal(); + expect(instance.id).not.toBe(originals[i].id); + expect(instance.shellProfile).toEqual(originals[i].shellProfile); + expect(instance.cwd).toBe(path); + await expectWorkingDirectory(instance, path); + } + }, 30000); + + it("confirms active processes and leaves sessions untouched when canceled", async function () { + const instance = await openReadyTerminal(); + const connectorSpy = reportActiveProcess(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + expect(getDialogTitle()).toBe(Strings.TERMINAL_RESTART_CONFIRM_TITLE); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(); + await awaitsFor(function () { + return !testWindow.$(".terminal-project-restart").prop("disabled"); + }, "restart action to be available again", 3000); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + expect(connectorSpy.calls.allArgs().some(args => args[0] === "killTerminal")).toBeFalse(); + + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return active && active.id !== instance.id && active.isAlive; + }, "confirmed restart to replace the terminal", 10000); + expect(instance._disposed).toBeTrue(); + expect(termModule._getActiveTerminal().cwd).toBe(getNativeProjectPath()); + }, 30000); + + it("does not restart into a stale project if the project changes during confirmation", async function () { + const instance = await openReadyTerminal(); + const connectorSpy = reportActiveProcess(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + await awaitsFor(function () { + return !testWindow.$(".terminal-project-restart").prop("disabled"); + }, "new project banner to be actionable", 3000); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + expect(testWindow.$(".terminal-project-path").text()).toBe(getNativeProjectPath()); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(connectorSpy.calls.allArgs().some(args => args[0] === "killTerminal")).toBeFalse(); + }, 30000); + + it("retains the notice while hidden and removes it when all terminals close", async function () { + await openReadyTerminal(); + const panel = WorkspaceManager.getPanelForID(PANEL_ID); + panel.hide(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(panel.isVisible()).toBeFalse(); + panel.show(); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await termModule._disposeAll(); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + }, 30000); + }); + it("should forward Alt+Up to the terminal instead of a Phoenix command", async function () { const termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); let inputListener; From 5146b5e7113ba3b909da75bb65093a38d423af67 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 17 Sep 2026 14:08:24 +0530 Subject: [PATCH 4/5] fix(terminal): clear banner when returning to the owning project The project-switch banner reappeared when users returned to the project their terminals belonged to. Store each terminal's owning project separately from its shell directory and hide the banner when all tabs belong to the current project. Keep the notice for mixed-project tabs until the last mismatched tab closes. Keep Terminals only dismisses the banner, preserving both the session and its project association. Restart All associates replacement tabs with the chosen project, including when another project switch occurs while the restart is in progress. Label the destination path as "Restart in:" and move the restart explanation into the button tooltip to shorten the banner. Add coverage for returning to the original project and mixed-project tabs, and update restart and confirmation tests for the new behavior. Validation: all 23 Terminal integration tests passed in the connected Linux Electron runner; ESLint and diff checks passed. Native Windows and macOS execution was not verified. --- src/extensionsIntegrated/Terminal/main.js | 21 +++++++---- src/nls/root/strings.js | 1 + test/spec/Terminal-integ-test.js | 45 ++++++++++++++++++++--- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 95a9fa79d8..c0f608923d 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -322,9 +322,10 @@ define(function (require, exports, module) { * Create a terminal using a shell profile and an optional VFS directory. * @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. * @return {Promise} The new terminal, if a shell is available. */ - async function _createNewTerminalWithShell(shell, cwdOverride) { + async function _createNewTerminalWithShell(shell, cwdOverride, projectPath) { if (!shell) { console.error("Terminal: No shell available"); Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", "noShell"); @@ -335,11 +336,11 @@ define(function (require, exports, module) { _shellMetricLabel(shell.name)); // Get cwd: use override if provided, otherwise fall back to project root + const projectRoot = ProjectManager.getProjectRoot(); let cwd; if (cwdOverride) { cwd = _toNativePath(cwdOverride); } else { - const projectRoot = ProjectManager.getProjectRoot(); if (projectRoot) { cwd = _toNativePath(projectRoot.fullPath); } @@ -347,6 +348,8 @@ define(function (require, exports, module) { // Create instance 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); // Set up callbacks instance.onTitleChanged = _onTerminalTitleChanged; @@ -399,15 +402,15 @@ define(function (require, exports, module) { function _showProjectBanner() { _hideProjectBanner(); const root = ProjectManager.getProjectRoot(); - if (!root || !terminalInstances.length) { + if (!root || !terminalInstances.some(inst => inst.projectPath !== root.fullPath)) { return; } const $banner = $('
'); $banner.append($('
') .text(StringUtils.format(Strings.TERMINAL_PROJECT_CHANGED, root.name))); const path = _toNativePath(root.fullPath); - $banner.append($('
').text(path).attr("title", path)); - $banner.append($('
').text(Strings.TERMINAL_PROJECT_RESTART_WARNING)); + $banner.append($('
') + .text(StringUtils.format(Strings.TERMINAL_PROJECT_RESTART_PATH, path)).attr("title", path)); const $actions = $('
'); $actions.append($('') .text(Strings.TERMINAL_PROJECT_KEEP).on("click", function () { @@ -418,7 +421,8 @@ define(function (require, exports, module) { } })); $actions.append($('') - .text(Strings.TERMINAL_PROJECT_RESTART).on("click", _restartTerminalsInProject)); + .text(Strings.TERMINAL_PROJECT_RESTART).attr("title", Strings.TERMINAL_PROJECT_RESTART_WARNING) + .on("click", _restartTerminalsInProject)); $actions.find("button").prop("disabled", _restartingTerminals); $banner.append($actions); $contentArea.append($banner); @@ -482,7 +486,7 @@ define(function (require, exports, module) { _updateFlyout(); const replacements = []; for (const profile of profiles) { - replacements.push(await _createNewTerminalWithShell(profile, path)); + replacements.push(await _createNewTerminalWithShell(profile, path, path)); } if (replacements[activeIndex]) { _activateTerminal(replacements[activeIndex].id); @@ -558,6 +562,9 @@ define(function (require, exports, module) { instance.dispose(); terminalInstances.splice(idx, 1); delete processInfo[id]; + if ($contentArea.find(".terminal-project-banner").length) { + _showProjectBanner(); + } Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "close", "user"); // If we closed the active terminal, activate another diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 2be2f24c67..fd86c62978 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2231,6 +2231,7 @@ define({ "TERMINAL_CLEAR": "Clear Terminal", "TERMINAL_CLEAR_BUFFER_HINT": "💡 Press {0} to clear terminal buffer", "TERMINAL_PROJECT_CHANGED": "Project changed to {0}. Existing terminals have kept their locations.", + "TERMINAL_PROJECT_RESTART_PATH": "Restart in: {0}", "TERMINAL_PROJECT_RESTART_WARNING": "Restarting opens every terminal in this folder, stops running processes and clears terminal output.", "TERMINAL_PROJECT_KEEP": "Keep Terminals", "TERMINAL_PROJECT_RESTART": "Restart All in This Project", diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 18716e6435..a0a94f098c 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -28,6 +28,7 @@ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"); const Strings = require("strings"); + const StringUtils = require("utils/StringUtils"); const IS_WINDOWS = Phoenix.platform === "win"; const IS_MAC = Phoenix.platform === "mac"; @@ -688,7 +689,8 @@ define(function (require, exports, module) { expect(testWindow.$(".terminal-project-banner").length).toBe(0); await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); - expect(testWindow.$(".terminal-project-path").text()).toBe(getNativeProjectPath()); + expect(testWindow.$(".terminal-project-path").text()) + .toBe(StringUtils.format(Strings.TERMINAL_PROJECT_RESTART_PATH, getNativeProjectPath())); testWindow.$(".terminal-project-keep").click(); const panel = WorkspaceManager.getPanelForID(PANEL_ID); @@ -699,7 +701,38 @@ define(function (require, exports, module) { expect(termModule._getActiveTerminal()).toBe(instance); expect(instance.isAlive).toBeTrue(); await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + }, 30000); + + it("clears the banner when returning to the original project without restarting", async function () { + const instance = await openReadyTerminal(); + await writeToTerminal("cd ..\r"); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + }, 30000); + + it("keeps the banner while tabs from another project remain", async function () { + const first = await openReadyTerminal(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + await __PR.execCommand(termModule.CMD_NEW_TERMINAL); + const second = termModule._getActiveTerminal(); + await second.firstDataReceived; + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + testWindow.$('.terminal-flyout-item[data-terminal-id="' + second.id + '"] .terminal-flyout-close') + .click(); + await awaitsFor(function () { + return second._disposed && getTerminalCount() === 1; + }, "the other project's terminal to close", 10000); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(first); + expect(first.isAlive).toBeTrue(); }, 30000); it("restarts every tab in the new project and preserves its shell and selection", async function () { @@ -738,6 +771,10 @@ define(function (require, exports, module) { expect(instance.cwd).toBe(path); await expectWorkingDirectory(instance, path); } + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); }, 30000); it("confirms active processes and leaves sessions untouched when canceled", async function () { @@ -777,11 +814,7 @@ define(function (require, exports, module) { await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); await __PR.waitForModalDialogClosed(); - await awaitsFor(function () { - return !testWindow.$(".terminal-project-restart").prop("disabled"); - }, "new project banner to be actionable", 3000); - expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); - expect(testWindow.$(".terminal-project-path").text()).toBe(getNativeProjectPath()); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); expect(termModule._getActiveTerminal()).toBe(instance); expect(connectorSpy.calls.allArgs().some(args => args[0] === "killTerminal")).toBeFalse(); }, 30000); From c9343d252091cf2d83e81925a3eec2f61a51e152 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 17 Sep 2026 15:17:40 +0530 Subject: [PATCH 5/5] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 30759735f7..ed1a8bc1b9 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "4983d20cc5eb34a4cda14bd94c3599a24e56b6d1" + "commitID": "9e1ee1d4f8e5eb181a63329977cabebb3caec4d9" } }