From cddac197b866c9c78aa40ab0025d12b2a1f46f3c Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 26 Feb 2026 21:41:57 +0530 Subject: [PATCH 01/36] feat(terminal): add View > Terminal menu entry and improve panel toggling - Add VIEW_TERMINAL command to Commands.js and View menu - Remove toolbar-terminal sidebar icon and related CSS - Terminal command shows/focuses panel, cycles through terminals when panel is visible and focused with 2+ terminals open - Extract _togglePanels() in WorkspaceManager for reuse by both Escape key and status bar chevron - Escape toggles bottom panel, Shift+Escape cycles open panel tabs - Add PanelView.showNextPanel() for cycling open bottom panels - Fix DefaultPanelView terminal card to use Commands.VIEW_TERMINAL --- src/command/Commands.js | 3 + src/command/DefaultMenus.js | 1 + src/extensionsIntegrated/Terminal/main.js | 71 +++++++++-------------- src/index.html | 1 - src/nls/root/strings.js | 1 + src/styles/Extn-Terminal.less | 17 ------ src/view/DefaultPanelView.js | 2 +- src/view/PanelView.js | 19 ++++++ src/view/WorkspaceManager.js | 42 ++++++++------ 9 files changed, 76 insertions(+), 81 deletions(-) diff --git a/src/command/Commands.js b/src/command/Commands.js index 95a47fcf71..c22d3ba10e 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -297,6 +297,9 @@ define(function (require, exports, module) { /** Toggles problems panel visibility */ exports.VIEW_TOGGLE_PROBLEMS = "view.toggleProblems"; // CodeInspection.js toggleProblems() + /** Opens the terminal panel */ + exports.VIEW_TERMINAL = "view.terminal"; // Terminal/main.js _showTerminal() + /** Toggles line numbers visibility */ exports.TOGGLE_LINE_NUMBERS = "view.toggleLineNumbers"; // EditorOptionHandlers.js _getToggler() diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 9e713ecd19..3db49ac3a6 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -231,6 +231,7 @@ define(function (require, exports, module) { menu.addMenuItem(Commands.TOGGLE_RULERS); menu.addMenuDivider(); menu.addMenuItem(Commands.VIEW_TOGGLE_PROBLEMS); + menu.addMenuItem(Commands.VIEW_TERMINAL); menu.addMenuItem(Commands.VIEW_TOGGLE_INSPECTION); /* diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index dbe2b75b37..9d6f5d0ccc 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -30,7 +30,6 @@ define(function (require, exports, module) { 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"); @@ -40,6 +39,7 @@ define(function (require, exports, module) { const Strings = require("strings"); const StringUtils = require("utils/StringUtils"); + const Commands = require("command/Commands"); const TerminalInstance = require("./TerminalInstance"); const ShellProfiles = require("./ShellProfiles"); const panelHTML = require("text!./terminal-panel.html"); @@ -48,7 +48,7 @@ define(function (require, exports, module) { ExtensionUtils.loadStyleSheet(module, "../../thirdparty/xterm/xterm.css"); // Constants - const CMD_TOGGLE_TERMINAL = "terminal.toggle"; + const CMD_VIEW_TERMINAL = Commands.VIEW_TERMINAL; const CMD_NEW_TERMINAL = "terminal.new"; const PANEL_ID = "terminal-panel"; const PANEL_MIN_SIZE = 100; @@ -254,7 +254,7 @@ define(function (require, exports, module) { // Show panel if hidden if (!panel.isVisible()) { panel.show(); - _updateToolbarIcon(true); + } // Spawn PTY process @@ -329,7 +329,7 @@ define(function (require, exports, module) { // If no terminals left, hide the panel if (terminalInstances.length === 0) { panel.hide(); - _updateToolbarIcon(false); + } _updateFlyout(); @@ -467,23 +467,28 @@ define(function (require, exports, module) { } /** - * Toggle the terminal panel visibility + * 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. */ - function _togglePanel() { - if (panel.isVisible()) { - panel.hide(); - _updateToolbarIcon(false); + function _showTerminal() { + if (terminalInstances.length === 0) { + _createNewTerminal(); + return; + } + const active = _getActiveTerminal(); + const terminalHasFocus = active && active.$container && + active.$container[0].contains(document.activeElement); + if (terminalInstances.length >= 2 && panel.isVisible() && terminalHasFocus) { + const activeIdx = terminalInstances.findIndex(t => t.id === activeTerminalId); + const nextIdx = (activeIdx + 1) % terminalInstances.length; + _activateTerminal(terminalInstances[nextIdx].id); } else { - if (terminalInstances.length === 0) { - _createNewTerminal(); - } else { - panel.show(); - _updateToolbarIcon(true); - const active = _getActiveTerminal(); - if (active) { - active.handleResize(); - active.focus(); - } + panel.show(); + if (active) { + active.handleResize(); + active.focus(); } } } @@ -507,18 +512,6 @@ define(function (require, exports, module) { } } - /** - * 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 */ @@ -541,13 +534,7 @@ define(function (require, exports, module) { // 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"); - } + CommandManager.register(Strings.CMD_VIEW_TERMINAL, CMD_VIEW_TERMINAL, _showTerminal); // Initialize on app ready AppInit.appReady(function () { @@ -558,12 +545,6 @@ define(function (require, exports, module) { _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(); @@ -581,6 +562,6 @@ define(function (require, exports, module) { }); // Export for testing - exports.CMD_TOGGLE_TERMINAL = CMD_TOGGLE_TERMINAL; + exports.CMD_VIEW_TERMINAL = CMD_VIEW_TERMINAL; exports.CMD_NEW_TERMINAL = CMD_NEW_TERMINAL; }); diff --git a/src/index.html b/src/index.html index 700df9f3c8..5300d70303 100644 --- a/src/index.html +++ b/src/index.html @@ -995,7 +995,6 @@
-
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 34ff0b665d..d2c0bb6b06 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -650,6 +650,7 @@ define({ "CMD_TOGGLE_WORD_WRAP": "Word Wrap", "CMD_VIEW_TOGGLE_INSPECTION": "Lint Files on Save", "CMD_VIEW_TOGGLE_PROBLEMS": "Problems", + "CMD_VIEW_TERMINAL": "Terminal", "CMD_WORKINGSET_SORT_BY_ADDED": "Sort by Added", "CMD_WORKINGSET_SORT_BY_NAME": "Sort by Name", "CMD_WORKINGSET_SORT_BY_TYPE": "Sort by Type", diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less index 68512dcdbc..80673668e6 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -390,23 +390,6 @@ 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; diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index f6e6edda54..4491499ae5 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -71,7 +71,7 @@ define(function (require, exports, module) { id: "terminal", icon: "fa-solid fa-terminal", label: "Terminal", - commandID: "terminal.toggle", + commandID: Commands.VIEW_TERMINAL, nativeOnly: true } ]; diff --git a/src/view/PanelView.js b/src/view/PanelView.js index 7cd2a4e971..e4b7155c48 100644 --- a/src/view/PanelView.js +++ b/src/view/PanelView.js @@ -765,12 +765,31 @@ define(function (require, exports, module) { return closedIds; } + /** + * Cycle to the next open bottom panel tab. If the container is hidden + * or no panels are open, does nothing and returns false. + * @return {boolean} true if a panel switch occurred + */ + function showNextPanel() { + if (_openIds.length <= 0) { + return false; + } + const currentIdx = _activeId ? _openIds.indexOf(_activeId) : -1; + const nextIdx = (currentIdx + 1) % _openIds.length; + const nextPanel = _panelMap[_openIds[nextIdx]]; + if (nextPanel) { + nextPanel.show(); + } + return true; + } + EventDispatcher.makeEventDispatcher(exports); // Public API exports.Panel = Panel; exports.init = init; exports.getOpenBottomPanelIDs = getOpenBottomPanelIDs; + exports.showNextPanel = showNextPanel; exports.hideAllOpenPanels = hideAllOpenPanels; exports.exitMaximizeOnResize = exitMaximizeOnResize; exports.enterMaximizeOnResize = enterMaximizeOnResize; diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index dd2c899417..c79997ed9e 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -389,15 +389,7 @@ define(function (require, exports, module) { $statusBarPanelToggle.on("click", function () { _statusBarToggleInProgress = true; - if ($bottomPanelContainer.is(":visible")) { - Resizer.hide($bottomPanelContainer[0]); - triggerUpdateLayout(); - } else if (PanelView.getOpenBottomPanelIDs().length > 0) { - Resizer.show($bottomPanelContainer[0]); - triggerUpdateLayout(); - } else { - _showDefaultPanel(); - } + _togglePanels(); _statusBarToggleInProgress = false; }); @@ -631,15 +623,28 @@ define(function (require, exports, module) { } } - function _handleEscapeKey() { - // Collapse the entire bottom panel container, keeping all tabs intact. - // Maximize state is preserved so the panel re-opens maximized. - if ($bottomPanelContainer && $bottomPanelContainer.is(":visible")) { + /** + * Toggle the bottom panel container: hide if visible, show if there are + * open panels, or show the default panel when nothing is open. + * @return {boolean} true if the toggle was handled + */ + function _togglePanels() { + if (!$bottomPanelContainer) { + return false; + } + if ($bottomPanelContainer.is(":visible")) { Resizer.hide($bottomPanelContainer[0]); - triggerUpdateLayout(); - return true; + } else if (PanelView.getOpenBottomPanelIDs().length > 0) { + Resizer.show($bottomPanelContainer[0]); + } else { + _showDefaultPanel(); } - return false; + triggerUpdateLayout(); + return true; + } + + function _handleEscapeKey() { + return _togglePanels(); } // pressing escape when focused on editor will hide the bottom panel container @@ -666,7 +671,10 @@ define(function (require, exports, module) { return; } - if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) { + if (event.shiftKey) { + // Shift+Escape: cycle through open bottom panels + PanelView.showNextPanel(); + } else { _handleEscapeKey(); } From 003bddb204843bc5073b9fa1ee93e48ce6185472 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 11:24:34 +0530 Subject: [PATCH 02/36] feat: shift+escape toggles focus between editor and bottom panel - Add Panel.focus() API (default returns false, override for focusable panels) - Add PanelView.getActiveBottomPanel() to retrieve the active panel - Terminal panel overrides focus() to focus the active terminal instance - Terminal passes Shift+Escape through to WorkspaceManager - Shift+Escape from editor focuses the active bottom panel if visible - Shift+Escape from anywhere else focuses the active editor --- .../Terminal/TerminalInstance.js | 5 +++ src/extensionsIntegrated/Terminal/main.js | 10 +++++ src/view/PanelView.js | 21 ++++++++++ src/view/WorkspaceManager.js | 42 +++++++++++++++---- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js index b196efc814..50f9afa71b 100644 --- a/src/extensionsIntegrated/Terminal/TerminalInstance.js +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -247,6 +247,11 @@ define(function (require, exports, module) { const ctrlOrMeta = event.ctrlKey || event.metaKey; + // Shift+Escape should focus the active editor + if (event.shiftKey && event.key === "Escape") { + return false; + } + // 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; diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 9d6f5d0ccc..8fc38547b9 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -106,6 +106,16 @@ define(function (require, exports, module) { $panel = $(Mustache.render(panelHTML, templateVars)); panel = WorkspaceManager.createBottomPanel(PANEL_ID, $panel, PANEL_MIN_SIZE); + // Override focus() so Shift+Escape can transfer focus to the terminal + panel.focus = function () { + const active = _getActiveTerminal(); + if (active) { + active.focus(); + return true; + } + return false; + }; + // Cache DOM references $contentArea = $panel.find(".terminal-content-area"); $shellDropdown = $panel.find(".terminal-shell-dropdown"); diff --git a/src/view/PanelView.js b/src/view/PanelView.js index e4b7155c48..8dbb52ba97 100644 --- a/src/view/PanelView.js +++ b/src/view/PanelView.js @@ -412,6 +412,15 @@ define(function (require, exports, module) { } }; + /** + * Attempts to focus the panel. Override this in panels that support focus + * (e.g. terminal). The default implementation returns false. + * @return {boolean} true if the panel accepted focus, false otherwise + */ + Panel.prototype.focus = function () { + return false; + }; + /** * Sets the panel's visibility state * @param {boolean} visible true to show, false to hide @@ -765,6 +774,17 @@ define(function (require, exports, module) { return closedIds; } + /** + * Returns the currently active (visible) bottom panel, or null if none. + * @return {Panel|null} + */ + function getActiveBottomPanel() { + if (_activeId && _panelMap[_activeId]) { + return _panelMap[_activeId]; + } + return null; + } + /** * Cycle to the next open bottom panel tab. If the container is hidden * or no panels are open, does nothing and returns false. @@ -789,6 +809,7 @@ define(function (require, exports, module) { exports.Panel = Panel; exports.init = init; exports.getOpenBottomPanelIDs = getOpenBottomPanelIDs; + exports.getActiveBottomPanel = getActiveBottomPanel; exports.showNextPanel = showNextPanel; exports.hideAllOpenPanels = hideAllOpenPanels; exports.exitMaximizeOnResize = exitMaximizeOnResize; diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index c79997ed9e..d31276ca63 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -45,6 +45,7 @@ define(function (require, exports, module) { PluginPanelView = require("view/PluginPanelView"), PanelView = require("view/PanelView"), EditorManager = require("editor/EditorManager"), + MainViewManager = require("view/MainViewManager"), KeyEvent = require("utils/KeyEvent"); /** @@ -647,12 +648,44 @@ define(function (require, exports, module) { return _togglePanels(); } - // pressing escape when focused on editor will hide the bottom panel container + /** + * Shift+Escape: toggle focus between editor and active bottom panel + * @param event + * @returns {boolean} + * @private + */ + function _handleShiftEscape(event) { + if (!event.shiftKey) { + return false; + } + if (EditorManager.getFocusedEditor()) { + // Editor has focus — focus the panel + const activePanel = PanelView.getActiveBottomPanel(); + if(!activePanel || !activePanel.isVisible()){ + _togglePanels(); + } + activePanel.focus(); + } else { + // Focus is elsewhere (panel, sidebar, etc.) — focus the editor + MainViewManager.focusActivePane(); + } + event.stopPropagation(); + event.preventDefault(); + return true; + } + + // pressing escape when focused on editor will toggle the bottom panel container + // pressing shift+escape toggles focus between editor and active bottom panel function _handleKeydown(event) { if(event.keyCode !== KeyEvent.DOM_VK_ESCAPE || KeyBindingManager.isInOverlayMode()){ return; } + // Shift+Escape: toggle focus between editor and active bottom panel + if (_handleShiftEscape(event)) { + return; + } + for(let consumerName of Object.keys(_escapeKeyConsumers)){ if(_escapeKeyConsumers[consumerName](event)){ return; @@ -671,12 +704,7 @@ define(function (require, exports, module) { return; } - if (event.shiftKey) { - // Shift+Escape: cycle through open bottom panels - PanelView.showNextPanel(); - } else { - _handleEscapeKey(); - } + _handleEscapeKey(); event.stopPropagation(); event.preventDefault(); From 99f15327ff68488f1f767475d00230a0bfa56c45 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 11:41:04 +0530 Subject: [PATCH 03/36] feat(terminal): add F4 shortcut and auto-focus on panel shown - Add F4 keyboard shortcut for view.terminal command - Listen for EVENT_PANEL_SHOWN to focus terminal when panel becomes visible --- src/base-config/keyboard.json | 3 +++ src/extensionsIntegrated/Terminal/main.js | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/base-config/keyboard.json b/src/base-config/keyboard.json index 6469aadec7..488573c9a4 100644 --- a/src/base-config/keyboard.json +++ b/src/base-config/keyboard.json @@ -290,6 +290,9 @@ "view.toggleProblems": [ "Ctrl-Shift-M" ], + "view.terminal": [ + "F4" + ], "navigate.jumptoDefinition": [ "Ctrl-J" ], diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 8fc38547b9..fb05afcab0 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -136,6 +136,18 @@ define(function (require, exports, module) { // Listen for panel resize WorkspaceManager.on("workspaceUpdateLayout", _handleResize); + // Focus terminal when the panel becomes visible + const PanelView = require("view/PanelView"); + PanelView.on(PanelView.EVENT_PANEL_SHOWN, function (_event, panelId) { + if (panelId === PANEL_ID) { + const active = _getActiveTerminal(); + if (active) { + active.handleResize(); + active.focus(); + } + } + }); + // Listen for theme changes via MutationObserver on body class const observer = new MutationObserver(function () { _updateAllThemes(); @@ -264,7 +276,6 @@ define(function (require, exports, module) { // Show panel if hidden if (!panel.isVisible()) { panel.show(); - } // Spawn PTY process From 8ea0ca0df0c15e759f6cf6ff11377e4d742019bb Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 11:52:31 +0530 Subject: [PATCH 04/36] feat(terminal): show shift+escape focus hint toast on first terminal open - Add one-time toast notification showing Shift+Esc shortcut hint - Internationalize toast text via TERMINAL_FOCUS_HINT string - Add theme-aware toast styles for light and dark themes --- src/extensionsIntegrated/Terminal/main.js | 31 +++++++++++++++ src/nls/root/strings.js | 1 + src/styles/Extn-Terminal.less | 46 +++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index fb05afcab0..29b73dbc76 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -81,6 +81,7 @@ define(function (require, exports, module) { let activeTerminalId = null; // Currently visible terminal let processInfo = {}; // id -> processName from PTY let originalDefaultShellName = null; // System-detected default shell name + let _focusToastShown = false; // Show focus hint toast only once per session let $panel, $contentArea, $shellDropdown, $flyoutList; /** @@ -145,6 +146,7 @@ define(function (require, exports, module) { active.handleResize(); active.focus(); } + _showFocusHintToast(); } }); @@ -533,6 +535,35 @@ define(function (require, exports, module) { } } + /** + * Show a one-time toast hint about Shift+Escape to switch focus + */ + function _showFocusHintToast() { + if (_focusToastShown) { + return; + } + _focusToastShown = true; + + const shortcutKey = 'Shift+Esc'; + const message = StringUtils.format(Strings.TERMINAL_FOCUS_HINT, shortcutKey); + const $toast = $('
') + .html('' + message + ''); + $contentArea.append($toast); + + // Fade in + setTimeout(function () { + $toast.addClass("visible"); + }, 100); + + // Auto-dismiss after 5 seconds + setTimeout(function () { + $toast.removeClass("visible"); + setTimeout(function () { + $toast.remove(); + }, 300); + }, 5000); + } + /** * Escape HTML special characters */ diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index d2c0bb6b06..86ae2c0054 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1488,6 +1488,7 @@ define({ "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?", + "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", "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 80673668e6..ef6ef3e5f3 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -399,3 +399,49 @@ color: var(--terminal-tab-text); font-size: 13px; } + +/* Focus hint toast */ +.terminal-focus-toast { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 6px; + font-size: 12px; + line-height: 1.4; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 10; + background: rgba(0, 0, 0, 0.75); + color: #e0e0e0; +} + +.terminal-focus-toast.visible { + opacity: 1; +} + +.terminal-focus-toast kbd { + display: inline-block; + padding: 1px 5px; + margin: 0 2px; + border-radius: 3px; + font-family: inherit; + font-size: 11px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Light theme toast */ +.terminal-panel-container .terminal-focus-toast { + background: rgba(0, 0, 0, 0.7); + color: #f0f0f0; +} + +.terminal-panel-container .terminal-focus-toast kbd { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.25); + color: #fff; +} From 6aef0c74d0f40069119662f4286e2927dceababd Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 13:06:31 +0530 Subject: [PATCH 05/36] refactor(notification): extract inline toast into reusable NotificationUI.showToastOn API Move terminal focus hint toast implementation into NotificationUI as a generic showToastOn(container, template, options) function. Move toast CSS from Extn-Terminal.less to brackets.less as .inline-toast. Add unit tests for the new API. --- src/extensionsIntegrated/Terminal/main.js | 21 ++--- src/styles/Extn-Terminal.less | 45 ---------- src/styles/brackets.less | 34 +++++++ src/widgets/NotificationUI.js | 83 +++++++++++++++++ test/spec/NotificationUI-test.js | 104 ++++++++++++++++++++++ 5 files changed, 226 insertions(+), 61 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 29b73dbc76..cd58b7ede3 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -40,6 +40,7 @@ define(function (require, exports, module) { const StringUtils = require("utils/StringUtils"); const Commands = require("command/Commands"); + const NotificationUI = require("widgets/NotificationUI"); const TerminalInstance = require("./TerminalInstance"); const ShellProfiles = require("./ShellProfiles"); const panelHTML = require("text!./terminal-panel.html"); @@ -546,22 +547,10 @@ define(function (require, exports, module) { const shortcutKey = 'Shift+Esc'; const message = StringUtils.format(Strings.TERMINAL_FOCUS_HINT, shortcutKey); - const $toast = $('
') - .html('' + message + ''); - $contentArea.append($toast); - - // Fade in - setTimeout(function () { - $toast.addClass("visible"); - }, 100); - - // Auto-dismiss after 5 seconds - setTimeout(function () { - $toast.removeClass("visible"); - setTimeout(function () { - $toast.remove(); - }, 300); - }, 5000); + NotificationUI.showToastOn($contentArea[0], message, { + autoCloseTimeS: 5, + dismissOnClick: true + }); } /** diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less index ef6ef3e5f3..db7b298c67 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -400,48 +400,3 @@ font-size: 13px; } -/* Focus hint toast */ -.terminal-focus-toast { - position: absolute; - bottom: 12px; - left: 50%; - transform: translateX(-50%); - padding: 6px 14px; - border-radius: 6px; - font-size: 12px; - line-height: 1.4; - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; - z-index: 10; - background: rgba(0, 0, 0, 0.75); - color: #e0e0e0; -} - -.terminal-focus-toast.visible { - opacity: 1; -} - -.terminal-focus-toast kbd { - display: inline-block; - padding: 1px 5px; - margin: 0 2px; - border-radius: 3px; - font-family: inherit; - font-size: 11px; - background: rgba(255, 255, 255, 0.15); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; -} - -/* Light theme toast */ -.terminal-panel-container .terminal-focus-toast { - background: rgba(0, 0, 0, 0.7); - color: #f0f0f0; -} - -.terminal-panel-container .terminal-focus-toast kbd { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.25); - color: #fff; -} diff --git a/src/styles/brackets.less b/src/styles/brackets.less index 3580935369..adc23d7d1b 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -3381,6 +3381,40 @@ label input { } } +/* Inline toast: positioned inside a relative/absolute container */ +.inline-toast { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 6px; + font-size: 12px; + line-height: 1.4; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 10; + background: rgba(0, 0, 0, 0.75); + color: #e0e0e0; +} + +.inline-toast.visible { + opacity: 1; +} + +.inline-toast kbd { + display: inline-block; + padding: 1px 5px; + margin: 0 2px; + border-radius: 3px; + font-family: inherit; + font-size: 11px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; +} + .github-stars-button { .starContainer { --fa-style-family-brands: "Font Awesome 6 Brands"; diff --git a/src/widgets/NotificationUI.js b/src/widgets/NotificationUI.js index 90858caa85..0aab5da8f0 100644 --- a/src/widgets/NotificationUI.js +++ b/src/widgets/NotificationUI.js @@ -398,8 +398,91 @@ define(function (require, exports, module) { return notification; } + /** + * Shows a small, transient inline toast notification inside a given DOM container. + * The toast is centered at the bottom of the container and auto-dismisses. + * + * ```js + * NotificationUI.showToastOn(document.getElementById("my-panel"), "Hello!", { + * autoCloseTimeS: 5, + * dismissOnClick: true + * }); + * ``` + * + * @param {Element|string} containerOrSelector A DOM element or CSS selector for the parent container. + * The container should have `position: relative` or `absolute` so the toast is positioned correctly. + * @param {string|Element} template HTML string or DOM Element for the toast content. + * @param {Object} [options] optional, supported options: + * * `autoCloseTimeS` - Time in seconds after which the toast auto-closes. Default is 5. + * * `dismissOnClick` - If true, clicking the toast dismisses it. Default is true. + * @return {Notification} Object with a done handler that resolves when the toast closes. + * @type {function} + */ + function showToastOn(containerOrSelector, template, options = {}) { + const autoCloseTimeS = options.autoCloseTimeS !== undefined ? options.autoCloseTimeS : 5; + const dismissOnClick = options.dismissOnClick !== undefined ? options.dismissOnClick : true; + + const $container = $(containerOrSelector); + const $toast = $('
'); + if (typeof template === "string") { + $toast.html(template); + } else { + $toast.append($(template)); + } + $container.append($toast); + + const notification = new Notification($toast, "inlineToast"); + + // Fade in on next frame + requestAnimationFrame(function () { + $toast.addClass("visible"); + }); + + function closeToast(reason) { + let cleaned = false; + function cleanup() { + if (cleaned) { + return; + } + cleaned = true; + $toast.remove(); + notification._result.resolve(reason); + } + $toast.removeClass("visible"); + $toast.one("transitionend transitioncancel", cleanup); + // Safety fallback in case transition events don't fire + setTimeout(cleanup, 500); + } + + notification.close = function (closeType) { + if (!this.$notification) { + return this; + } + this.$notification = null; + closeToast(closeType || CLOSE_REASON.CLICK_DISMISS); + return this; + }; + + if (autoCloseTimeS) { + setTimeout(function () { + if (notification.$notification) { + notification.close(CLOSE_REASON.TIMEOUT); + } + }, autoCloseTimeS * 1000); + } + + if (dismissOnClick) { + $toast.on("click", function () { + notification.close(CLOSE_REASON.CLICK_DISMISS); + }); + } + + return notification; + } + exports.createFromTemplate = createFromTemplate; exports.createToastFromTemplate = createToastFromTemplate; + exports.showToastOn = showToastOn; exports.CLOSE_REASON = CLOSE_REASON; exports.NOTIFICATION_STYLES_CSS_CLASS = NOTIFICATION_STYLES_CSS_CLASS; }); diff --git a/test/spec/NotificationUI-test.js b/test/spec/NotificationUI-test.js index 2880179714..7b8418b333 100644 --- a/test/spec/NotificationUI-test.js +++ b/test/spec/NotificationUI-test.js @@ -114,5 +114,109 @@ define(function (require, exports, module) { await verifyToast(NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.DANGER); await verifyToast("custom-class-name"); }, 10000); + + describe("showToastOn", function () { + let $container; + + beforeAll(function () { + $container = $( + '
'); + $("body").append($container); + }); + + afterAll(function () { + $container.remove(); + }); + + it("Should show an inline toast inside a container", async function () { + let notification = NotificationUI.showToastOn($container[0], "Hello inline toast"); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 1; + }, "waiting for inline toast to appear"); + expect($container.find(".inline-toast").text()).toBe("Hello inline toast"); + notification.close(); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to close"); + }); + + it("Should auto-close after autoCloseTimeS", async function () { + NotificationUI.showToastOn($container[0], "Auto close", { + autoCloseTimeS: 1 + }); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 1; + }, "waiting for inline toast to appear"); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to auto-close", 3000); + }); + + it("Should dismiss on click by default", async function () { + NotificationUI.showToastOn($container[0], "Click me"); + await awaitsFor(function () { + return $container.find(".inline-toast.visible").length === 1; + }, "waiting for inline toast to be visible"); + $container.find(".inline-toast").click(); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to close on click"); + }); + + it("Should not dismiss on click when dismissOnClick is false", async function () { + let notification = NotificationUI.showToastOn($container[0], "No dismiss", { + dismissOnClick: false, + autoCloseTimeS: 0 + }); + await awaitsFor(function () { + return $container.find(".inline-toast.visible").length === 1; + }, "waiting for inline toast to be visible"); + $container.find(".inline-toast").click(); + await awaits(250); + expect($container.find(".inline-toast").length).toBe(1); + notification.close("manual"); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to close manually"); + }); + + it("Should accept a jQuery selector string as container", async function () { + NotificationUI.showToastOn("#inline-toast-test-container", "Selector toast"); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 1; + }, "waiting for inline toast via selector"); + $container.find(".inline-toast").click(); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to close"); + }); + + it("Should resolve done callback with close reason", async function () { + let closeReason; + let notification = NotificationUI.showToastOn($container[0], "Done test"); + notification.done(function (reason) { + closeReason = reason; + }); + await awaitsFor(function () { + return $container.find(".inline-toast.visible").length === 1; + }, "waiting for inline toast to be visible"); + notification.close("testReason"); + await awaitsFor(function () { + return closeReason === "testReason"; + }, "waiting for done callback"); + }); + + it("Should accept HTML template with elements", async function () { + NotificationUI.showToastOn($container[0], 'Bold text'); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 1; + }, "waiting for inline toast"); + expect($container.find(".inline-toast b").length).toBe(1); + $container.find(".inline-toast").click(); + await awaitsFor(function () { + return $container.find(".inline-toast").length === 0; + }, "waiting for inline toast to close"); + }); + }); }); }); From 165b4194291624609ce807ad854d99966e054a0d Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 16:06:09 +0530 Subject: [PATCH 06/36] fix(workspace): clamp plugin panel width on window resize Plugin panels could encroach into the sidebar/content area when the window was resized smaller, or reopen too wide after a resize while closed. Add _clampPluginPanelWidth to enforce max width limits both during window resize events and when panels are first shown. --- src/view/WorkspaceManager.js | 21 ++++++ test/spec/MainViewManager-integ-test.js | 97 +++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index d31276ca63..745e9af2a5 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -213,6 +213,11 @@ define(function (require, exports, module) { return; } + // Clamp plugin panel toolbar width so it doesn't encroach into the sidebar/content area + if (currentlyShownPanel && $mainToolbar.is(":visible")) { + _clampPluginPanelWidth(currentlyShownPanel); + } + // FIXME (issue #4564) Workaround https://github.com/codemirror/CodeMirror/issues/1787 triggerUpdateLayout(); @@ -496,12 +501,28 @@ define(function (require, exports, module) { return panel.initialSize; } + function _clampPluginPanelWidth(panelBeingShown) { + let sidebarWidth = $("#sidebar").outerWidth() || 0; + let pluginIconsBarWidth = $pluginIconsBar.outerWidth(); + let minToolbarWidth = (panelBeingShown.minWidth || 0) + pluginIconsBarWidth; + let maxToolbarWidth = Math.max( + minToolbarWidth, + Math.min(window.innerWidth * 0.75, window.innerWidth - sidebarWidth - 100) + ); + if ($mainToolbar.width() > maxToolbarWidth) { + $mainToolbar.width(maxToolbarWidth); + $windowContent.css("right", maxToolbarWidth); + Resizer.resyncSizer($mainToolbar[0]); + } + } + function _showPluginSidePanel(panelID) { let panelBeingShown = getPanelForID(panelID); Resizer.makeResizable($mainToolbar, Resizer.DIRECTION_HORIZONTAL, Resizer.POSITION_LEFT, panelBeingShown.minWidth, false, undefined, true, undefined, $windowContent, undefined, _getInitialSize(panelBeingShown)); Resizer.show($mainToolbar[0]); + _clampPluginPanelWidth(panelBeingShown); recomputeLayout(true); } diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index a3a895da0b..59b77ccead 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -1237,5 +1237,102 @@ define(function (require, exports, module) { expect(maxSize).toBeLessThanOrEqual(testWindow.innerWidth * 0.75); }); }); + + describe("Plugin panel clamping on window resize", function () { + let pluginPanel, $toolbarIcon; + const MIN_WIDTH = 200; + + beforeAll(function () { + $toolbarIcon = _$(''); + _$("#plugin-icons-bar").append($toolbarIcon); + + let panelTemplate = '
Test Panel
'; + pluginPanel = WorkspaceManager.createPluginPanel( + "test-clamp-panel", _$(panelTemplate), MIN_WIDTH, $toolbarIcon + ); + }); + + afterAll(function () { + if (pluginPanel) { + pluginPanel.hide(); + } + $toolbarIcon.remove(); + }); + + afterEach(function () { + pluginPanel.hide(); + }); + + it("should clamp plugin panel when window resizes smaller", function () { + pluginPanel.show(); + WorkspaceManager.setPluginPanelWidth(600); + + const $mainToolbar = _$("#main-toolbar"); + const widthBefore = $mainToolbar.width(); + + // Simulate a narrow window resize + const sidebarWidth = _$("#sidebar").outerWidth() || 0; + const maxAllowed = Math.min( + testWindow.innerWidth * 0.75, + testWindow.innerWidth - sidebarWidth - 100 + ); + + // Only expect clamping if the panel was wider than max + if (widthBefore > maxAllowed) { + testWindow.dispatchEvent(new testWindow.Event("resize")); + expect($mainToolbar.width()).toBeLessThanOrEqual(maxAllowed); + } else { + // Panel fits, dispatch resize and verify it stays unchanged + testWindow.dispatchEvent(new testWindow.Event("resize")); + expect($mainToolbar.width()).toEqual(widthBefore); + } + }); + + it("should not let toolbar disappear on window resize", function () { + pluginPanel.show(); + + const $mainToolbar = _$("#main-toolbar"); + const $pluginIconsBar = _$("#plugin-icons-bar"); + + testWindow.dispatchEvent(new testWindow.Event("resize")); + + // Toolbar must remain at least as wide as the icons bar + panel minWidth + const minToolbarWidth = MIN_WIDTH + $pluginIconsBar.outerWidth(); + expect($mainToolbar.width()).toBeGreaterThanOrEqual(minToolbarWidth); + }); + + it("should clamp panel width when shown after window was resized", function () { + // Panel is hidden; compute what the max toolbar width would be + const sidebarWidth = _$("#sidebar").outerWidth() || 0; + const $pluginIconsBar = _$("#plugin-icons-bar"); + const maxToolbarWidth = Math.min( + testWindow.innerWidth * 0.75, + testWindow.innerWidth - sidebarWidth - 100 + ); + + // Now show the panel — it should be clamped to maxToolbarWidth + pluginPanel.show(); + + const $mainToolbar = _$("#main-toolbar"); + expect($mainToolbar.width()).toBeLessThanOrEqual(maxToolbarWidth); + + // And content area should match + const $windowContent = _$(".content"); + const rightOffset = parseInt($windowContent.css("right"), 10); + expect(rightOffset).toEqual($mainToolbar.width()); + }); + + it("should keep content right offset in sync after resize clamp", function () { + pluginPanel.show(); + WorkspaceManager.setPluginPanelWidth(600); + + testWindow.dispatchEvent(new testWindow.Event("resize")); + + const $mainToolbar = _$("#main-toolbar"); + const $windowContent = _$(".content"); + const rightOffset = parseInt($windowContent.css("right"), 10); + expect(rightOffset).toEqual($mainToolbar.width()); + }); + }); }); }); From 810d7cb73c75ce0fef2b079edf7d1165854697c0 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 18:54:42 +0530 Subject: [PATCH 07/36] feat(panel): add requestClose with onCloseRequested handler for panels Add registerOnCloseRequestedHandler/requestClose API to both PanelView and PluginPanelView. The tab close button now calls requestClose() which invokes the async handler before hiding. Terminal registers a handler that confirms before disposing all terminals when active processes are running or multiple terminals are open. --- src/extensionsIntegrated/Terminal/main.js | 42 +++++++++++++++++++++++ src/nls/root/strings.js | 3 ++ src/view/PanelView.js | 31 ++++++++++++++++- src/view/PluginPanelView.js | 29 ++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index cd58b7ede3..2ff6f734d9 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -586,6 +586,48 @@ define(function (require, exports, module) { _initNodeConnector(); _createPanel(); + // 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 () { + const activeProcesses = []; + for (const inst of terminalInstances) { + if (!inst.isAlive) { + continue; + } + try { + const result = await nodeConnector.execPeer("getTerminalProcess", {id: inst.id}); + if (result.process && !_isShellProcess(result.process)) { + activeProcesses.push(result.process); + } + } catch (e) { + // terminal may be dead + } + } + + if (terminalInstances.length > 1 || activeProcesses.length > 0) { + const msgKey = activeProcesses.length > 0 + ? Strings.TERMINAL_CLOSE_ALL_MSG_PROCESS + : Strings.TERMINAL_CLOSE_ALL_MSG; + const message = StringUtils.format( + msgKey, terminalInstances.length, activeProcesses.length + ); + const dialog = Dialogs.showConfirmDialog( + Strings.TERMINAL_CLOSE_ALL_TITLE, message + ); + const buttonId = await dialog.getPromise(); + if (buttonId !== Dialogs.DIALOG_BTN_OK) { + return false; + } + } + + // User confirmed (or single idle terminal) — dispose everything + _disposeAll(); + activeTerminalId = null; + _updateFlyout(); + return true; + }); + // Detect shells ShellProfiles.init(nodeConnector).then(function () { const shells = ShellProfiles.getShells(); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 86ae2c0054..c32c06ea77 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1488,6 +1488,9 @@ define({ "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?", + "TERMINAL_CLOSE_ALL_TITLE": "Close All Terminals", + "TERMINAL_CLOSE_ALL_MSG": "This will close {0} terminal(s).
Continue?", + "TERMINAL_CLOSE_ALL_MSG_PROCESS": "This will close {0} terminal(s). {1} have active processes that will be terminated.
Continue?", "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", "EXTENDED_COMMIT_MESSAGE": "EXTENDED", "GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026", diff --git a/src/view/PanelView.js b/src/view/PanelView.js index 8dbb52ba97..83c3f4e9e5 100644 --- a/src/view/PanelView.js +++ b/src/view/PanelView.js @@ -328,6 +328,35 @@ define(function (require, exports, module) { return true; }; + /** + * Registers an async handler that is called before the panel is closed via user interaction (e.g. clicking the + * tab close button). The handler should return `true` to allow the close, or `false` to prevent it. + * @param {function|null} handler An async function returning a boolean, or null to clear the handler. + */ + Panel.prototype.registerOnCloseRequestedHandler = function (handler) { + if (this._onCloseRequestedHandler && handler) { + console.warn(`onCloseRequestedHandler already registered for panel: ${this.panelID}. will be overwritten`); + } + this._onCloseRequestedHandler = handler; + }; + + /** + * Requests the panel to hide, invoking the registered onCloseRequested handler first (if any). + * If the handler returns false, the panel stays open. If it returns true or no handler is + * registered, `hide()` is called. + * @return {Promise} Resolves to true if the panel was hidden, false if prevented. + */ + Panel.prototype.requestClose = async function () { + if (this._onCloseRequestedHandler) { + const allowed = await this._onCloseRequestedHandler(); + if (!allowed) { + return false; + } + } + this.hide(); + return true; + }; + /** * Shows the panel */ @@ -495,7 +524,7 @@ define(function (require, exports, module) { if (panelId) { let panel = _panelMap[panelId]; if (panel) { - panel.hide(); + panel.requestClose(); } } }); diff --git a/src/view/PluginPanelView.js b/src/view/PluginPanelView.js index 8d1e829a43..c4b41dffb6 100644 --- a/src/view/PluginPanelView.js +++ b/src/view/PluginPanelView.js @@ -109,6 +109,35 @@ define(function (require, exports, module) { return true; }; + /** + * Registers an async handler that is called before the panel is closed via user interaction. + * The handler should return `true` to allow the close, or `false` to prevent it. + * @param {function|null} handler An async function returning a boolean, or null to clear the handler. + */ + Panel.prototype.registerOnCloseRequestedHandler = function (handler) { + if (this._onCloseRequestedHandler && handler) { + console.warn(`onCloseRequestedHandler already registered for panel: ${this.panelID}. will be overwritten`); + } + this._onCloseRequestedHandler = handler; + }; + + /** + * Requests the panel to hide, invoking the registered onCloseRequested handler first (if any). + * If the handler returns false, the panel stays open. If it returns true or no handler is + * registered, `hide()` is called. + * @return {Promise} Resolves to true if the panel was hidden, false if prevented. + */ + Panel.prototype.requestClose = async function () { + if (this._onCloseRequestedHandler) { + const allowed = await this._onCloseRequestedHandler(); + if (!allowed) { + return false; + } + } + this.hide(); + return true; + }; + /** * Shows the panel */ From 16053f9d1d03b8d32bcb34686b964dcd4b51f2c6 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 19:11:08 +0530 Subject: [PATCH 08/36] feat(terminal): improve close confirmation dialogs with context-specific messages Use distinct dialog titles, messages, and button labels for three scenarios: single terminal with active process, multiple idle terminals, and multiple terminals with active processes. Singular/plural forms use separate i18n keys for proper translation support. --- src/extensionsIntegrated/Terminal/main.js | 56 +++++++++++++++++------ src/nls/root/strings.js | 12 +++-- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 2ff6f734d9..aefea72bc7 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -36,6 +36,7 @@ define(function (require, exports, module) { const NodeConnector = require("NodeConnector"); const Mustache = require("thirdparty/mustache/mustache"); const Dialogs = require("widgets/Dialogs"); + const DefaultDialogs = require("widgets/DefaultDialogs"); const Strings = require("strings"); const StringUtils = require("utils/StringUtils"); @@ -605,23 +606,48 @@ define(function (require, exports, module) { } } - if (terminalInstances.length > 1 || activeProcesses.length > 0) { - const msgKey = activeProcesses.length > 0 - ? Strings.TERMINAL_CLOSE_ALL_MSG_PROCESS - : Strings.TERMINAL_CLOSE_ALL_MSG; - const message = StringUtils.format( - msgKey, terminalInstances.length, activeProcesses.length - ); - const dialog = Dialogs.showConfirmDialog( - Strings.TERMINAL_CLOSE_ALL_TITLE, message - ); - const buttonId = await dialog.getPromise(); - if (buttonId !== Dialogs.DIALOG_BTN_OK) { - return false; - } + let title, message, confirmText; + const count = terminalInstances.length; + const procCount = activeProcesses.length; + + if (count === 1 && procCount > 0) { + // Single terminal with an active process + title = Strings.TERMINAL_CLOSE_SINGLE_TITLE; + message = Strings.TERMINAL_CLOSE_SINGLE_MSG; + confirmText = Strings.TERMINAL_CLOSE_SINGLE_BTN; + } else if (count > 1 && procCount === 0) { + // Multiple terminals, no active processes + title = Strings.TERMINAL_CLOSE_ALL_TITLE; + message = Strings.TERMINAL_CLOSE_ALL_MSG; + confirmText = Strings.TERMINAL_CLOSE_ALL_BTN; + } else if (count > 1 && procCount > 0) { + // Multiple terminals, some with active processes + title = Strings.TERMINAL_CLOSE_ALL_TITLE; + message = procCount === 1 + ? Strings.TERMINAL_CLOSE_ALL_MSG_PROCESS_ONE + : StringUtils.format(Strings.TERMINAL_CLOSE_ALL_MSG_PROCESS_MANY, procCount); + confirmText = Strings.TERMINAL_CLOSE_ALL_STOP_BTN; + } else { + // Single idle terminal — no confirmation needed + _disposeAll(); + activeTerminalId = null; + _updateFlyout(); + return true; + } + + const buttons = [ + {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: confirmText} + ]; + const dialog = Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_INFO, title, message, buttons + ); + const buttonId = await dialog.getPromise(); + if (buttonId !== Dialogs.DIALOG_BTN_OK) { + return false; } - // User confirmed (or single idle terminal) — dispose everything + // User confirmed — dispose everything _disposeAll(); activeTerminalId = null; _updateFlyout(); diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index c32c06ea77..dde5873c9e 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1488,9 +1488,15 @@ define({ "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?", - "TERMINAL_CLOSE_ALL_TITLE": "Close All Terminals", - "TERMINAL_CLOSE_ALL_MSG": "This will close {0} terminal(s).
Continue?", - "TERMINAL_CLOSE_ALL_MSG_PROCESS": "This will close {0} terminal(s). {1} have active processes that will be terminated.
Continue?", + "TERMINAL_CLOSE_SINGLE_TITLE": "Close Terminal?", + "TERMINAL_CLOSE_SINGLE_MSG": "This terminal has an active process. Closing it will stop the process.
Do you want to continue?", + "TERMINAL_CLOSE_SINGLE_BTN": "Close Terminal", + "TERMINAL_CLOSE_ALL_TITLE": "Close All Terminals?", + "TERMINAL_CLOSE_ALL_MSG": "All terminals will be closed.
No active processes are running.

Continue?", + "TERMINAL_CLOSE_ALL_BTN": "Close All", + "TERMINAL_CLOSE_ALL_MSG_PROCESS_ONE": "All terminals will be closed.
1 active process will be stopped.

Continue?", + "TERMINAL_CLOSE_ALL_MSG_PROCESS_MANY": "All terminals will be closed.
{0} active processes will be stopped.

Continue?", + "TERMINAL_CLOSE_ALL_STOP_BTN": "Close All & Stop Processes", "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", "EXTENDED_COMMIT_MESSAGE": "EXTENDED", "GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026", From 27fe3fd5c2a990e59424c7bda6f3be420e8b32f4 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 19:39:24 +0530 Subject: [PATCH 09/36] test(terminal): add integration tests for panel close confirmation Tests cover: single idle terminal closes without dialog, multiple terminals show close-all dialog, active process shows close-terminal dialog, stop-processes dialog variant, cancel preserves state, confirm disposes all terminals, and programmatic hide() keeps terminals alive. Also adds _writeToActiveTerminal test helper to terminal extension. --- src/extensionsIntegrated/Terminal/main.js | 15 + test/UnitTestSuite.js | 1 + test/spec/Terminal-integ-test.js | 404 ++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 test/spec/Terminal-integ-test.js diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index aefea72bc7..e61181b44b 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -673,4 +673,19 @@ define(function (require, exports, module) { // Export for testing exports.CMD_VIEW_TERMINAL = CMD_VIEW_TERMINAL; exports.CMD_NEW_TERMINAL = CMD_NEW_TERMINAL; + + /** + * Write data to the active terminal's PTY. Test-only helper. + * @param {string} data The text to send to the terminal. + * @return {Promise} + */ + exports._writeToActiveTerminal = function (data) { + const active = _getActiveTerminal(); + if (!active || !active.isAlive) { + return Promise.reject(new Error("No active terminal")); + } + return nodeConnector.execPeer("writeTerminal", { + id: active.id, data + }); + }; }); diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 31f88c9ab9..946fb6913d 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -140,6 +140,7 @@ define(function (require, exports, module) { require("spec/Extn-HTMLCodeHints-Lint-integ-test"); require("spec/Extn-HtmlTagSyncEdit-integ-test"); require("spec/Extn-Git-integ-test"); + require("spec/Terminal-integ-test"); // Node Tests require("spec/NodeConnection-test"); // pro test suite optional components diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js new file mode 100644 index 0000000000..77459e857b --- /dev/null +++ b/test/spec/Terminal-integ-test.js @@ -0,0 +1,404 @@ +/* + * 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. + * + */ + +/*global describe, it, expect, beforeAll, afterAll, awaitsFor, awaits */ + +define(function (require, exports, module) { + + if (!Phoenix.isNativeApp) { + return; + } + + const SpecRunnerUtils = require("spec/SpecRunnerUtils"); + const Strings = require("strings"); + + describe("integration:Terminal", function () { + let testWindow, + __PR, + WorkspaceManager, + testProjectPath; + + const PANEL_ID = "terminal-panel"; + + beforeAll(async function () { + testWindow = await SpecRunnerUtils.createTestWindowAndRun(); + __PR = testWindow.__PR; + WorkspaceManager = testWindow.brackets.test.WorkspaceManager; + + // Create a real temp directory so the terminal has a + // physical native path to use as cwd. + testProjectPath = await SpecRunnerUtils.getTempTestDirectory( + "/spec/JSUtils-test-files" + ); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterAll(async function () { + // Ensure terminal panel is closed before teardown + const panel = WorkspaceManager.getPanelForID(PANEL_ID); + if (panel && panel.isVisible()) { + panel.hide(); + } + testWindow = null; + __PR = null; + WorkspaceManager = null; + await SpecRunnerUtils.closeTestWindow(); + }, 30000); + + // --- Helpers --- + + async function openTerminal() { + await __PR.execCommand(__PR.Commands.VIEW_TERMINAL); + await awaitsFor(function () { + return testWindow.$("#terminal-panel").is(":visible"); + }, "terminal panel to be visible", 10000); + } + + function clickNewTerminal() { + testWindow.$(".terminal-flyout-new-btn").click(); + } + + function getTerminalCount() { + return testWindow.$(".terminal-flyout-item").length; + } + + function clickPanelCloseButton() { + testWindow.$( + '.bottom-panel-tab[data-panel-id="terminal-panel"]' + + ' .bottom-panel-tab-close-btn' + ).click(); + } + + function isDialogOpen() { + return testWindow.$(".modal.instance").length >= 1; + } + + function getDialogTitle() { + return testWindow.$( + ".modal.instance .dialog-title" + ).text(); + } + + function getDialogConfirmButtonText() { + return testWindow.$( + ".modal.instance .dialog-button.primary" + ).text(); + } + + /** + * Write data to the active terminal's PTY via the + * terminal extension's test helper. + */ + async function writeToTerminal(text) { + const termModule = testWindow.brackets.getModule( + "extensionsIntegrated/Terminal/main" + ); + await termModule._writeToActiveTerminal(text); + } + + /** + * Get the native platform path for the loaded project. + * Mirrors the same VFS→native conversion the terminal uses. + */ + function getNativeProjectPath() { + const Phoenix = testWindow.Phoenix; + const ProjectManager = + testWindow.brackets.test.ProjectManager; + const fullPath = + ProjectManager.getProjectRoot().fullPath; + const tauriPrefix = Phoenix.VFS.getTauriDir(); + let nativePath; + if (fullPath.startsWith(tauriPrefix)) { + nativePath = + Phoenix.fs.getTauriPlatformPath(fullPath); + } else { + nativePath = fullPath; + } + // Strip trailing slash (terminal does the same) + if (nativePath.length > 1 && + (nativePath.endsWith("/") || + nativePath.endsWith("\\"))) { + nativePath = nativePath.slice(0, -1); + } + return nativePath; + } + + // --- Tests --- + + describe("Panel basics", function () { + it("should open terminal in the current project directory", + async function () { + await openTerminal(); + expect(testWindow.$("#terminal-panel") + .is(":visible")).toBeTrue(); + expect(getTerminalCount()).toBe(1); + + // Wait for shell to start, then run `pwd`/`cd` + // to verify cwd. Use node for cross-platform. + await awaits(2000); + + // Use node to print cwd — works on all platforms + await writeToTerminal( + 'node -e "process.stdout.write(process.cwd())"\r' + ); + await awaits(1000); + + // The terminal title typically contains the cwd. + // Also verify via the flyout tooltip which holds + // the full terminal title. + const expectedPath = getNativeProjectPath(); + const flyoutTitle = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + + // The title format is "user@host: /path" — the + // path portion should end with our project dir. + // Extract last path component for a robust check. + const projectDirName = expectedPath.split("/").pop() + .split("\\").pop(); + expect(flyoutTitle).toContain(projectDirName); + }); + + it("should close single idle terminal without dialog", + async function () { + if (!testWindow.$("#terminal-panel") + .is(":visible")) { + await openTerminal(); + } + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "single terminal to exist", 5000); + + await awaits(1000); + + clickPanelCloseButton(); + + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + + expect(isDialogOpen()).toBeFalse(); + expect(getTerminalCount()).toBe(0); + }); + }); + + describe("Close confirmation with multiple terminals", + function () { + it("should show close-all dialog and cancel keeps panel", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "first terminal", 10000); + + clickNewTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 2; + }, "second terminal", 10000); + + await awaits(1000); + + clickPanelCloseButton(); + + await __PR.waitForModalDialog(); + + expect(getDialogTitle()) + .toBe(Strings.TERMINAL_CLOSE_ALL_TITLE); + expect(getDialogConfirmButtonText()) + .toBe(Strings.TERMINAL_CLOSE_ALL_BTN); + + // Cancel + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_CANCEL + ); + await __PR.waitForModalDialogClosed(); + + expect(testWindow.$("#terminal-panel") + .is(":visible")).toBeTrue(); + expect(getTerminalCount()).toBe(2); + }); + + it("should close all terminals when confirmed", + async function () { + // Still 2 terminals from previous test + expect(getTerminalCount()).toBe(2); + + clickPanelCloseButton(); + await __PR.waitForModalDialog(); + + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_OK + ); + await __PR.waitForModalDialogClosed(); + + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + + expect(getTerminalCount()).toBe(0); + }); + }); + + describe("Close confirmation with active process", function () { + it("should show close-terminal dialog for single terminal", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "terminal to be created", 10000); + + await awaits(2000); + + // Start a long-running node process + await writeToTerminal( + 'node -e "setTimeout(()=>{},60000)"\r' + ); + await awaits(2000); + + clickPanelCloseButton(); + + await __PR.waitForModalDialog(); + + + expect(getDialogTitle()) + .toBe(Strings.TERMINAL_CLOSE_SINGLE_TITLE); + expect(getDialogConfirmButtonText()) + .toBe(Strings.TERMINAL_CLOSE_SINGLE_BTN); + + // Cancel — terminal stays + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_CANCEL + ); + await __PR.waitForModalDialogClosed(); + + expect(testWindow.$("#terminal-panel") + .is(":visible")).toBeTrue(); + expect(getTerminalCount()).toBe(1); + + // Now confirm + clickPanelCloseButton(); + await __PR.waitForModalDialog(); + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_OK + ); + await __PR.waitForModalDialogClosed(); + + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "panel to close after confirm", 5000); + + expect(getTerminalCount()).toBe(0); + }); + + it("should show stop-processes dialog with multiple terminals", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "first terminal", 10000); + + clickNewTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 2; + }, "second terminal", 10000); + + await awaits(2000); + + await writeToTerminal( + 'node -e "setTimeout(()=>{},60000)"\r' + ); + await awaits(2000); + + clickPanelCloseButton(); + + await __PR.waitForModalDialog(); + + + expect(getDialogTitle()) + .toBe(Strings.TERMINAL_CLOSE_ALL_TITLE); + expect(getDialogConfirmButtonText()) + .toBe(Strings.TERMINAL_CLOSE_ALL_STOP_BTN); + + // Cancel + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_CANCEL + ); + await __PR.waitForModalDialogClosed(); + + expect(getTerminalCount()).toBe(2); + + // Confirm + clickPanelCloseButton(); + await __PR.waitForModalDialog(); + __PR.clickDialogButtonID( + __PR.Dialogs.DIALOG_BTN_OK + ); + await __PR.waitForModalDialogClosed(); + + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "panel to close after confirm", 5000); + + expect(getTerminalCount()).toBe(0); + }); + }); + + describe("Programmatic hide vs user close", function () { + it("should keep terminals alive after panel.hide()", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "terminal to be created", 10000); + + const panel = + WorkspaceManager.getPanelForID(PANEL_ID); + panel.hide(); + + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to hide", 5000); + + expect(isDialogOpen()).toBeFalse(); + + // Re-show — terminal should still exist + panel.show(); + await awaitsFor(function () { + return testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to show again", 5000); + + expect(getTerminalCount()).toBe(1); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + }); + }); +}); From 47227f8e4b90088fef66ff793a9a608dd20b6099 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 27 Feb 2026 19:50:19 +0530 Subject: [PATCH 10/36] fix(terminal): replace awaits() with awaitsFor() in integration tests Use condition-based polling instead of fixed timeouts to prevent flaky tests. Add waitForShellReady() and waitForActiveProcess() helpers that trigger flyout process refresh and poll the flyout title text. --- test/spec/Terminal-integ-test.js | 87 +++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 77459e857b..d868ac73af 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, awaitsFor, awaits */ +/*global describe, it, expect, beforeAll, afterAll, awaitsFor */ define(function (require, exports, module) { @@ -102,6 +102,47 @@ define(function (require, exports, module) { ).text(); } + /** + * Trigger a flyout process refresh so tab titles + * reflect the current foreground process. + */ + function triggerFlyoutRefresh() { + testWindow.$(".terminal-tab-flyout") + .trigger("mouseenter"); + } + + /** + * Wait for the active terminal's shell to initialize. + * The flyout title text changes from "Terminal" to the + * shell name (e.g. "bash") once process info is fetched. + */ + async function waitForShellReady() { + await awaitsFor(function () { + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active " + + ".terminal-flyout-title" + ).text(); + return title && title !== "Terminal"; + }, "shell to initialize", 10000); + } + + /** + * Wait for a child process (e.g. "node") to appear as + * the active terminal's foreground process in the flyout. + */ + async function waitForActiveProcess(processName) { + await awaitsFor(function () { + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active " + + ".terminal-flyout-title" + ).text(); + return title && title.indexOf(processName) !== -1; + }, processName + " process to appear in flyout", + 15000); + } + /** * Write data to the active terminal's PTY via the * terminal extension's test helper. @@ -150,29 +191,25 @@ define(function (require, exports, module) { .is(":visible")).toBeTrue(); expect(getTerminalCount()).toBe(1); - // Wait for shell to start, then run `pwd`/`cd` - // to verify cwd. Use node for cross-platform. - await awaits(2000); + // The shell sets its title to include the cwd + // (e.g. "user@host: /path/to/project"). + // Wait for the flyout tooltip to contain the + // project directory name. + const expectedPath = getNativeProjectPath(); + const projectDirName = expectedPath + .split("/").pop().split("\\").pop(); - // Use node to print cwd — works on all platforms - await writeToTerminal( - 'node -e "process.stdout.write(process.cwd())"\r' - ); - await awaits(1000); + await awaitsFor(function () { + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + return title.indexOf(projectDirName) !== -1; + }, "terminal title to contain project dir", + 10000); - // The terminal title typically contains the cwd. - // Also verify via the flyout tooltip which holds - // the full terminal title. - const expectedPath = getNativeProjectPath(); const flyoutTitle = testWindow.$( ".terminal-flyout-item.active" ).attr("title") || ""; - - // The title format is "user@host: /path" — the - // path portion should end with our project dir. - // Extract last path component for a robust check. - const projectDirName = expectedPath.split("/").pop() - .split("\\").pop(); expect(flyoutTitle).toContain(projectDirName); }); @@ -186,7 +223,7 @@ define(function (require, exports, module) { return getTerminalCount() === 1; }, "single terminal to exist", 5000); - await awaits(1000); + await waitForShellReady(); clickPanelCloseButton(); @@ -214,7 +251,7 @@ define(function (require, exports, module) { return getTerminalCount() === 2; }, "second terminal", 10000); - await awaits(1000); + await waitForShellReady(); clickPanelCloseButton(); @@ -266,13 +303,13 @@ define(function (require, exports, module) { return getTerminalCount() === 1; }, "terminal to be created", 10000); - await awaits(2000); + await waitForShellReady(); // Start a long-running node process await writeToTerminal( 'node -e "setTimeout(()=>{},60000)"\r' ); - await awaits(2000); + await waitForActiveProcess("node"); clickPanelCloseButton(); @@ -322,12 +359,12 @@ define(function (require, exports, module) { return getTerminalCount() === 2; }, "second terminal", 10000); - await awaits(2000); + await waitForShellReady(); await writeToTerminal( 'node -e "setTimeout(()=>{},60000)"\r' ); - await awaits(2000); + await waitForActiveProcess("node"); clickPanelCloseButton(); From 51ac41103a155bbcfc08c03b2810003b4bbfa97e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 00:47:00 +0530 Subject: [PATCH 11/36] fix: terminal integ tests working in windows --- docs/API-Reference/command/Commands.md | 6 ++ docs/API-Reference/view/PanelView.md | 92 +++++++++++++++----- docs/API-Reference/view/PluginPanelView.md | 23 ++++- docs/API-Reference/widgets/NotificationUI.md | 83 +++++------------- src-node/terminal.js | 38 ++++++++ src/extensionsIntegrated/Terminal/main.js | 54 +++++++++--- src/view/WorkspaceManager.js | 1 + test/spec/Terminal-integ-test.js | 91 +++++++++++++++---- 8 files changed, 267 insertions(+), 121 deletions(-) diff --git a/docs/API-Reference/command/Commands.md b/docs/API-Reference/command/Commands.md index 36c87ac56c..da355f0cf5 100644 --- a/docs/API-Reference/command/Commands.md +++ b/docs/API-Reference/command/Commands.md @@ -530,6 +530,12 @@ Toggles code inspection ## VIEW\_TOGGLE\_PROBLEMS Toggles problems panel visibility +**Kind**: global variable + + +## VIEW\_TERMINAL +Opens the terminal panel + **Kind**: global variable diff --git a/docs/API-Reference/view/PanelView.md b/docs/API-Reference/view/PanelView.md index 1a7cce00be..cd9055fc02 100644 --- a/docs/API-Reference/view/PanelView.md +++ b/docs/API-Reference/view/PanelView.md @@ -14,8 +14,11 @@ const PanelView = brackets.getModule("view/PanelView") * [.isVisible()](#Panel+isVisible) ⇒ boolean * [.registerCanBeShownHandler(canShowHandlerFn)](#Panel+registerCanBeShownHandler) ⇒ boolean * [.canBeShown()](#Panel+canBeShown) ⇒ boolean + * [.registerOnCloseRequestedHandler(handler)](#Panel+registerOnCloseRequestedHandler) + * [.requestClose()](#Panel+requestClose) ⇒ Promise.<boolean> * [.show()](#Panel+show) * [.hide()](#Panel+hide) + * [.focus()](#Panel+focus) ⇒ boolean * [.setVisible(visible)](#Panel+setVisible) * [.setTitle(newTitle)](#Panel+setTitle) * [.destroy()](#Panel+destroy) @@ -49,8 +52,7 @@ Determines if the panel is visible ### panel.registerCanBeShownHandler(canShowHandlerFn) ⇒ boolean -Registers a call back function that will be called just before panel is shown. The handler should return true -if the panel can be shown, else return false and the panel will not be shown. +Registers a call back function that will be called just before panel is shown. The handler should return true if the panel can be shown, else return false and the panel will not be shown. **Kind**: instance method of [Panel](#Panel) **Returns**: boolean - true if visible, false if not @@ -65,6 +67,24 @@ if the panel can be shown, else return false and the panel will not be shown. Returns true if th panel can be shown, else false. **Kind**: instance method of [Panel](#Panel) + + +### panel.registerOnCloseRequestedHandler(handler) +Registers an async handler that is called before the panel is closed via user interaction (e.g. clicking the tab close button). The handler should return `true` to allow the close, or `false` to prevent it. + +**Kind**: instance method of [Panel](#Panel) + +| Param | Type | Description | +| --- | --- | --- | +| handler | function \| null | An async function returning a boolean, or null to clear the handler. | + + + +### panel.requestClose() ⇒ Promise.<boolean> +Requests the panel to hide, invoking the registered onCloseRequested handler first (if any). If the handler returns false, the panel stays open. If it returns true or no handler is registered, `hide()` is called. + +**Kind**: instance method of [Panel](#Panel) +**Returns**: Promise.<boolean> - Resolves to true if the panel was hidden, false if prevented. ### panel.show() @@ -77,6 +97,13 @@ Shows the panel Hides the panel **Kind**: instance method of [Panel](#Panel) + + +### panel.focus() ⇒ boolean +Attempts to focus the panel. Override this in panels that support focus (e.g. terminal). The default implementation returns false. + +**Kind**: instance method of [Panel](#Panel) +**Returns**: boolean - true if the panel accepted focus, false otherwise ### panel.setVisible(visible) @@ -102,8 +129,7 @@ Updates the display title shown in the tab bar for this panel. ### panel.destroy() -Destroys the panel, removing it from the tab bar, internal maps, and the DOM. -After calling this, the Panel instance should not be reused. +Destroys the panel, removing it from the tab bar, internal maps, and the DOM. After calling this, the Panel instance should not be reused. **Kind**: instance method of [Panel](#Panel) @@ -171,6 +197,18 @@ The editor holder element, passed from WorkspaceManager ## \_recomputeLayout : function recomputeLayout callback from WorkspaceManager +**Kind**: global variable + + +## \_defaultPanelId : string \| null +The default/quick-access panel ID + +**Kind**: global variable + + +## \_$addBtn : jQueryObject +The "+" button inside the tab overflow area + **Kind**: global variable @@ -193,24 +231,25 @@ type for bottom panel ## MAXIMIZE\_THRESHOLD : number -Pixel threshold for detecting near-maximize state during resize. -If the editor holder height is within this many pixels of zero, the -panel is treated as maximized. Keeps the maximize icon responsive -during drag without being overly sensitive. +Pixel threshold for detecting near-maximize state during resize. If the editor holder height is within this many pixels of zero, the panel is treated as maximized. Keeps the maximize icon responsive during drag without being overly sensitive. **Kind**: global constant ## MIN\_PANEL\_HEIGHT : number -Minimum panel height (matches Resizer minSize) used as a floor -when computing a sensible restore height. +Minimum panel height (matches Resizer minSize) used as a floor when computing a sensible restore height. + +**Kind**: global constant + + +## PREF\_BOTTOM\_PANEL\_MAXIMIZED +Preference key for persisting the maximize state across reloads. **Kind**: global constant -## init($container, $tabBar, $tabsOverflow, $editorHolder, recomputeLayoutFn) -Initializes the PanelView module with references to the bottom panel container DOM elements. -Called by WorkspaceManager during htmlReady. +## init($container, $tabBar, $tabsOverflow, $editorHolder, recomputeLayoutFn, defaultPanelId) +Initializes the PanelView module with references to the bottom panel container DOM elements. Called by WorkspaceManager during htmlReady. **Kind**: global function @@ -221,30 +260,24 @@ Called by WorkspaceManager during htmlReady. | $tabsOverflow | jQueryObject | The scrollable area holding tab elements. | | $editorHolder | jQueryObject | The editor holder element (for maximize height calculation). | | recomputeLayoutFn | function | Callback to trigger workspace layout recomputation. | +| defaultPanelId | string | The ID of the default/quick-access panel. | ## exitMaximizeOnResize() -Exit maximize state without resizing (for external callers like drag-resize). -Clears internal maximize state and resets the button icon. +Exit maximize state without resizing (for external callers like drag-resize). Clears internal maximize state and resets the button icon. **Kind**: global function ## enterMaximizeOnResize() -Enter maximize state during a drag-resize that reaches the maximum -height. No pre-maximize height is stored because the user arrived -here via continuous dragging; a sensible default will be computed if -they later click the Restore button. +Enter maximize state during a drag-resize that reaches the maximum height. No pre-maximize height is stored because the user arrived here via continuous dragging; a sensible default will be computed if they later click the Restore button. **Kind**: global function ## restoreIfMaximized() -Restore the container's CSS height to the pre-maximize value and clear maximize state. -Must be called BEFORE Resizer.hide() so the Resizer reads the correct height. -If not maximized, this is a no-op. -When the saved height is near-max or unknown, a sensible default is used. +Restore the container's CSS height to the pre-maximize value and clear maximize state. Must be called BEFORE Resizer.hide() so the Resizer reads the correct height. If not maximized, this is a no-op. When the saved height is near-max or unknown, a sensible default is used. **Kind**: global function @@ -266,3 +299,16 @@ Hides every open bottom panel tab in a single batch **Kind**: global function **Returns**: Array.<string> - The IDs of panels that were open (useful for restoring later). + + +## getActiveBottomPanel() ⇒ [Panel](#Panel) \| null +Returns the currently active (visible) bottom panel, or null if none. + +**Kind**: global function + + +## showNextPanel() ⇒ boolean +Cycle to the next open bottom panel tab. If the container is hidden or no panels are open, does nothing and returns false. + +**Kind**: global function +**Returns**: boolean - true if a panel switch occurred diff --git a/docs/API-Reference/view/PluginPanelView.md b/docs/API-Reference/view/PluginPanelView.md index 43f609b9d7..a3e0401543 100644 --- a/docs/API-Reference/view/PluginPanelView.md +++ b/docs/API-Reference/view/PluginPanelView.md @@ -14,6 +14,8 @@ const PluginPanelView = brackets.getModule("view/PluginPanelView") * [.isVisible()](#Panel+isVisible) ⇒ boolean * [.registerCanBeShownHandler(canShowHandlerFn)](#Panel+registerCanBeShownHandler) ⇒ boolean * [.canBeShown()](#Panel+canBeShown) ⇒ boolean + * [.registerOnCloseRequestedHandler(handler)](#Panel+registerOnCloseRequestedHandler) + * [.requestClose()](#Panel+requestClose) ⇒ Promise.<boolean> * [.show()](#Panel+show) * [.hide()](#Panel+hide) * [.setVisible(visible)](#Panel+setVisible) @@ -49,8 +51,7 @@ Determines if the panel is visible ### panel.registerCanBeShownHandler(canShowHandlerFn) ⇒ boolean -Registers a call back function that will be called just before panel is shown. The handler should return true -if the panel can be shown, else return false and the panel will not be shown. +Registers a call back function that will be called just before panel is shown. The handler should return true if the panel can be shown, else return false and the panel will not be shown. **Kind**: instance method of [Panel](#Panel) **Returns**: boolean - true if visible, false if not @@ -65,6 +66,24 @@ if the panel can be shown, else return false and the panel will not be shown. Returns true if th panel can be shown, else false. **Kind**: instance method of [Panel](#Panel) + + +### panel.registerOnCloseRequestedHandler(handler) +Registers an async handler that is called before the panel is closed via user interaction. The handler should return `true` to allow the close, or `false` to prevent it. + +**Kind**: instance method of [Panel](#Panel) + +| Param | Type | Description | +| --- | --- | --- | +| handler | function \| null | An async function returning a boolean, or null to clear the handler. | + + + +### panel.requestClose() ⇒ Promise.<boolean> +Requests the panel to hide, invoking the registered onCloseRequested handler first (if any). If the handler returns false, the panel stays open. If it returns true or no handler is registered, `hide()` is called. + +**Kind**: instance method of [Panel](#Panel) +**Returns**: Promise.<boolean> - Resolves to true if the panel was hidden, false if prevented. ### panel.show() diff --git a/docs/API-Reference/widgets/NotificationUI.md b/docs/API-Reference/widgets/NotificationUI.md index 3f3030bff0..13bbe3631e 100644 --- a/docs/API-Reference/widgets/NotificationUI.md +++ b/docs/API-Reference/widgets/NotificationUI.md @@ -6,43 +6,12 @@ const NotificationUI = brackets.getModule("widgets/NotificationUI") ## widgets/NotificationUI -The global NotificationUI can be used to create popup notifications over dom elements or generics app notifications. - -A global `window.EventManager` object is made available in phoenix that can be called anytime after AppStart. -This global can be triggered from anywhere without using require context. - -## Usage -### Simple example -For Eg. Let's say we have to create a popup notification over the HTML element with ID `showInfileTree`. -We can do this with the following +The global NotificationUI can be used to create popup notifications over dom elements or generics app notifications. A global `window.EventManager` object is made available in phoenix that can be called anytime after AppStart. This global can be triggered from anywhere without using require context. ## Usage ### Simple example For Eg. Let's say we have to create a popup notification over the HTML element with ID `showInfileTree`. We can do this with the following **Example** -```js -const NotificationUI = brackets.getModule("widgets/NotificationUI"); -// or use window.NotificationUI global object has the same effect. -let notification = NotificationUI.createFromTemplate("Click me to locate the file in file tree", "showInfileTree",{}); -notification.done(()=>{ - console.log("notification is closed in ui."); -}) -``` -### Advanced example -Another advanced example where you can specify html and interactive components in the notification +```js const NotificationUI = brackets.getModule("widgets/NotificationUI"); // or use window.NotificationUI global object has the same effect. let notification = NotificationUI.createFromTemplate("Click me to locate the file in file tree", "showInfileTree",{}); notification.done(()=>{ console.log("notification is closed in ui."); }) ``` ### Advanced example Another advanced example where you can specify html and interactive components in the notification **Example** -```js -// note that you can even provide an HTML Element node with -// custom event handlers directly here instead of HTML text. -let notification1 = NotificationUI.createFromTemplate( - "
Click me to locate the file in file tree
", "showInfileTree",{ - allowedPlacements: ['top', 'bottom'], - dismissOnClick: false, - autoCloseTimeS: 300 // auto close the popup after 5 minutes - }); -// do stuff -notification1.done((closeReason)=>{ - console.log("notification is closed in ui reason:", closeReason); -}) -``` -The `createFromTemplate` API can be configured with numerous options. See API options below. +```js // note that you can even provide an HTML Element node with // custom event handlers directly here instead of HTML text. let notification1 = NotificationUI.createFromTemplate( "
Click me to locate the file in file tree
", "showInfileTree",{ allowedPlacements: ['top', 'bottom'], dismissOnClick: false, autoCloseTimeS: 300 // auto close the popup after 5 minutes }); // do stuff notification1.done((closeReason)=>{ console.log("notification is closed in ui reason:", closeReason); }) ``` The `createFromTemplate` API can be configured with numerous options. See API options below. * [widgets/NotificationUI](#module_widgets/NotificationUI) * [.API](#module_widgets/NotificationUI..API) @@ -50,6 +19,7 @@ The `createFromTemplate` API can be configured with numerous options. See API op * [.CLOSE_REASON](#module_widgets/NotificationUI..CLOSE_REASON) : enum * [.createFromTemplate(title, template, [elementID], [options])](#module_widgets/NotificationUI..createFromTemplate) ⇒ Notification * [.createToastFromTemplate(title, template, [options])](#module_widgets/NotificationUI..createToastFromTemplate) ⇒ Notification + * [.showToastOn(containerOrSelector, template, [options])](#module_widgets/NotificationUI..showToastOn) ⇒ Notification @@ -90,21 +60,7 @@ Closing notification reason. ### widgets/NotificationUI.createFromTemplate(title, template, [elementID], [options]) ⇒ Notification -Creates a new notification popup from given template. -The template can either be a string or a jQuery object representing a DOM node that is *not* in the current DOM. - -Creating a notification popup - -```js -// note that you can even provide an HTML Element node with -// custom event handlers directly here instead of HTML text. -let notification1 = NotificationUI.createFromTemplate( - "
Click me to locate the file in file tree
", "showInfileTree",{ - allowedPlacements: ['top', 'bottom'], - dismissOnClick: false, - autoCloseTimeS: 300 // auto close the popup after 5 minutes - }); -``` +Creates a new notification popup from given template. The template can either be a string or a jQuery object representing a DOM node that is *not* in the current DOM. Creating a notification popup ```js // note that you can even provide an HTML Element node with // custom event handlers directly here instead of HTML text. let notification1 = NotificationUI.createFromTemplate( "
Click me to locate the file in file tree
", "showInfileTree",{ allowedPlacements: ['top', 'bottom'], dismissOnClick: false, autoCloseTimeS: 300 // auto close the popup after 5 minutes }); ``` **Kind**: inner method of [widgets/NotificationUI](#module_widgets/NotificationUI) **Returns**: Notification - Object with a done handler that resolves when the notification closes. @@ -119,20 +75,7 @@ let notification1 = NotificationUI.createFromTemplate( ### widgets/NotificationUI.createToastFromTemplate(title, template, [options]) ⇒ Notification -Creates a new toast notification popup from given title and html message. -The message can either be a string or a jQuery object representing a DOM node that is *not* in the current DOM. - -Creating a toast notification popup - -```js -// note that you can even provide an HTML Element node with -// custom event handlers directly here instead of HTML text. -let notification1 = NotificationUI.createToastFromTemplate( "Title here", - "
Click me to locate the file in file tree
", { - dismissOnClick: false, - autoCloseTimeS: 300 // auto close the popup after 5 minutes - }); -``` +Creates a new toast notification popup from given title and html message. The message can either be a string or a jQuery object representing a DOM node that is *not* in the current DOM. Creating a toast notification popup ```js // note that you can even provide an HTML Element node with // custom event handlers directly here instead of HTML text. let notification1 = NotificationUI.createToastFromTemplate( "Title here", "
Click me to locate the file in file tree
", { dismissOnClick: false, autoCloseTimeS: 300 // auto close the popup after 5 minutes }); ``` **Kind**: inner method of [widgets/NotificationUI](#module_widgets/NotificationUI) **Returns**: Notification - Object with a done handler that resolves when the notification closes. @@ -143,3 +86,17 @@ let notification1 = NotificationUI.createToastFromTemplate( "Title here", | template | string \| Element | A string template or HTML Element to use as the dialog HTML. | | [options] | Object | optional, supported * options are: * `autoCloseTimeS` - Time in seconds after which the notification should be auto closed. Default is never. * `dismissOnClick` - when clicked, the notification is closed. Default is true(dismiss). * `toastStyle` - To style the toast notification for error, warning, info etc. Can be one of `NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.*` or your own css class name. * `instantOpen` - To instantly open the popup without any open animation delays | + + +### widgets/NotificationUI.showToastOn(containerOrSelector, template, [options]) ⇒ Notification +Shows a small, transient inline toast notification inside a given DOM container. The toast is centered at the bottom of the container and auto-dismisses. ```js NotificationUI.showToastOn(document.getElementById("my-panel"), "Hello!", { autoCloseTimeS: 5, dismissOnClick: true }); ``` + +**Kind**: inner method of [widgets/NotificationUI](#module_widgets/NotificationUI) +**Returns**: Notification - Object with a done handler that resolves when the toast closes. + +| Param | Type | Description | +| --- | --- | --- | +| containerOrSelector | Element \| string | A DOM element or CSS selector for the parent container. The container should have `position: relative` or `absolute` so the toast is positioned correctly. | +| template | string \| Element | HTML string or DOM Element for the toast content. | +| [options] | Object | optional, supported options: * `autoCloseTimeS` - Time in seconds after which the toast auto-closes. Default is 5. * `dismissOnClick` - If true, clicking the toast dismisses it. Default is true. | + diff --git a/src-node/terminal.js b/src-node/terminal.js index 40ec651cd0..6002d975c2 100644 --- a/src-node/terminal.js +++ b/src-node/terminal.js @@ -221,6 +221,7 @@ exports.killTerminal = async function ({id}) { if (process.platform === "win32") { // On Windows, use taskkill for process tree kill const {execSync} = require("child_process"); + delete _processCache[term.pty.pid]; try { execSync(`taskkill /pid ${term.pty.pid} /T /F`, {stdio: "ignore"}); } catch (e) { @@ -343,15 +344,52 @@ exports.getDefaultShells = async function () { return {shells}; }; +// Cache for Windows process lookups: pid -> {name, timestamp, pending} +// Avoids spawning hundreds of PowerShell processes when the UI polls rapidly. +const _processCache = {}; +const PROCESS_CACHE_TTL = 2000; // 2 seconds + /** * 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. + * + * Results are cached for PROCESS_CACHE_TTL ms per PID so that rapid polling + * (e.g. from the flyout hover handler) does not spawn a new PowerShell process + * on every call. * @param {number} pid - The shell PID to look up children for * @returns {Promise} The leaf child process name, or empty string */ function _getWindowsForegroundProcess(pid) { + const now = Date.now(); + const cached = _processCache[pid]; + if (cached) { + // Return cached result if still fresh + if (cached.timestamp && (now - cached.timestamp) < PROCESS_CACHE_TTL) { + return Promise.resolve(cached.name); + } + // If a query is already in flight, piggyback on it + if (cached.pending) { + return cached.pending; + } + } + + const pending = _getWindowsForegroundProcessUncached(pid).then(function (name) { + _processCache[pid] = {name, timestamp: Date.now(), pending: null}; + return name; + }, function () { + _processCache[pid] = {name: "", timestamp: Date.now(), pending: null}; + return ""; + }); + _processCache[pid] = {name: cached ? cached.name : "", timestamp: 0, pending}; + return pending; +} + +/** + * Uncached implementation: spawns PowerShell to query child processes. + */ +function _getWindowsForegroundProcessUncached(pid) { return new Promise((resolve) => { const psCommand = `Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}'` + ` | Select-Object Name,ProcessId | ConvertTo-Json -Compress`; diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index e61181b44b..8094f6821f 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -564,7 +564,8 @@ define(function (require, exports, module) { } /** - * Clean up all terminals (on app quit) + * Clean up all terminals (on app quit). + * Fire-and-forget — PTY kills are not awaited. */ function _disposeAll() { for (const inst of terminalInstances) { @@ -574,6 +575,22 @@ define(function (require, exports, module) { processInfo = {}; } + /** + * Async version: awaits all PTY kill commands so the + * caller can be sure the kill signals have been sent + * and acknowledged by the Node side. + */ + async function _disposeAllAsync() { + const killPromises = terminalInstances + .filter(function (inst) { return inst.isAlive && !inst._disposed; }) + .map(function (inst) { + return nodeConnector.execPeer("killTerminal", {id: inst.id}) + .catch(function () {}); + }); + _disposeAll(); + await Promise.all(killPromises); + } + // Register commands CommandManager.register("New Terminal", CMD_NEW_TERMINAL, _createNewTerminal); CommandManager.register(Strings.CMD_VIEW_TERMINAL, CMD_VIEW_TERMINAL, _showTerminal); @@ -591,18 +608,16 @@ define(function (require, exports, module) { // 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 inst of terminalInstances) { - if (!inst.isAlive) { - continue; - } - try { - const result = await nodeConnector.execPeer("getTerminalProcess", {id: inst.id}); - if (result.process && !_isShellProcess(result.process)) { - activeProcesses.push(result.process); - } - } catch (e) { - // terminal may be dead + for (const result of results) { + if (result.process && !_isShellProcess(result.process)) { + activeProcesses.push(result.process); } } @@ -629,7 +644,7 @@ define(function (require, exports, module) { confirmText = Strings.TERMINAL_CLOSE_ALL_STOP_BTN; } else { // Single idle terminal — no confirmation needed - _disposeAll(); + await _disposeAllAsync(); activeTerminalId = null; _updateFlyout(); return true; @@ -648,7 +663,7 @@ define(function (require, exports, module) { } // User confirmed — dispose everything - _disposeAll(); + await _disposeAllAsync(); activeTerminalId = null; _updateFlyout(); return true; @@ -688,4 +703,15 @@ define(function (require, exports, module) { id: active.id, data }); }; + + /** + * Dispose all terminal instances. Test-only helper. + * Awaits all PTY kill commands so the caller can be + * sure processes have been signalled before the test + * window is torn down. + */ + exports._disposeAll = async function () { + await _disposeAllAsync(); + activeTerminalId = null; + }; }); diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index 745e9af2a5..8888c9f22e 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -648,6 +648,7 @@ define(function (require, exports, module) { /** * Toggle the bottom panel container: hide if visible, show if there are * open panels, or show the default panel when nothing is open. + * @private * @return {boolean} true if the toggle was handled */ function _togglePanels() { diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index d868ac73af..7f965eaed5 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -29,6 +29,8 @@ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"); const Strings = require("strings"); + const IS_WINDOWS = Phoenix.platform === "win"; + describe("integration:Terminal", function () { let testWindow, __PR, @@ -51,8 +53,48 @@ define(function (require, exports, module) { }, 30000); afterAll(async function () { - // Ensure terminal panel is closed before teardown - const panel = WorkspaceManager.getPanelForID(PANEL_ID); + // Dispose all terminal PTY processes before teardown. + // panel.hide() keeps terminals alive by design, so we + // must explicitly kill them. On Windows, a shell whose + // cwd is the temp directory locks it and prevents + // cleanup by the next test run. + if (testWindow) { + try { + const termModule = testWindow.brackets.getModule( + "extensionsIntegrated/Terminal/main" + ); + if (termModule && termModule._disposeAll) { + // _disposeAll is async — awaits all kill + // commands so PTYs are signalled before + // the test window is torn down. + await termModule._disposeAll(); + } + // Wait for terminals to fully exit. On Windows, + // taskkill is async and the shell process may + // hold the cwd lock for several seconds after + // the kill signal is sent. + if (IS_WINDOWS) { + await awaitsFor(function () { + return testWindow.$( + ".terminal-flyout-item" + ).length === 0; + }, "terminals to be disposed", 5000); + // taskkill returns before the process fully + // exits — the directory lock can persist for + // a few more seconds. Wait for the OS to + // release the lock so the next test run's + // getTempTestDirectory can clean up. + await new Promise(function (resolve) { + setTimeout(resolve, 5000); + }); + } + } catch (e) { + // test window may already be torn down + } + } + const panel = WorkspaceManager + ? WorkspaceManager.getPanelForID(PANEL_ID) + : null; if (panel && panel.isVisible()) { panel.hide(); } @@ -191,26 +233,37 @@ define(function (require, exports, module) { .is(":visible")).toBeTrue(); expect(getTerminalCount()).toBe(1); - // The shell sets its title to include the cwd - // (e.g. "user@host: /path/to/project"). - // Wait for the flyout tooltip to contain the - // project directory name. - const expectedPath = getNativeProjectPath(); - const projectDirName = expectedPath - .split("/").pop().split("\\").pop(); + if (IS_WINDOWS) { + // On Windows, PowerShell/cmd set the title to + // their own executable path rather than + // "user@host: /path/to/cwd". Verify the shell + // started and updated its title from the default + // profile name. + await waitForShellReady(); + const flyoutTitle = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + expect(flyoutTitle).not.toBe(""); + } else { + // On Linux/macOS, bash/zsh set the title to + // include the cwd (e.g. "user@host: /path"). + const expectedPath = getNativeProjectPath(); + const projectDirName = expectedPath + .split("/").pop().split("\\").pop(); - await awaitsFor(function () { - const title = testWindow.$( + await awaitsFor(function () { + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + return title.indexOf(projectDirName) !== -1; + }, "terminal title to contain project dir", + 10000); + + const flyoutTitle = testWindow.$( ".terminal-flyout-item.active" ).attr("title") || ""; - return title.indexOf(projectDirName) !== -1; - }, "terminal title to contain project dir", - 10000); - - const flyoutTitle = testWindow.$( - ".terminal-flyout-item.active" - ).attr("title") || ""; - expect(flyoutTitle).toContain(projectDirName); + expect(flyoutTitle).toContain(projectDirName); + } }); it("should close single idle terminal without dialog", From a7721af71ca4ef0be566e81cd41f3e06fadfb5af Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 01:36:48 +0530 Subject: [PATCH 12/36] fix(test): terminate test window phnode on test runner reload Add a beforeunload handler in SpecRunnerUtils that calls terminateNode() on the test iframe's Node engine when the test runner page reloads. This prevents orphaned phnode.exe processes from holding directory locks on Windows, which caused EBUSY errors in consecutive integration test runs. Also simplifies Terminal-integ-test afterAll by removing the Windows-specific 5s delay that was a workaround for the same issue. --- test/spec/SpecRunnerUtils.js | 15 +++++++++++++++ test/spec/Terminal-integ-test.js | 26 +------------------------- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/test/spec/SpecRunnerUtils.js b/test/spec/SpecRunnerUtils.js index 871d3938af..1ec95272cf 100644 --- a/test/spec/SpecRunnerUtils.js +++ b/test/spec/SpecRunnerUtils.js @@ -54,6 +54,21 @@ define(function (require, exports, module) { MainViewManager._initialize($("#mock-main-view")); + // When the test runner page reloads (e.g. switching test + // categories), terminate the test window's Node engine so + // its phnode.exe process and children (ESLint runners, + // terminal shells) don't become orphans that hold directory + // locks on Windows. + window.addEventListener("beforeunload", function () { + if (_testWindow && _testWindow.PhNodeEngine) { + try { + _testWindow.PhNodeEngine.terminateNode(); + } catch (e) { + // ignore — test window may already be torn down + } + } + }); + function _getFileSystem() { return FileSystem; } diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 7f965eaed5..4c0dd23843 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -55,39 +55,15 @@ define(function (require, exports, module) { afterAll(async function () { // Dispose all terminal PTY processes before teardown. // panel.hide() keeps terminals alive by design, so we - // must explicitly kill them. On Windows, a shell whose - // cwd is the temp directory locks it and prevents - // cleanup by the next test run. + // must explicitly kill them. if (testWindow) { try { const termModule = testWindow.brackets.getModule( "extensionsIntegrated/Terminal/main" ); if (termModule && termModule._disposeAll) { - // _disposeAll is async — awaits all kill - // commands so PTYs are signalled before - // the test window is torn down. await termModule._disposeAll(); } - // Wait for terminals to fully exit. On Windows, - // taskkill is async and the shell process may - // hold the cwd lock for several seconds after - // the kill signal is sent. - if (IS_WINDOWS) { - await awaitsFor(function () { - return testWindow.$( - ".terminal-flyout-item" - ).length === 0; - }, "terminals to be disposed", 5000); - // taskkill returns before the process fully - // exits — the directory lock can persist for - // a few more seconds. Wait for the OS to - // release the lock so the next test run's - // getTempTestDirectory can clean up. - await new Promise(function (resolve) { - setTimeout(resolve, 5000); - }); - } } catch (e) { // test window may already be torn down } From 09055c3838e9b015a32ba90abb2e3792060a84bb Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 01:50:35 +0530 Subject: [PATCH 13/36] fix(test): terminate SpecRunner's own PhNode on page reload The beforeunload handler only terminated the test iframe's PhNode but not the SpecRunner's own process, leaving orphaned phnode processes after every test runner reload. --- test/spec/SpecRunnerUtils.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/spec/SpecRunnerUtils.js b/test/spec/SpecRunnerUtils.js index 1ec95272cf..beef9a2aaa 100644 --- a/test/spec/SpecRunnerUtils.js +++ b/test/spec/SpecRunnerUtils.js @@ -67,6 +67,15 @@ define(function (require, exports, module) { // ignore — test window may already be torn down } } + // Also terminate the SpecRunner's own PhNode process so it + // doesn't become an orphan on page reload. + if (window.PhNodeEngine) { + try { + window.PhNodeEngine.terminateNode(); + } catch (e) { + // ignore + } + } }); function _getFileSystem() { From b51de0b3f51dd3890103d030120d8f8ffdbdf790 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 10:41:30 +0530 Subject: [PATCH 14/36] fix(terminal): reset stale title when child process exits When a child process (e.g. claude) sets a custom terminal title via escape sequences and then exits, shells like zsh on macOS do not emit a title reset. The flyout tab was stuck showing the old title. Reset inst.title to the shell profile name when the foreground process returns to the shell. Also fix _isShellProcess to handle login-shell prefixes like "-zsh", and skip the cwd-in-title check on macOS where zsh does not set it. --- src/extensionsIntegrated/Terminal/main.js | 14 ++- test/spec/Terminal-integ-test.js | 123 ++++++++++++++++++++-- 2 files changed, 130 insertions(+), 7 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 8094f6821f..1022fcc705 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -72,7 +72,8 @@ define(function (require, exports, module) { if (!processName) { return true; } - const basename = processName.split("/").pop().split("\\").pop(); + // Strip path and leading "-" for login shells (e.g. "-zsh") + const basename = processName.split("/").pop().split("\\").pop().replace(/^-/, ""); return SHELL_NAMES.has(basename); } @@ -409,6 +410,17 @@ define(function (require, exports, module) { const newProc = result.process || ""; if (processInfo[id] !== newProc) { processInfo[id] = newProc; + // When a child process (e.g. "claude") exits and the + // shell regains foreground, the child may have set a + // custom terminal title via escape sequences. Shells + // like zsh on macOS do not emit a title reset, so + // inst.title stays stale. Reset it only when the + // foreground process returns to the shell. If the + // shell does emit a title change, onTitleChange will + // overwrite this immediately. + if (_isShellProcess(newProc)) { + instance.title = instance.shellProfile.name; + } _updateFlyout(); } }).catch(function () { diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 4c0dd23843..bea5039cd2 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -30,6 +30,7 @@ define(function (require, exports, module) { const Strings = require("strings"); const IS_WINDOWS = Phoenix.platform === "win"; + const IS_MAC = Phoenix.platform === "mac"; describe("integration:Terminal", function () { let testWindow, @@ -209,19 +210,19 @@ define(function (require, exports, module) { .is(":visible")).toBeTrue(); expect(getTerminalCount()).toBe(1); - if (IS_WINDOWS) { + if (IS_WINDOWS || IS_MAC) { // On Windows, PowerShell/cmd set the title to - // their own executable path rather than - // "user@host: /path/to/cwd". Verify the shell - // started and updated its title from the default - // profile name. + // their own executable path. On macOS, zsh does + // not emit title escape sequences by default. + // Just verify the shell started and updated its + // title from the default profile name. await waitForShellReady(); const flyoutTitle = testWindow.$( ".terminal-flyout-item.active" ).attr("title") || ""; expect(flyoutTitle).not.toBe(""); } else { - // On Linux/macOS, bash/zsh set the title to + // On Linux, bash/zsh set the title to // include the cwd (e.g. "user@host: /path"). const expectedPath = getNativeProjectPath(); const projectDirName = expectedPath @@ -430,6 +431,116 @@ define(function (require, exports, module) { }); }); + describe("Terminal title management", function () { + it("should retain custom title while child process runs", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "terminal to be created", 10000); + + await waitForShellReady(); + + // Start a long-running node process that sets a + // custom terminal title via escape sequence. + await writeToTerminal( + 'node -e "process.stdout.write(' + + "'\\x1b]0;MyAppTitle\\x07'" + + ');setTimeout(()=>{},60000)"\r' + ); + await waitForActiveProcess("node"); + + // Verify the custom title appears + await awaitsFor(function () { + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + return title.indexOf("MyAppTitle") !== -1; + }, "custom title to appear", 10000); + + // Trigger several flyout refreshes — the title + // must remain stable while the process runs. + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + expect(title).toContain("MyAppTitle"); + + // Kill the node process so next test starts clean + await writeToTerminal("\x03"); // Ctrl+C + await awaitsFor(function () { + triggerFlyoutRefresh(); + const label = testWindow.$( + ".terminal-flyout-item.active " + + ".terminal-flyout-title" + ).text(); + return label && label.indexOf("node") === -1; + }, "node process to exit", 10000); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + + it("should clear stale title after child process exits", + async function () { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "terminal to be created", 10000); + + await waitForShellReady(); + + // Start a node process that sets a custom title + // then exits after a short delay. + await writeToTerminal( + 'node -e "process.stdout.write(' + + "'\\x1b]0;TestCustomTitle\\x07'" + + ');setTimeout(()=>{},3000)"\r' + ); + await waitForActiveProcess("node"); + + // Verify the custom title appears in the flyout + await awaitsFor(function () { + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + return title.indexOf("TestCustomTitle") !== -1; + }, "custom title to appear", 10000); + + // Wait for the node process to exit (3s timeout) + // and the flyout to reflect the shell again. + await awaitsFor(function () { + triggerFlyoutRefresh(); + const title = testWindow.$( + ".terminal-flyout-item.active" + ).attr("title") || ""; + return title.indexOf("TestCustomTitle") === -1; + }, "stale title to be cleared after exit", 15000); + + // The flyout label should be back to the shell + triggerFlyoutRefresh(); + const label = testWindow.$( + ".terminal-flyout-item.active " + + ".terminal-flyout-title" + ).text(); + expect(label).not.toBe(""); + expect(label).not.toContain("node"); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + }); + describe("Programmatic hide vs user close", function () { it("should keep terminals alive after panel.hide()", async function () { From 7090b25851357713e4a296d3d7e3628ca78f3c0f Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 11:31:27 +0530 Subject: [PATCH 15/36] fix(terminal): use stale-title flag instead of overwriting inst.title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (b51de0b3f) directly overwrote inst.title when the foreground process returned to the shell, which broke Linux where bash sets the title via PS1 escape sequences on each prompt. Replace with a _titleStale flag that is set on non-shell→shell transitions and cleared when onTitleChange fires, so shells that emit title sequences (bash/Linux) work correctly while shells that don't (zsh/Mac) still show the profile name instead of stale child titles. Also fix the Linux integration test to trigger a prompt refresh (echo + enter) after shell init so PS1 title escapes fire reliably. --- src/extensionsIntegrated/Terminal/main.js | 37 ++++++++++++++--------- test/spec/Terminal-integ-test.js | 5 +++ 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 1022fcc705..15a67d12bc 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -391,9 +391,14 @@ define(function (require, exports, module) { } /** - * Handle terminal title change — also fetches and displays the foreground process + * Handle terminal title change — also fetches and displays the foreground process. + * Clears the stale-title flag since the shell has provided its own title. */ function _onTerminalTitleChanged(id, title) { + const instance = terminalInstances.find(t => t.id === id); + if (instance) { + instance._titleStale = false; + } _updateFlyout(); _updateTabProcess(id); } @@ -409,17 +414,18 @@ define(function (require, exports, module) { nodeConnector.execPeer("getTerminalProcess", {id}).then(function (result) { const newProc = result.process || ""; if (processInfo[id] !== newProc) { + const oldProc = processInfo[id]; processInfo[id] = newProc; - // When a child process (e.g. "claude") exits and the - // shell regains foreground, the child may have set a - // custom terminal title via escape sequences. Shells - // like zsh on macOS do not emit a title reset, so - // inst.title stays stale. Reset it only when the - // foreground process returns to the shell. If the - // shell does emit a title change, onTitleChange will - // overwrite this immediately. - if (_isShellProcess(newProc)) { - instance.title = instance.shellProfile.name; + // When a child process exits and the shell regains + // foreground, the child may have set a custom title + // via escape sequences. Some shells (e.g. zsh on + // macOS) don't emit a title reset, leaving inst.title + // stale. Mark it so _updateFlyout can fall back to + // the profile name. If the shell DOES emit a title + // change (e.g. bash on Linux), _onTerminalTitleChanged + // clears this flag immediately. + if (oldProc && !_isShellProcess(oldProc) && _isShellProcess(newProc)) { + instance._titleStale = true; } _updateFlyout(); } @@ -461,13 +467,16 @@ define(function (require, exports, module) { const proc = processInfo[inst.id] || ""; const basename = proc ? proc.split("/").pop().split("\\").pop() : ""; - // Label: process basename; right side: cwd basename; tooltip: full title + // Label: process basename; right side: cwd basename; tooltip: full title. + // If the title is stale (child set it and the shell didn't reset it), + // fall back to the shell profile name. const label = basename || "Terminal"; - const cwdName = _extractCwdBasename(inst.title); + const displayTitle = inst._titleStale ? inst.shellProfile.name : inst.title; + const cwdName = _extractCwdBasename(displayTitle); const $item = $('
') .attr("data-terminal-id", inst.id) - .attr("title", inst.title) + .attr("title", displayTitle) .toggleClass("active", inst.id === activeTerminalId); if (!inst.isAlive) { diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index bea5039cd2..7ad9766618 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -224,6 +224,11 @@ define(function (require, exports, module) { } else { // On Linux, bash/zsh set the title to // include the cwd (e.g. "user@host: /path"). + // Wait for shell ready first, then trigger + // a prompt refresh so PS1 title escapes fire. + await waitForShellReady(); + await writeToTerminal("echo\r"); + const expectedPath = getNativeProjectPath(); const projectDirName = expectedPath .split("/").pop().split("\\").pop(); From 98bb2a655bf987334c7c71fa58dc3636f399032e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 11:33:29 +0530 Subject: [PATCH 16/36] refactor(terminal): remove unused title parameter from _onTerminalTitleChanged --- src/extensionsIntegrated/Terminal/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 15a67d12bc..3299d23ee2 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -394,7 +394,7 @@ define(function (require, exports, module) { * Handle terminal title change — also fetches and displays the foreground process. * Clears the stale-title flag since the shell has provided its own title. */ - function _onTerminalTitleChanged(id, title) { + function _onTerminalTitleChanged(id) { const instance = terminalInstances.find(t => t.id === id); if (instance) { instance._titleStale = false; From e5549bce8848c16b83b02ab60c0a4540e062d197 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 13:40:42 +0530 Subject: [PATCH 17/36] feat(workspace): add app-drawer toolbar button for Quick Access panel Add a grid icon above the profile button in the right toolbar that toggles the Quick Access (default) panel. The button shows a selected/pressed state when the panel is open and deselects when the panel is hidden or another panel opens. Also remove unused $pluginIconsBar variable in MainViewManager tests and add 5 integration tests for the new button behavior. --- src/styles/images/app-drawer.svg | 6 +++ src/view/DefaultPanelView.js | 36 +++++++++++-- test/spec/MainViewManager-integ-test.js | 71 ++++++++++++++++++++++++- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 src/styles/images/app-drawer.svg diff --git a/src/styles/images/app-drawer.svg b/src/styles/images/app-drawer.svg new file mode 100644 index 0000000000..dfcb4349d4 --- /dev/null +++ b/src/styles/images/app-drawer.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 4491499ae5..4522760992 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -31,7 +31,8 @@ define(function (require, exports, module) { CommandManager = require("command/CommandManager"), Strings = require("strings"), WorkspaceManager = require("view/WorkspaceManager"), - PanelView = require("view/PanelView"); + PanelView = require("view/PanelView"), + ExtensionUtils = require("utils/ExtensionUtils"); /** * Descriptors for each launcher button. @@ -177,14 +178,43 @@ define(function (require, exports, module) { } }); - // Auto-hide when any other panel is shown. - // hide() is a no-op if the panel is already closed, so no guard needed. + // Create the app-drawer toolbar icon above the profile button + const iconURL = ExtensionUtils.getModulePath(module, "../styles/images/app-drawer.svg"); + const $drawerBtn = $("") + .attr({ + id: "app-drawer-button", + href: "#", + title: Strings.BOTTOM_PANEL_DEFAULT_TITLE + }) + .css({ + "background-image": "url('" + iconURL + "')", + "background-position": "center", + "background-size": "16px" + }) + .prependTo($("#main-toolbar .bottom-buttons")); + + $drawerBtn.on("click", function () { + if (_panel.isVisible()) { + _panel.hide(); + } else { + _panel.show(); + } + }); + + // Auto-hide when any other panel is shown; update drawer button state. PanelView.on(PanelView.EVENT_PANEL_SHOWN, function (event, panelID) { if (panelID !== WorkspaceManager.DEFAULT_PANEL_ID) { _panel.hide(); } else { _updateButtonVisibility(); } + $drawerBtn.toggleClass("selected-button", panelID === WorkspaceManager.DEFAULT_PANEL_ID); + }); + + PanelView.on(PanelView.EVENT_PANEL_HIDDEN, function (event, panelID) { + if (panelID === WorkspaceManager.DEFAULT_PANEL_ID) { + $drawerBtn.removeClass("selected-button"); + } }); // Initial visibility update and set up live observers diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 59b77ccead..85df1f6a6b 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -1304,7 +1304,6 @@ define(function (require, exports, module) { it("should clamp panel width when shown after window was resized", function () { // Panel is hidden; compute what the max toolbar width would be const sidebarWidth = _$("#sidebar").outerWidth() || 0; - const $pluginIconsBar = _$("#plugin-icons-bar"); const maxToolbarWidth = Math.min( testWindow.innerWidth * 0.75, testWindow.innerWidth - sidebarWidth - 100 @@ -1334,5 +1333,75 @@ define(function (require, exports, module) { expect(rightOffset).toEqual($mainToolbar.width()); }); }); + + describe("Quick Access panel (app drawer button)", function () { + const DEFAULT_PANEL_ID = "workspace.defaultPanel"; + + function getDrawerButton() { + return _$("#app-drawer-button"); + } + + function isDefaultPanelVisible() { + return _$("#default-panel").is(":visible"); + } + + function isDrawerSelected() { + return getDrawerButton().hasClass("selected-button"); + } + + beforeEach(function () { + // Ensure a clean state: hide any open panels + const panel = WorkspaceManager.getPanelForID(DEFAULT_PANEL_ID); + if (panel && panel.isVisible()) { + panel.hide(); + } + }); + + it("should have the app-drawer button in the toolbar", function () { + expect(getDrawerButton().length).toBe(1); + }); + + it("should open Quick Access panel on drawer button click", function () { + expect(isDefaultPanelVisible()).toBeFalse(); + + getDrawerButton().click(); + + expect(isDefaultPanelVisible()).toBeTrue(); + }); + + it("should show selected state when panel is open", function () { + expect(isDrawerSelected()).toBeFalse(); + + getDrawerButton().click(); + + expect(isDrawerSelected()).toBeTrue(); + }); + + it("should close Quick Access panel on second click", function () { + getDrawerButton().click(); + expect(isDefaultPanelVisible()).toBeTrue(); + expect(isDrawerSelected()).toBeTrue(); + + getDrawerButton().click(); + + expect(isDefaultPanelVisible()).toBeFalse(); + expect(isDrawerSelected()).toBeFalse(); + }); + + it("should deselect drawer when another panel opens", async function () { + getDrawerButton().click(); + expect(isDefaultPanelVisible()).toBeTrue(); + expect(isDrawerSelected()).toBeTrue(); + + // Open a different panel (Problems) + await CommandManager.execute(Commands.VIEW_TOGGLE_PROBLEMS); + + expect(isDefaultPanelVisible()).toBeFalse(); + expect(isDrawerSelected()).toBeFalse(); + + // Clean up: close Problems panel + await CommandManager.execute(Commands.VIEW_TOGGLE_PROBLEMS); + }); + }); }); }); From 0d6a0863f209eee4e9d2741b1dfe759a3e06b47e Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 14:20:59 +0530 Subject: [PATCH 18/36] feat(workspace): add app-drawer icon to Quick Access tab title and enlarge SVG Add the app-drawer grid icon to the Quick Access panel tab title for visual consistency with the toolbar button. Enlarge the SVG grid squares for better visibility at small sizes. Rename panel title from "Quick Access" to "Tools". --- src/nls/root/strings.js | 2 +- src/styles/images/app-drawer.svg | 8 ++++---- src/view/DefaultPanelView.js | 21 ++++++++++++++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index dde5873c9e..f5a71ffcef 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1255,7 +1255,7 @@ define({ "BOTTOM_PANEL_MINIMIZE": "Minimize Panel", "BOTTOM_PANEL_SHOW": "Show Bottom Panel", "BOTTOM_PANEL_HIDE_TOGGLE": "Hide Bottom Panel", - "BOTTOM_PANEL_DEFAULT_TITLE": "Quick Access", + "BOTTOM_PANEL_DEFAULT_TITLE": "Tools", "BOTTOM_PANEL_DEFAULT_HEADING": "Open a Panel", "BOTTOM_PANEL_OPEN_PANEL": "Open a Panel", "BOTTOM_PANEL_MAXIMIZE": "Maximize Panel", diff --git a/src/styles/images/app-drawer.svg b/src/styles/images/app-drawer.svg index dfcb4349d4..dd1070f605 100644 --- a/src/styles/images/app-drawer.svg +++ b/src/styles/images/app-drawer.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 4522760992..3f490b7d7d 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -178,8 +178,26 @@ define(function (require, exports, module) { } }); - // Create the app-drawer toolbar icon above the profile button const iconURL = ExtensionUtils.getModulePath(module, "../styles/images/app-drawer.svg"); + + /** + * Inject the app-drawer icon into the Quick Access tab title. + * Called each time the panel is shown because the tab DOM is rebuilt. + */ + function _addTabIcon() { + const $tabTitle = $('#bottom-panel-tab-bar .bottom-panel-tab[data-panel-id="' + + WorkspaceManager.DEFAULT_PANEL_ID + '"] .bottom-panel-tab-title'); + if ($tabTitle.length && !$tabTitle.find(".app-drawer-tab-icon").length) { + $tabTitle.prepend($('').attr("src", iconURL).css({ + "width": "12px", + "height": "12px", + "vertical-align": "middle", + "margin-right": "4px" + })); + } + } + + // Create the app-drawer toolbar icon above the profile button const $drawerBtn = $("") .attr({ id: "app-drawer-button", @@ -207,6 +225,7 @@ define(function (require, exports, module) { _panel.hide(); } else { _updateButtonVisibility(); + _addTabIcon(); } $drawerBtn.toggleClass("selected-button", panelID === WorkspaceManager.DEFAULT_PANEL_ID); }); From 9dc7518f8f773716d8d70da3b63b65b6c8a3d735 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 15:04:50 +0530 Subject: [PATCH 19/36] feat(terminal): add right-click context menu with Copy, Paste, and Clear Register terminal-specific commands and a context menu on the terminal content area. Copy is disabled when there's no selection. All actions refocus the terminal after executing. --- src/extensionsIntegrated/Terminal/main.js | 54 +++++++++++++++++++++++ src/nls/root/strings.js | 1 + 2 files changed, 55 insertions(+) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 3299d23ee2..e26eecaf97 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -40,6 +40,7 @@ define(function (require, exports, module) { const Strings = require("strings"); const StringUtils = require("utils/StringUtils"); + const Menus = require("command/Menus"); const Commands = require("command/Commands"); const NotificationUI = require("widgets/NotificationUI"); const TerminalInstance = require("./TerminalInstance"); @@ -52,6 +53,10 @@ define(function (require, exports, module) { // Constants const CMD_VIEW_TERMINAL = Commands.VIEW_TERMINAL; const CMD_NEW_TERMINAL = "terminal.new"; + const CMD_TERMINAL_COPY = "terminal.copy"; + const CMD_TERMINAL_PASTE = "terminal.paste"; + const CMD_TERMINAL_CLEAR = "terminal.clear"; + const TERMINAL_CONTEXT_MENU_ID = "terminal-context-menu"; const PANEL_ID = "terminal-panel"; const PANEL_MIN_SIZE = 100; @@ -125,6 +130,12 @@ define(function (require, exports, module) { $shellDropdown = $panel.find(".terminal-shell-dropdown"); $flyoutList = $panel.find(".terminal-flyout-list"); + // Right-click context menu for terminal content area + $contentArea.on("contextmenu", function (e) { + e.preventDefault(); + terminalContextMenu.open(e); + }); + // "+" button creates a new terminal with the default shell $panel.find(".terminal-flyout-new-btn").on("click", function (e) { e.stopPropagation(); @@ -616,6 +627,49 @@ define(function (require, exports, module) { CommandManager.register("New Terminal", CMD_NEW_TERMINAL, _createNewTerminal); CommandManager.register(Strings.CMD_VIEW_TERMINAL, CMD_VIEW_TERMINAL, _showTerminal); + // Terminal context menu commands + CommandManager.register(Strings.CMD_COPY, CMD_TERMINAL_COPY, function () { + const active = _getActiveTerminal(); + if (active && active.terminal.hasSelection()) { + navigator.clipboard.writeText(active.terminal.getSelection()); + active.focus(); + } + }); + CommandManager.register(Strings.CMD_PASTE, CMD_TERMINAL_PASTE, function () { + const active = _getActiveTerminal(); + if (active && active.isAlive) { + active.focus(); + navigator.clipboard.readText().then(function (text) { + if (text) { + nodeConnector.execPeer("writeTerminal", {id: active.id, data: text}); + } + }); + } + }); + CommandManager.register(Strings.TERMINAL_CLEAR, CMD_TERMINAL_CLEAR, function () { + _clearActiveTerminal(); + const active = _getActiveTerminal(); + if (active) { + active.focus(); + } + }); + + // Register terminal context menu + const terminalContextMenu = Menus.registerContextMenu(TERMINAL_CONTEXT_MENU_ID); + terminalContextMenu.addMenuItem(CMD_TERMINAL_COPY); + terminalContextMenu.addMenuItem(CMD_TERMINAL_PASTE); + terminalContextMenu.addMenuDivider(); + terminalContextMenu.addMenuItem(CMD_TERMINAL_CLEAR); + + // Enable/disable Copy based on terminal selection + terminalContextMenu.on(Menus.EVENT_BEFORE_CONTEXT_MENU_OPEN, function () { + const active = _getActiveTerminal(); + const hasSelection = active && active.terminal.hasSelection(); + CommandManager.get(CMD_TERMINAL_COPY).setEnabled(hasSelection); + CommandManager.get(CMD_TERMINAL_PASTE).setEnabled(active && active.isAlive); + CommandManager.get(CMD_TERMINAL_CLEAR).setEnabled(!!active); + }); + // Initialize on app ready AppInit.appReady(function () { if (Phoenix.isSpecRunnerWindow) { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index f5a71ffcef..7db31bfa77 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1498,6 +1498,7 @@ define({ "TERMINAL_CLOSE_ALL_MSG_PROCESS_MANY": "All terminals will be closed.
{0} active processes will be stopped.

Continue?", "TERMINAL_CLOSE_ALL_STOP_BTN": "Close All & Stop Processes", "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", + "TERMINAL_CLEAR": "Clear Terminal", "EXTENDED_COMMIT_MESSAGE": "EXTENDED", "GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026", "GIT_COMMIT": "Git commit\u2026", From 605984613dbe578ff16d0b2f5e0bea354cbcc42f Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 15:13:04 +0530 Subject: [PATCH 20/36] feat(terminal): add "Open in Integrated Terminal" to project context menu Adds an "Integrated Terminal" option to the "Open in" submenu in both the project tree and working set context menus. Opens a new terminal at the selected file's directory (or folder itself if a directory is selected). Refactors cwd resolution into a reusable _toNativePath helper. --- src/command/Commands.js | 2 + src/command/DefaultMenus.js | 2 + src/extensionsIntegrated/Terminal/main.js | 56 ++++++++++++++++------- src/nls/root/strings.js | 1 + 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/command/Commands.js b/src/command/Commands.js index c22d3ba10e..30403d1cbe 100644 --- a/src/command/Commands.js +++ b/src/command/Commands.js @@ -348,6 +348,8 @@ define(function (require, exports, module) { /** Shows current file in OS Terminal */ exports.NAVIGATE_OPEN_IN_TERMINAL = "navigate.openInTerminal"; + /** Opens integrated terminal at the selected file/folder path */ + exports.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL = "navigate.openInIntegratedTerminal"; /** Shows current file in open powershell in Windows os */ exports.NAVIGATE_OPEN_IN_POWERSHELL = "navigate.openInPowerShell"; diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 3db49ac3a6..c803191e33 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -315,6 +315,7 @@ define(function (require, exports, module) { let subMenu = workingset_cmenu.addSubMenu(Strings.CMD_OPEN_IN, Commands.OPEN_IN_SUBMENU_WS); subMenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_TERMINAL); + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL); if (brackets.platform === "win") { subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_POWERSHELL); } @@ -358,6 +359,7 @@ define(function (require, exports, module) { let subMenu = project_cmenu.addSubMenu(Strings.CMD_OPEN_IN, Commands.OPEN_IN_SUBMENU); subMenu.addMenuItem(Commands.NAVIGATE_SHOW_IN_OS); subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_TERMINAL); + subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL); if (brackets.platform === "win") { subMenu.addMenuItem(Commands.NAVIGATE_OPEN_IN_POWERSHELL); } diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index e26eecaf97..9f169dff7b 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -242,34 +242,46 @@ define(function (require, exports, module) { /** * Create a new terminal with the default shell */ - async function _createNewTerminal() { + async function _createNewTerminal(cwdOverride) { const shell = ShellProfiles.getDefaultShell(); - return _createNewTerminalWithShell(shell); + return _createNewTerminalWithShell(shell, cwdOverride); + } + + /** + * Convert a VFS path to a native platform path suitable for use as cwd. + * Strips trailing slashes (posix_spawnp can fail with them). + */ + function _toNativePath(vfsPath) { + let cwd = vfsPath; + const tauriPrefix = Phoenix.VFS.getTauriDir(); + if (cwd.startsWith(tauriPrefix)) { + cwd = Phoenix.fs.getTauriPlatformPath(cwd); + } + if (cwd.length > 1 && (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 */ - async function _createNewTerminalWithShell(shell) { + async function _createNewTerminalWithShell(shell, cwdOverride) { 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(); + // Get cwd: use override if provided, otherwise fall back to project root 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); + if (cwdOverride) { + cwd = _toNativePath(cwdOverride); + } else { + const projectRoot = ProjectManager.getProjectRoot(); + if (projectRoot) { + cwd = _toNativePath(projectRoot.fullPath); } } @@ -626,6 +638,18 @@ define(function (require, exports, module) { // Register commands CommandManager.register("New Terminal", CMD_NEW_TERMINAL, _createNewTerminal); CommandManager.register(Strings.CMD_VIEW_TERMINAL, CMD_VIEW_TERMINAL, _showTerminal); + CommandManager.register(Strings.CMD_OPEN_IN_INTEGRATED_TERMINAL, + Commands.NAVIGATE_OPEN_IN_INTEGRATED_TERMINAL, function () { + const entry = ProjectManager.getSelectedItem(); + let cwdPath; + if (entry) { + cwdPath = entry.isDirectory ? entry.fullPath : entry.parentPath; + } else { + const projectRoot = ProjectManager.getProjectRoot(); + cwdPath = projectRoot ? projectRoot.fullPath : undefined; + } + _createNewTerminal(cwdPath); + }); // Terminal context menu commands CommandManager.register(Strings.CMD_COPY, CMD_TERMINAL_COPY, function () { diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 7db31bfa77..fa76f2918b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -686,6 +686,7 @@ define({ "CMD_SHOW_IN_FINDER": "macOS Finder", "CMD_SHOW_IN_FILE_MANAGER": "File Manager", "CMD_OPEN_IN_TERMINAL_DO_NOT_TRANSLATE": "Terminal", + "CMD_OPEN_IN_INTEGRATED_TERMINAL": "Integrated Terminal", "CMD_OPEN_IN_CMD": "Command Prompt", "CMD_OPEN_IN_POWER_SHELL": "PowerShell", "CMD_OPEN_IN_DEFAULT_APP": "System Default App", From a7f24aeca169df1bd6746391fa0aaacc304d3e40 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 21:23:32 +0530 Subject: [PATCH 21/36] test(terminal): add integration tests for clear, copy, paste, and context menu Test clear command removes buffer content, copy command reads the xterm selection, paste command writes mocked clipboard text to the PTY, and beforeContextMenuOpen disables Copy when nothing is selected. Guard test-only exports behind Phoenix.isTestWindow. --- src/extensionsIntegrated/Terminal/main.js | 52 ++++--- test/spec/Terminal-integ-test.js | 181 +++++++++++++++++++++- 2 files changed, 208 insertions(+), 25 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 9f169dff7b..5bd5948e91 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -788,29 +788,33 @@ define(function (require, exports, module) { exports.CMD_VIEW_TERMINAL = CMD_VIEW_TERMINAL; exports.CMD_NEW_TERMINAL = CMD_NEW_TERMINAL; - /** - * Write data to the active terminal's PTY. Test-only helper. - * @param {string} data The text to send to the terminal. - * @return {Promise} - */ - exports._writeToActiveTerminal = function (data) { - const active = _getActiveTerminal(); - if (!active || !active.isAlive) { - return Promise.reject(new Error("No active terminal")); - } - return nodeConnector.execPeer("writeTerminal", { - id: active.id, data - }); - }; + if (Phoenix.isTestWindow) { + exports._getActiveTerminal = _getActiveTerminal; + + /** + * Write data to the active terminal's PTY. Test-only helper. + * @param {string} data The text to send to the terminal. + * @return {Promise} + */ + exports._writeToActiveTerminal = function (data) { + const active = _getActiveTerminal(); + if (!active || !active.isAlive) { + return Promise.reject(new Error("No active terminal")); + } + return nodeConnector.execPeer("writeTerminal", { + id: active.id, data + }); + }; - /** - * Dispose all terminal instances. Test-only helper. - * Awaits all PTY kill commands so the caller can be - * sure processes have been signalled before the test - * window is torn down. - */ - exports._disposeAll = async function () { - await _disposeAllAsync(); - activeTerminalId = null; - }; + /** + * Dispose all terminal instances. Test-only helper. + * Awaits all PTY kill commands so the caller can be + * sure processes have been signalled before the test + * window is torn down. + */ + exports._disposeAll = async function () { + await _disposeAllAsync(); + activeTerminalId = null; + }; + } }); diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 7ad9766618..27b9c1de75 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, awaitsFor */ +/*global describe, it, expect, beforeAll, afterAll, awaitsFor, spyOn */ define(function (require, exports, module) { @@ -546,6 +546,185 @@ define(function (require, exports, module) { }); }); + describe("Context menu commands", function () { + let CommandManager; + + function getActiveTerminal() { + const mod = testWindow.brackets.getModule( + "extensionsIntegrated/Terminal/main" + ); + return mod._getActiveTerminal(); + } + + /** + * Check whether a marker string appears anywhere + * in the active terminal's buffer. + */ + function bufferContains(marker) { + const active = getActiveTerminal(); + if (!active) { + return false; + } + const buffer = active.terminal.buffer.active; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line && line.translateToString() + .indexOf(marker) !== -1) { + return true; + } + } + return false; + } + + beforeAll(function () { + CommandManager = + testWindow.brackets.test.CommandManager; + }); + + it("should clear the terminal screen", + async function () { + await openTerminal(); + await waitForShellReady(); + + // Write some output so the terminal has content + await writeToTerminal("echo cleartest\r"); + await awaitsFor(function () { + return bufferContains("cleartest"); + }, "echo output to appear", 10000); + + // Execute the clear command + await CommandManager.execute("terminal.clear"); + + // After clear, the marker should no longer be + // in the buffer (xterm.clear() wipes scrollback). + await awaitsFor(function () { + return !bufferContains("cleartest"); + }, "terminal to be cleared", 5000); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + + it("should copy selected terminal text to clipboard", + async function () { + await openTerminal(); + await waitForShellReady(); + + // Write a unique marker string + await writeToTerminal("echo COPYMARKER123\r"); + await awaitsFor(function () { + return bufferContains("COPYMARKER123"); + }, "echo output to appear", 10000); + + // Select all text in xterm + const active = getActiveTerminal(); + active.terminal.selectAll(); + expect(active.terminal.hasSelection()) + .toBeTrue(); + const selection = active.terminal.getSelection(); + expect(selection).toContain("COPYMARKER123"); + + // The copy command writes to the system clipboard + // via navigator.clipboard.writeText(). In the test + // iframe clipboard writes may be denied (no focus), + // so we verify the command reads the right text by + // spying on writeText. + const clipboard = testWindow.navigator.clipboard; + let copiedText = null; + spyOn(clipboard, "writeText").and.callFake( + function (text) { + copiedText = text; + return testWindow.Promise.resolve(); + } + ); + await CommandManager.execute("terminal.copy"); + expect(copiedText).toContain("COPYMARKER123"); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + + it("should paste clipboard text into terminal", + async function () { + await openTerminal(); + await waitForShellReady(); + + // Mock clipboard.readText to return a known string, + // since the test iframe may not have clipboard + // permission (no window focus). + const pasteText = "PASTEMARKER456"; + const clipboard = testWindow.navigator.clipboard; + spyOn(clipboard, "readText").and.returnValue( + testWindow.Promise.resolve(pasteText) + ); + + // Execute the paste command + await CommandManager.execute("terminal.paste"); + + // The pasted text should appear in the terminal + // buffer (written to PTY input → echoed back). + await awaitsFor(function () { + return bufferContains(pasteText); + }, "pasted text to appear in terminal", 10000); + + // Clean up: press Enter then close + await writeToTerminal("\r"); + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + + it("should disable copy when there is no selection", + async function () { + await openTerminal(); + await waitForShellReady(); + + const active = getActiveTerminal(); + // Ensure no selection + active.terminal.clearSelection(); + expect(active.terminal.hasSelection()) + .toBeFalse(); + + // Open context menu to trigger + // beforeContextMenuOpen event + const Menus = testWindow.brackets.test.Menus; + const ctxMenu = Menus.getContextMenu( + "terminal-context-menu" + ); + ctxMenu.open({pageX: 100, pageY: 100}); + + const copyCmd = + CommandManager.get("terminal.copy"); + expect(copyCmd.getEnabled()).toBeFalse(); + + // Close menu + ctxMenu.close(); + + // Now select text and re-open + active.terminal.selectAll(); + ctxMenu.open({pageX: 100, pageY: 100}); + expect(copyCmd.getEnabled()).toBeTrue(); + ctxMenu.close(); + + // Clean up + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel") + .is(":visible"); + }, "terminal panel to close", 5000); + }); + }); + describe("Programmatic hide vs user close", function () { it("should keep terminals alive after panel.hide()", async function () { From 625fe0f8adab6ec77387e4e0ece945d42d164d58 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 21:30:58 +0530 Subject: [PATCH 22/36] fix(workspace): vertically align icon and text in bottom panel tab title --- src/styles/Extn-BottomPanelTabs.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/styles/Extn-BottomPanelTabs.less b/src/styles/Extn-BottomPanelTabs.less index 530e6fcb17..4e5c9e3d61 100644 --- a/src/styles/Extn-BottomPanelTabs.less +++ b/src/styles/Extn-BottomPanelTabs.less @@ -145,6 +145,8 @@ } .bottom-panel-tab-title { + display: inline-flex; + align-items: center; pointer-events: none; } From 9a879a106ca5c7182f91e094d0a5d994b9808bf8 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 21:36:57 +0530 Subject: [PATCH 23/36] fix(test): wait for async process info update in terminal title test The "clear stale title after child process exits" test had a race condition on Windows where the flyout label still showed "node.exe" because the async process detection hadn't completed yet. Add an awaitsFor that polls until the flyout label no longer contains "node". --- test/spec/Terminal-integ-test.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 27b9c1de75..508fad684b 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -528,8 +528,20 @@ define(function (require, exports, module) { return title.indexOf("TestCustomTitle") === -1; }, "stale title to be cleared after exit", 15000); + // Wait for the process info to update (async) + // so the flyout label reflects the shell, not the + // exited child process. + await awaitsFor(function () { + triggerFlyoutRefresh(); + const lbl = testWindow.$( + ".terminal-flyout-item.active " + + ".terminal-flyout-title" + ).text(); + return lbl && lbl.indexOf("node") === -1; + }, "flyout label to show shell instead of node", + 15000); + // The flyout label should be back to the shell - triggerFlyoutRefresh(); const label = testWindow.$( ".terminal-flyout-item.active " + ".terminal-flyout-title" From 0b86e6549e68e122595a9d60699bae06c754d778 Mon Sep 17 00:00:00 2001 From: abose Date: Sat, 28 Feb 2026 22:34:14 +0530 Subject: [PATCH 24/36] fix(test): avoid race condition in FileFilters integration tests Set exclusion filter before opening the search bar to prevent a race where _showFindBar() inherits the previous query and triggers an unfiltered search that populates the worker cache with all files. Also add retry loops to exclude-filter tests to handle instant/deferred search races, matching the existing pattern in "should search in files". --- test/spec/FileFilters-integ-test.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/test/spec/FileFilters-integ-test.js b/test/spec/FileFilters-integ-test.js index 34ba8017d6..47fcbf00e5 100644 --- a/test/spec/FileFilters-integ-test.js +++ b/test/spec/FileFilters-integ-test.js @@ -207,10 +207,16 @@ define(function (require, exports, module) { } it("should exclude files from search", async function () { - await openSearchBar(); + // Set the exclusion filter before opening the search bar to avoid + // a race where opening the bar triggers an unfiltered search that + // populates the worker cache with all files (including *.css). await setExcludeCSSFiles(); await openSearchBar(); - await executeCleanSearch("{1}"); + await awaitsFor(async ()=>{ + await executeCleanSearch("{1}"); + return !FindInFiles.searchModel.results[testPath + "/test1.css"]; + // retry as instant/deferred searches can race with the explicit search + }, "Search to exclude css results", 7000, 300); // *.css should have been excluded this time expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); @@ -219,7 +225,6 @@ define(function (require, exports, module) { it("should respect filter when searching folder", async function () { let dirEntry = FileSystem.getDirectoryForPath(testPath); - await openSearchBar(dirEntry); await setExcludeCSSFiles(); await openSearchBar(dirEntry); await executeCleanSearch("{1}"); @@ -265,8 +270,8 @@ define(function (require, exports, module) { }, 30000); it("should respect filter when editing code", async function () { - await openSearchBar(); await setExcludeCSSFiles(); + await openSearchBar(); await executeCleanSearch("{1}"); let promise = testWindow.brackets.test.DocumentManager.getDocumentForPath(testPath + "/test1.css"); await awaitsForDone(promise); @@ -322,7 +327,11 @@ define(function (require, exports, module) { it("should search exclude files", async function () { await openSearchBar(); _setExcludeFiles("*.css"); - await executeCleanSearch("{1}"); + await awaitsFor(async ()=>{ + await executeCleanSearch("{1}"); + return !FindInFiles.searchModel.results[testPath + "/test1.css"]; + // retry as instant/deferred searches can race with the explicit search + }, "Search to exclude css results", 7000, 300); expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy(); await closeSearchBar(); From 6b44f2e8a2bca4e68d842c8405c722aa512de756 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 1 Mar 2026 12:48:28 +0530 Subject: [PATCH 25/36] fix(workspace): update escape/shift-escape tests and fix plugin panel min width - Update tests for new escape toggle behavior (second escape re-shows panel) - Replace shift-escape collapse tests with focus-switching tests - Add tests for: shift-escape focus toggle, custom panel focus handler, default panel open on shift-escape, app-drawer-button toggle - Fix _showPluginSidePanel to include icons bar width in makeResizable minSize so toolbar respects minimum content + icons width - Fix _clampPluginPanelWidth to enforce minimum width (not just maximum) - Move #app-drawer-button from runtime JS creation to index.html - Move app-drawer-button inline styles to brackets.less --- src/index.html | 1 + src/styles/brackets.less | 6 ++ src/view/DefaultPanelView.js | 16 +--- src/view/WorkspaceManager.js | 15 ++-- test/spec/MainViewManager-integ-test.js | 100 +++++++++++++++++++++--- 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/src/index.html b/src/index.html index 5300d70303..f569fc9eb2 100644 --- a/src/index.html +++ b/src/index.html @@ -999,6 +999,7 @@
+
diff --git a/src/styles/brackets.less b/src/styles/brackets.less index adc23d7d1b..e855886add 100644 --- a/src/styles/brackets.less +++ b/src/styles/brackets.less @@ -1189,6 +1189,12 @@ a, img { } } +#app-drawer-button { + background-image: url("images/app-drawer.svg"); + background-position: center; + background-size: 16px; +} + /* Project panel */ #working-set-list-container { diff --git a/src/view/DefaultPanelView.js b/src/view/DefaultPanelView.js index 3f490b7d7d..6f5291a45d 100644 --- a/src/view/DefaultPanelView.js +++ b/src/view/DefaultPanelView.js @@ -197,19 +197,9 @@ define(function (require, exports, module) { } } - // Create the app-drawer toolbar icon above the profile button - const $drawerBtn = $("") - .attr({ - id: "app-drawer-button", - href: "#", - title: Strings.BOTTOM_PANEL_DEFAULT_TITLE - }) - .css({ - "background-image": "url('" + iconURL + "')", - "background-position": "center", - "background-size": "16px" - }) - .prependTo($("#main-toolbar .bottom-buttons")); + // The app-drawer button is defined in index.html; set its title here. + const $drawerBtn = $("#app-drawer-button") + .attr("title", Strings.BOTTOM_PANEL_DEFAULT_TITLE); $drawerBtn.on("click", function () { if (_panel.isVisible()) { diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js index 8888c9f22e..faaad7b894 100644 --- a/src/view/WorkspaceManager.js +++ b/src/view/WorkspaceManager.js @@ -509,17 +509,21 @@ define(function (require, exports, module) { minToolbarWidth, Math.min(window.innerWidth * 0.75, window.innerWidth - sidebarWidth - 100) ); - if ($mainToolbar.width() > maxToolbarWidth) { - $mainToolbar.width(maxToolbarWidth); - $windowContent.css("right", maxToolbarWidth); + let currentWidth = $mainToolbar.width(); + if (currentWidth > maxToolbarWidth || currentWidth < minToolbarWidth) { + let clampedWidth = Math.max(minToolbarWidth, Math.min(currentWidth, maxToolbarWidth)); + $mainToolbar.width(clampedWidth); + $windowContent.css("right", clampedWidth); Resizer.resyncSizer($mainToolbar[0]); } } function _showPluginSidePanel(panelID) { let panelBeingShown = getPanelForID(panelID); + let pluginIconsBarWidth = $pluginIconsBar.outerWidth(); + let minToolbarWidth = (panelBeingShown.minWidth || 0) + pluginIconsBarWidth; Resizer.makeResizable($mainToolbar, Resizer.DIRECTION_HORIZONTAL, Resizer.POSITION_LEFT, - panelBeingShown.minWidth, false, undefined, true, + minToolbarWidth, false, undefined, true, undefined, $windowContent, undefined, _getInitialSize(panelBeingShown)); Resizer.show($mainToolbar[0]); _clampPluginPanelWidth(panelBeingShown); @@ -682,9 +686,10 @@ define(function (require, exports, module) { } if (EditorManager.getFocusedEditor()) { // Editor has focus — focus the panel - const activePanel = PanelView.getActiveBottomPanel(); + let activePanel = PanelView.getActiveBottomPanel(); if(!activePanel || !activePanel.isVisible()){ _togglePanels(); + activePanel = PanelView.getActiveBottomPanel(); } activePanel.focus(); } else { diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 85df1f6a6b..9a32deb463 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -1044,7 +1044,7 @@ define(function (require, exports, module) { expect(panel1.isVisible()).toBeFalse(); }); - it("should not toggle bottom panel back on subsequent escape", async function () { + it("should toggle bottom panel back on subsequent escape", async function () { panel1.show(); expect(panel1.isVisible()).toBeTrue(); @@ -1055,13 +1055,19 @@ define(function (require, exports, module) { SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", _$("#editor-holder")[0]); expect(panel1.isVisible()).toBeFalse(); SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", _$("#editor-holder")[0]); - expect(panel1.isVisible()).toBeFalse(); + expect(panel1.isVisible()).toBeTrue(); }); - it("should shift-escape also collapse bottom panel", async function () { + it("should shift-escape focus active panel instead of collapsing", async function () { panel1.show(); expect(panel1.isVisible()).toBeTrue(); + let focusCalled = false; + panel1.focus = function () { + focusCalled = true; + return true; + }; + expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.js")); await awaitsForDone(promise, "MainViewManager.doOpen"); @@ -1069,14 +1075,34 @@ define(function (require, exports, module) { SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", _$("#editor-holder")[0], { shiftKey: true }); - expect(panel1.isVisible()).toBeFalse(); + // Shift+Escape should focus the panel, not collapse it + expect(panel1.isVisible()).toBeTrue(); + expect(focusCalled).toBeTrue(); + delete panel1.focus; }); - it("should shift-escape collapse bottom panel regardless of canBeShown", async function () { + it("should shift-escape from panel focus the editor", async function () { + expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); + promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.js")); + await awaitsForDone(promise, "MainViewManager.doOpen"); + panel1.show(); - panel2.registerCanBeShownHandler(function () { - return false; - }); + expect(panel1.isVisible()).toBeTrue(); + + // Blur the editor so getFocusedEditor() returns null + _$("#some-panel1")[0].setAttribute("tabindex", "-1"); + _$("#some-panel1")[0].focus(); + expect(EditorManager.getFocusedEditor()).toBeFalsy(); + + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", + _$("#editor-holder")[0], { shiftKey: true }); + // Editor should regain focus + expect(EditorManager.getFocusedEditor()).toBeTruthy(); + }); + + it("should shift-escape open default panel when no panel is visible", async function () { + panel1.hide(); + panel2.hide(); expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.js")); @@ -1085,8 +1111,62 @@ define(function (require, exports, module) { SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", _$("#editor-holder")[0], { shiftKey: true }); - expect(panel1.isVisible()).toBeFalse(); - panel2.registerCanBeShownHandler(null); + // A panel should now be visible (the default/quick access panel) + let defaultPanel = WorkspaceManager.getPanelForID(WorkspaceManager.DEFAULT_PANEL_ID); + expect(defaultPanel.isVisible()).toBeTrue(); + defaultPanel.hide(); + }); + + it("should shift-escape focus panel with custom focus handler", async function () { + // Create a panel with a text input that accepts focus + let focusPanelTemplate = `
+ +
`; + let focusPanel = WorkspaceManager.createBottomPanel("focusTestPanel", + _$(focusPanelTemplate), 100); + + focusPanel.focus = function () { + _$("#focus-test-input")[0].focus(); + return true; + }; + + focusPanel.show(); + expect(focusPanel.isVisible()).toBeTrue(); + + expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); + promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.js")); + await awaitsForDone(promise, "MainViewManager.doOpen"); + + // Shift+Escape from editor should focus the panel's text input + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", + _$("#editor-holder")[0], { shiftKey: true }); + expect(testWindow.document.activeElement).toBe(_$("#focus-test-input")[0]); + + // Shift+Escape from panel should focus the editor + SpecRunnerUtils.simulateKeyEvent(KeyEvent.DOM_VK_ESCAPE, "keydown", + _$("#editor-holder")[0], { shiftKey: true }); + expect(EditorManager.getFocusedEditor()).toBeTruthy(); + + focusPanel.hide(); + WorkspaceManager.destroyBottomPanel("focusTestPanel"); + }); + + it("should app-drawer-button toggle the default panel", async function () { + panel1.hide(); + panel2.hide(); + let defaultPanel = WorkspaceManager.getPanelForID(WorkspaceManager.DEFAULT_PANEL_ID); + if (defaultPanel.isVisible()) { + defaultPanel.hide(); + } + expect(defaultPanel.isVisible()).toBeFalse(); + + // Click app-drawer-button to show the default panel + _$("#app-drawer-button").click(); + expect(defaultPanel.isVisible()).toBeTrue(); + + // Click again to hide it + _$("#app-drawer-button").click(); + expect(defaultPanel.isVisible()).toBeFalse(); }); it("should escape collapse bottom panel regardless of canBeShown", async function () { From 3d9b54be58c63180a835e2ab65a20623dd35dfdb Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 1 Mar 2026 13:36:37 +0530 Subject: [PATCH 26/36] fix(test): fix codehints test using HTML file for reliable hints The codehints visibility test used test.js which only contains a comment, so no code hints were available at position (0,0). Changed to test.html with cursor at (8,1) inside a tag name where HTML hints reliably appear. Also removed debug logging and added focus assertion to custom handler test. --- test/spec/MainViewManager-integ-test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js index 9a32deb463..8fee8c4ea1 100644 --- a/test/spec/MainViewManager-integ-test.js +++ b/test/spec/MainViewManager-integ-test.js @@ -1013,10 +1013,11 @@ define(function (require, exports, module) { expect(panel1.isVisible()).toBeTrue(); expect(MainViewManager.getActivePaneId()).toEqual("first-pane"); - promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.js")); + promise = MainViewManager._open(MainViewManager.FIRST_PANE, FileSystem.getFileForPath(testPath + "/test.html")); await awaitsForDone(promise, "MainViewManager.doOpen"); let editor = EditorManager.getActiveEditor(); - editor.setCursorPos(0, 0); + // Position cursor inside the

Date: Sun, 1 Mar 2026 16:07:53 +0530 Subject: [PATCH 27/36] fix(terminal): prevent garbled output when resizing terminal panel Suppress the automatic PTY resize during fitAddon.fit() and clear only the prompt line to avoid duplicate/garbled output caused by readline redrawing the prompt on top of xterm's reflowed buffer. Add ResizeObserver for reliable resize detection and skip redundant fits when dimensions haven't changed. --- .../Terminal/TerminalInstance.js | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js index 50f9afa71b..2e168d613b 100644 --- a/src/extensionsIntegrated/Terminal/TerminalInstance.js +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -104,6 +104,10 @@ define(function (require, exports, module) { this.searchAddon = null; this.$container = null; this._resizeTimeout = null; + this._resizeObserver = null; + this._lastCols = 0; + this._lastRows = 0; + this._suppressPtyResize = false; this._disposed = false; // Bound event handlers for cleanup @@ -153,6 +157,12 @@ define(function (require, exports, module) { // Fit to container this._fit(); + // Use ResizeObserver for reliable resize detection + this._resizeObserver = new ResizeObserver(() => { + this.handleResize(); + }); + this._resizeObserver.observe(this.$container[0]); + // Set up custom key handler to intercept editor shortcuts this.terminal.attachCustomKeyEventHandler(this._customKeyHandler.bind(this)); @@ -165,9 +175,9 @@ define(function (require, exports, module) { } }); - // Wire resize: terminal -> PTY + // Wire resize: terminal -> PTY (suppressed during _fit to control timing) this.terminal.onResize(({cols, rows}) => { - if (this.isAlive) { + if (this.isAlive && !this._suppressPtyResize) { this.nodeConnector.execPeer("resizeTerminal", {id: this.id, cols, rows}).catch((err) => { console.error("Terminal: resize error:", err); }); @@ -271,12 +281,43 @@ define(function (require, exports, module) { }; /** - * Fit the terminal to its container + * Fit the terminal to its container. + * Suppresses the automatic PTY resize during fit() and clears the + * prompt area to avoid garbled output caused by readline redrawing + * the prompt on top of xterm's reflowed buffer. + * Historical output above the prompt is preserved. */ TerminalInstance.prototype._fit = function () { if (this.fitAddon && this.$container && this.$container.is(":visible")) { try { + const dims = this.fitAddon.proposeDimensions(); + if (!dims || (dims.cols === this._lastCols && dims.rows === this._lastRows)) { + return; + } + + this._lastCols = dims.cols; + this._lastRows = dims.rows; + + // Suppress automatic PTY resize from onResize handler + this._suppressPtyResize = true; this.fitAddon.fit(); + this._suppressPtyResize = false; + + if (this.isAlive) { + // After reflow, clear only the prompt line and below to + // remove garbled content from the reflow/SIGWINCH conflict. + // Use cursor position after reflow — the cursor sits at the + // end of the (possibly garbled) prompt. Clearing from the + // start of the cursor row preserves all output above. + const cursorY = this.terminal.buffer.active.cursorY; + this.terminal.write("\x1b[" + (cursorY + 1) + ";1H\x1b[J"); + + this.nodeConnector.execPeer("resizeTerminal", { + id: this.id, cols: dims.cols, rows: dims.rows + }).catch((err) => { + console.error("Terminal: resize error:", err); + }); + } } catch (e) { // Container might not be visible yet } @@ -290,7 +331,7 @@ define(function (require, exports, module) { clearTimeout(this._resizeTimeout); this._resizeTimeout = setTimeout(() => { this._fit(); - }, 50); + }, 150); }; /** @@ -361,8 +402,12 @@ define(function (require, exports, module) { this.isAlive = false; } - // Dispose xterm + // Dispose resize observer and xterm clearTimeout(this._resizeTimeout); + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } if (this.terminal) { this.terminal.dispose(); this.terminal = null; From 631cc5bb9b9109bcb0aeef0c57f218663b18be2a Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 1 Mar 2026 17:11:34 +0530 Subject: [PATCH 28/36] fix(terminal): prevent ghost lines when resizing terminal panel Clear the prompt region before xterm.js reflow to prevent ghost lines caused by reflowCursorLine:false (xterm default) which excludes the cursor row from reflow. When widening, wrapped prompt lines cannot merge back, leaving stale fragments visible. The fix walks up through isWrapped lines to find the prompt start, erases that region, then calls fitAddon.fit() inside the write() callback to ensure the erase is applied before reflow. Readline's SIGWINCH redraw then writes a clean prompt at the new width. Also removes the ResizeObserver (redundant with WorkspaceManager events), removes the PTY resize suppression mechanism, and increases the resize debounce to 300ms to avoid intermediate SIGWINCH during drag-resizing. --- .../Terminal/TerminalInstance.js | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js index 2e168d613b..90e2f4aac4 100644 --- a/src/extensionsIntegrated/Terminal/TerminalInstance.js +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -104,10 +104,6 @@ define(function (require, exports, module) { this.searchAddon = null; this.$container = null; this._resizeTimeout = null; - this._resizeObserver = null; - this._lastCols = 0; - this._lastRows = 0; - this._suppressPtyResize = false; this._disposed = false; // Bound event handlers for cleanup @@ -157,12 +153,6 @@ define(function (require, exports, module) { // Fit to container this._fit(); - // Use ResizeObserver for reliable resize detection - this._resizeObserver = new ResizeObserver(() => { - this.handleResize(); - }); - this._resizeObserver.observe(this.$container[0]); - // Set up custom key handler to intercept editor shortcuts this.terminal.attachCustomKeyEventHandler(this._customKeyHandler.bind(this)); @@ -175,9 +165,9 @@ define(function (require, exports, module) { } }); - // Wire resize: terminal -> PTY (suppressed during _fit to control timing) + // Wire resize: terminal -> PTY this.terminal.onResize(({cols, rows}) => { - if (this.isAlive && !this._suppressPtyResize) { + if (this.isAlive) { this.nodeConnector.execPeer("resizeTerminal", {id: this.id, cols, rows}).catch((err) => { console.error("Terminal: resize error:", err); }); @@ -282,56 +272,70 @@ define(function (require, exports, module) { /** * Fit the terminal to its container. - * Suppresses the automatic PTY resize during fit() and clears the - * prompt area to avoid garbled output caused by readline redrawing - * the prompt on top of xterm's reflowed buffer. - * Historical output above the prompt is preserved. + * + * Before reflowing, the prompt area is cleared so that stale wrapped + * text does not survive the reflow as a "ghost" line. xterm.js + * defaults to reflowCursorLine: false, meaning the cursor row is + * excluded from reflow. When the terminal widens, a multi-line + * wrapped prompt cannot merge back into one line; the first part + * remains as a visible ghost. By erasing the prompt region first + * (walking up through isWrapped lines), the reflow has nothing + * stale to preserve, and readline's SIGWINCH redraw writes a + * clean prompt at the new width. */ TerminalInstance.prototype._fit = function () { - if (this.fitAddon && this.$container && this.$container.is(":visible")) { - try { - const dims = this.fitAddon.proposeDimensions(); - if (!dims || (dims.cols === this._lastCols && dims.rows === this._lastRows)) { - return; - } + if (!this.fitAddon || !this.$container || !this.$container.is(":visible")) { + return; + } - this._lastCols = dims.cols; - this._lastRows = dims.rows; - - // Suppress automatic PTY resize from onResize handler - this._suppressPtyResize = true; - this.fitAddon.fit(); - this._suppressPtyResize = false; - - if (this.isAlive) { - // After reflow, clear only the prompt line and below to - // remove garbled content from the reflow/SIGWINCH conflict. - // Use cursor position after reflow — the cursor sits at the - // end of the (possibly garbled) prompt. Clearing from the - // start of the cursor row preserves all output above. - const cursorY = this.terminal.buffer.active.cursorY; - this.terminal.write("\x1b[" + (cursorY + 1) + ";1H\x1b[J"); - - this.nodeConnector.execPeer("resizeTerminal", { - id: this.id, cols: dims.cols, rows: dims.rows - }).catch((err) => { - console.error("Terminal: resize error:", err); - }); + try { + // Clear the prompt region before reflow to prevent ghost lines. + // xterm.js write() is asynchronous, so we must wait for the + // clear to be processed before calling fit(). + if (this.terminal && this.isAlive) { + const buf = this.terminal.buffer.active; + let promptStart = buf.cursorY; + + // Walk upward through wrapped lines to find prompt start + while (promptStart > 0) { + const line = buf.getLine(buf.baseY + promptStart); + if (!line || !line.isWrapped) { + break; + } + promptStart--; } - } catch (e) { - // Container might not be visible yet + + // Erase from prompt start to end of screen, then fit + // once the erase has been applied to the buffer. + this.terminal.write( + "\x1b[" + (promptStart + 1) + ";1H\x1b[J", + () => { + try { + this.fitAddon.fit(); + } catch (e) { + // Container might not be visible yet + } + } + ); + return; } + + this.fitAddon.fit(); + } catch (e) { + // Container might not be visible yet } }; /** - * Handle container resize - debounced + * Handle container resize — debounced so that only the final size + * triggers a reflow + PTY resize. This avoids garbled prompts from + * intermediate SIGWINCH signals during continuous drag-resizing. */ TerminalInstance.prototype.handleResize = function () { clearTimeout(this._resizeTimeout); this._resizeTimeout = setTimeout(() => { this._fit(); - }, 150); + }, 300); }; /** @@ -402,12 +406,8 @@ define(function (require, exports, module) { this.isAlive = false; } - // Dispose resize observer and xterm + // Dispose xterm clearTimeout(this._resizeTimeout); - if (this._resizeObserver) { - this._resizeObserver.disconnect(); - this._resizeObserver = null; - } if (this.terminal) { this.terminal.dispose(); this.terminal = null; From 8f7f6484fbc0f474a26aba9699df02e8cca87093 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 1 Mar 2026 17:35:31 +0530 Subject: [PATCH 29/36] fix(test): fix intermittent terminal and PreferencesManager test timeouts Terminal test: replace unreliable PS1-based terminal title check with direct `pwd` buffer output check. The title check depended on the shell having PS1 escape sequences that set the terminal title, which varies across CI environments. PreferencesManager test: increase awaitsFor timeouts from the default 2s to 10s in _verifySinglePreference, since loading projects and applying preferences can be slow in CI. --- test/spec/PreferencesManager-integ-test.js | 6 +-- test/spec/Terminal-integ-test.js | 43 ++++++++++++++-------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/test/spec/PreferencesManager-integ-test.js b/test/spec/PreferencesManager-integ-test.js index f7c63336df..a686578522 100644 --- a/test/spec/PreferencesManager-integ-test.js +++ b/test/spec/PreferencesManager-integ-test.js @@ -58,20 +58,20 @@ define(function (require, exports, module) { await awaitsForDone(SpecRunnerUtils.openProjectFiles(fileName)); await awaitsFor(()=>{ return PreferencesManager.get("spaceUnits") === expectedSpaceUnits; - }, "space units to be "+expectedSpaceUnits); + }, "space units to be "+expectedSpaceUnits, 10000); await awaitsForDone(FileViewController.openAndSelectDocument(nonProjectFile, FileViewController.WORKING_SET_VIEW)); await awaitsFor(()=>{ return PreferencesManager.get("spaceUnits") !== expectedSpaceUnits; - }, "space non project file units not to be "+expectedSpaceUnits); + }, "space non project file units not to be "+expectedSpaceUnits, 10000); // Changing projects will force a change in the project scope. await SpecRunnerUtils.loadProjectInTestWindow(projectWithoutSettings); await awaitsForDone(SpecRunnerUtils.openProjectFiles("file_one.js")); await awaitsFor(()=>{ return PreferencesManager.get("spaceUnits") !== expectedSpaceUnits; - }, "space units not to be "+expectedSpaceUnits); + }, "space units not to be "+expectedSpaceUnits, 10000); } it("should find .phcode.json preferences in the project", async function () { diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 508fad684b..b073f7749d 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -222,29 +222,40 @@ define(function (require, exports, module) { ).attr("title") || ""; expect(flyoutTitle).not.toBe(""); } else { - // On Linux, bash/zsh set the title to - // include the cwd (e.g. "user@host: /path"). - // Wait for shell ready first, then trigger - // a prompt refresh so PS1 title escapes fire. + // On Linux, verify the cwd by running `pwd` + // and checking the terminal buffer. This is + // more reliable than checking the terminal + // title, which depends on the shell's PS1 + // including title-setting escape sequences. await waitForShellReady(); - await writeToTerminal("echo\r"); + await writeToTerminal("pwd\r"); const expectedPath = getNativeProjectPath(); const projectDirName = expectedPath .split("/").pop().split("\\").pop(); + const termModule = testWindow.brackets + .getModule( + "extensionsIntegrated/Terminal/main" + ); await awaitsFor(function () { - const title = testWindow.$( - ".terminal-flyout-item.active" - ).attr("title") || ""; - return title.indexOf(projectDirName) !== -1; - }, "terminal title to contain project dir", - 10000); - - const flyoutTitle = testWindow.$( - ".terminal-flyout-item.active" - ).attr("title") || ""; - expect(flyoutTitle).toContain(projectDirName); + const active = + termModule._getActiveTerminal(); + if (!active) { + return false; + } + const buffer = + active.terminal.buffer.active; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line && line.translateToString() + .indexOf(projectDirName) !== -1) { + return true; + } + } + return false; + }, "pwd output to contain project dir", + 15000); } }); From 1b924e27c23ce867c3e40580ce3f8f430d5e4106 Mon Sep 17 00:00:00 2001 From: abose Date: Sun, 1 Mar 2026 17:45:48 +0530 Subject: [PATCH 30/36] chore: add no-autocommit rule to CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3ad2536b34..c1c067deb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # Claude Code Instructions ## Git Commits +- **Never commit unless the user explicitly asks you to commit or grants autocommit permission.** Only exception: if a commit is technically required for the current task to work (e.g. testing a CI pipeline). - Use Conventional Commits format: `type(scope): description` (e.g. `fix: ...`, `feat: ...`, `chore: ...`). - Keep commit subject lines concise; use the body for detail. - Never include `Co-Authored-By` lines in commit messages. From 33ae1778ec8af7a2fe1c4f3b135998da865873da Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 11:32:33 +0530 Subject: [PATCH 31/36] fix(test): fix FileFilters exclude test race condition in Firefox The awaitsFor loop only checked that CSS results were absent but did not wait for HTML results to be populated. In Firefox on CI the search can remove CSS matches before finishing HTML indexing, causing the subsequent expect to see undefined. Add the positive HTML check to both awaitsFor conditions, matching the pattern already used in the filter-switching test. --- test/spec/FileFilters-integ-test.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/spec/FileFilters-integ-test.js b/test/spec/FileFilters-integ-test.js index 47fcbf00e5..062cf202ae 100644 --- a/test/spec/FileFilters-integ-test.js +++ b/test/spec/FileFilters-integ-test.js @@ -214,7 +214,8 @@ define(function (require, exports, module) { await openSearchBar(); await awaitsFor(async ()=>{ await executeCleanSearch("{1}"); - return !FindInFiles.searchModel.results[testPath + "/test1.css"]; + return !FindInFiles.searchModel.results[testPath + "/test1.css"] && + !!FindInFiles.searchModel.results[testPath + "/test1.html"]; // retry as instant/deferred searches can race with the explicit search }, "Search to exclude css results", 7000, 300); // *.css should have been excluded this time @@ -329,7 +330,8 @@ define(function (require, exports, module) { _setExcludeFiles("*.css"); await awaitsFor(async ()=>{ await executeCleanSearch("{1}"); - return !FindInFiles.searchModel.results[testPath + "/test1.css"]; + return !FindInFiles.searchModel.results[testPath + "/test1.css"] && + !!FindInFiles.searchModel.results[testPath + "/test1.html"]; // retry as instant/deferred searches can race with the explicit search }, "Search to exclude css results", 7000, 300); expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy(); From 416b4a48e81ae6cb7fc171acb3b9fb6e4f6436e3 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 11:35:10 +0530 Subject: [PATCH 32/36] fix(test): fix terminal integration test timeout on Mac CI Await _createNewTerminal() in _showTerminal() so the VIEW_TERMINAL command completes only after PTY spawn finishes, eliminating a race where waitForShellReady() started its timer before the shell was spawned. Also add a fail-fast isAlive check in waitForShellReady() and an afterEach cleanup for leftover dialogs. --- src/extensionsIntegrated/Terminal/main.js | 4 ++-- test/spec/Terminal-integ-test.js | 28 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 5bd5948e91..2b16feac0a 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -541,9 +541,9 @@ define(function (require, exports, module) { * are 2+ terminals, cycles to the next one. Otherwise just shows and * focuses the active terminal. */ - function _showTerminal() { + async function _showTerminal() { if (terminalInstances.length === 0) { - _createNewTerminal(); + await _createNewTerminal(); return; } const active = _getActiveTerminal(); diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index b073f7749d..9cc256f62e 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, awaitsFor, spyOn */ +/*global describe, it, expect, beforeAll, afterAll, afterEach, awaitsFor, spyOn */ define(function (require, exports, module) { @@ -81,6 +81,22 @@ define(function (require, exports, module) { await SpecRunnerUtils.closeTestWindow(); }, 30000); + afterEach(async function () { + // If a test failed and left a dialog open, dismiss it + // so the next test starts from a clean state. + if (testWindow && isDialogOpen()) { + testWindow.$(".modal.instance .dialog-button") + .last().click(); + try { + await awaitsFor(function () { + return !isDialogOpen(); + }, "dialog to close", 3000); + } catch (e) { + // ignore — best-effort cleanup + } + } + }); + // --- Helpers --- async function openTerminal() { @@ -136,6 +152,16 @@ define(function (require, exports, module) { * shell name (e.g. "bash") once process info is fetched. */ async function waitForShellReady() { + const termModule = testWindow.brackets.getModule( + "extensionsIntegrated/Terminal/main" + ); + // Fail fast if the PTY never started + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return active && active.isAlive; + }, "terminal PTY to be alive", 10000); + + // Then wait for process info await awaitsFor(function () { triggerFlyoutRefresh(); const title = testWindow.$( From 8bffb1e2b358c99402bbadb9d890916d175e9f82 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 12:56:47 +0530 Subject: [PATCH 33/36] fix(terminal): prevent prompt from being erased on tab switch and creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ghost-line resize fix in _fit() unconditionally erased the prompt region before reflowing. This caused two bugs: 1. Tab switching: show() called _fit() which erased the prompt, but dimensions hadn't changed so fitAddon.fit() was a no-op — no SIGWINCH fired and the shell never redrew the prompt. 2. New terminal (panel hidden): spawn() used proposeDimensions() to create the PTY at the actual container size while xterm was still at default 80×24. The later _fit() saw a dimension mismatch, erased the prompt, and resized xterm — but the PTY was already at that size so SIGWINCH was a no-op. Fix: only erase the prompt region when dimensions are actually changing, and call fitAddon.fit() before spawn() so xterm and PTY start at the same size. --- .../Terminal/TerminalInstance.js | 58 +++++++++++-------- src/extensionsIntegrated/Terminal/main.js | 7 +++ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js index 90e2f4aac4..837a90458b 100644 --- a/src/extensionsIntegrated/Terminal/TerminalInstance.js +++ b/src/extensionsIntegrated/Terminal/TerminalInstance.js @@ -289,35 +289,43 @@ define(function (require, exports, module) { } try { - // Clear the prompt region before reflow to prevent ghost lines. - // xterm.js write() is asynchronous, so we must wait for the - // clear to be processed before calling fit(). + // Only clear the prompt region when dimensions are actually + // changing — i.e. a real reflow will happen. When dimensions + // are unchanged (e.g. tab switch, panel re-focus) clearing + // would erase the prompt without a subsequent SIGWINCH to + // make the shell redraw it. if (this.terminal && this.isAlive) { - const buf = this.terminal.buffer.active; - let promptStart = buf.cursorY; - - // Walk upward through wrapped lines to find prompt start - while (promptStart > 0) { - const line = buf.getLine(buf.baseY + promptStart); - if (!line || !line.isWrapped) { - break; + const proposed = this.fitAddon.proposeDimensions(); + const dimensionsChanged = proposed && + (proposed.cols !== this.terminal.cols || proposed.rows !== this.terminal.rows); + + if (dimensionsChanged) { + const buf = this.terminal.buffer.active; + let promptStart = buf.cursorY; + + // Walk upward through wrapped lines to find prompt start + while (promptStart > 0) { + const line = buf.getLine(buf.baseY + promptStart); + if (!line || !line.isWrapped) { + break; + } + promptStart--; } - promptStart--; - } - // Erase from prompt start to end of screen, then fit - // once the erase has been applied to the buffer. - this.terminal.write( - "\x1b[" + (promptStart + 1) + ";1H\x1b[J", - () => { - try { - this.fitAddon.fit(); - } catch (e) { - // Container might not be visible yet + // Erase from prompt start to end of screen, then fit + // once the erase has been applied to the buffer. + this.terminal.write( + "\x1b[" + (promptStart + 1) + ";1H\x1b[J", + () => { + try { + this.fitAddon.fit(); + } catch (e) { + // Container might not be visible yet + } } - } - ); - return; + ); + return; + } } this.fitAddon.fit(); diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 2b16feac0a..e8bdd28463 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -306,6 +306,13 @@ define(function (require, exports, module) { panel.show(); } + // Fit the terminal now that the panel is visible so xterm + // has the correct dimensions before the PTY is spawned. + // Without this, xterm stays at default 80x24 while the PTY + // is created at the actual container size, causing a later + // _fit() to erase the prompt without a real resize/SIGWINCH. + try { instance.fitAddon.fit(); } catch (e) { /* not ready */ } + // Spawn PTY process await instance.spawn(); } From 55ac2f8ef5df8f47341822474abb8c883bd25875 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 13:27:01 +0530 Subject: [PATCH 34/36] fix(terminal): add postinstall chmod for node-pty spawn-helper on macOS Workaround for node-pty #850: the prebuilt spawn-helper binary ships without execute permissions in the npm tarball, causing PTY spawns to fail with EACCES on macOS CI. This postinstall script ensures +x is set after npm install. Remove once node-pty >=1.2.0 is available. --- src-node/package-lock.json | 1 + src-node/package.json | 1 + src-node/postinstall.js | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 src-node/postinstall.js diff --git a/src-node/package-lock.json b/src-node/package-lock.json index eaf532e16d..ebbaa71bca 100644 --- a/src-node/package-lock.json +++ b/src-node/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@phcode/node-core", "version": "5.1.5-0", + "hasInstallScript": true, "license": "GNU-AGPL3.0", "dependencies": { "@anthropic-ai/claude-code": "^1.0.0", diff --git a/src-node/package.json b/src-node/package.json index 9279626554..5aca500069 100644 --- a/src-node/package.json +++ b/src-node/package.json @@ -8,6 +8,7 @@ "homepage": "https://github.com/phcode-dev/phoenix", "license": "GNU-AGPL3.0", "scripts": { + "postinstall": "node postinstall.js", "_watch_src-node": "cd .. && npm run _watch_src-node" }, "engines": { diff --git a/src-node/postinstall.js b/src-node/postinstall.js new file mode 100644 index 0000000000..8e11191985 --- /dev/null +++ b/src-node/postinstall.js @@ -0,0 +1,24 @@ +const fs = require("fs"); +const path = require("path"); + +// Workaround for node-pty #850: spawn-helper ships without +x in npm tarball. +// Fixed in node-pty >=1.2.0; remove this script once we upgrade. +if (process.platform === "darwin") { + const candidates = ["darwin-arm64", "darwin-x64"]; + for (const dir of candidates) { + const helperPath = path.join( + __dirname, "node_modules", "node-pty", + "prebuilds", dir, "spawn-helper" + ); + try { + fs.chmodSync(helperPath, 0o755); + console.log(`postinstall: chmod 755 ${helperPath}`); + } catch (e) { + if (e.code === "ENOENT") { + console.log(`postinstall: spawn-helper not found for ${dir} (expected on other arch)`); + } else { + console.error(`postinstall: failed to chmod spawn-helper for ${dir}: ${e.message}`); + } + } + } +} From d06e2b1d4c2c5a4b3ee08f3b7bfc77719a7f124f Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 13:36:58 +0530 Subject: [PATCH 35/36] fix(terminal): clip xterm canvas overflow during panel resize The xterm canvas has fixed pixel dimensions set by fitAddon.fit(). During drag-resize the terminal content area shrinks but the canvas retains its old size, visually overflowing into the live preview. Add overflow: hidden to .terminal-content-area so the canvas is clipped instantly while the debounced fit adjusts it after the drag. --- src/styles/Extn-Terminal.less | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less index db7b298c67..ac253797c5 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -362,6 +362,7 @@ position: relative; background: var(--terminal-background); min-width: 0; + overflow: hidden; } .terminal-instance-container { From bb19d50cb81d3ece831f2c98d35fabd7a69182a1 Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 2 Mar 2026 13:59:29 +0530 Subject: [PATCH 36/36] fix(mcp): dismantle trust ring before reload in phoenix-builder The MCP reload handlers called location.reload() without dismantling the Tauri trust ring first. This left stale keys in place, preventing the reloaded page from establishing a new trust ring. trust_ring.js now passes its reference to phoenix-builder via a write-once setKernalModeTrust setter. A shared _dismantleTrustRing helper awaits dismantleKeyring() with a 5s timeout before reloading. --- src/phoenix-builder/phoenix-builder-boot.js | 35 +++++++++++++++++-- src/phoenix-builder/phoenix-builder-client.js | 3 +- src/phoenix/trust_ring.js | 5 +++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/phoenix-builder/phoenix-builder-boot.js b/src/phoenix-builder/phoenix-builder-boot.js index f3d5269d00..91eb38b2d0 100644 --- a/src/phoenix-builder/phoenix-builder-boot.js +++ b/src/phoenix-builder/phoenix-builder-boot.js @@ -52,6 +52,26 @@ const RECONNECT_MAX_MS = 5000; const DEFAULT_WS_URL = "ws://localhost:38571"; + // --- Trust ring reference (set later via setKernalModeTrust) --- + let _kernalModeTrust = null; + + /** + * Dismantle the trust ring before reload. Awaits up to 5s, ignores errors. + * @return {Promise} + */ + async function _dismantleTrustRing() { + try { + if (_kernalModeTrust && _kernalModeTrust.dismantleKeyring) { + await Promise.race([ + _kernalModeTrust.dismantleKeyring(), + new Promise(resolve => setTimeout(resolve, 5000)) + ]); + } + } catch (e) { + console.error("Error dismantling trust ring before reload:", e); + } + } + // --- State --- let ws = null; let logBuffer = []; @@ -359,7 +379,8 @@ id: msg.id, success: true }); - setTimeout(function () { + setTimeout(async function () { + await _dismantleTrustRing(); location.reload(); }, 100); }); @@ -385,6 +406,9 @@ }); // --- Expose API for AMD module --- + // Phoenix builder should never be in prod builds as it exposes the kernal mode trust. + // for prod mcp controls, we need to expose this with another framework that has + // restricted access to the trust framework. window._phoenixBuilder = { connect: connect, disconnect: disconnect, @@ -392,7 +416,14 @@ getInstanceName: getInstanceName, sendMessage: sendMessage, registerHandler: registerHandler, - getLogBuffer: function () { return capturedLogs.slice(); } + getLogBuffer: function () { return capturedLogs.slice(); }, + dismantleTrustRing: _dismantleTrustRing, + // Called once by trust_ring.js to pass the trust ring reference + // before it is nuked from window. Set-only, no getter. + setKernalModeTrust: function (trust) { + _kernalModeTrust = trust; + delete window._phoenixBuilder.setKernalModeTrust; + } }; // --- Auto-connect --- diff --git a/src/phoenix-builder/phoenix-builder-client.js b/src/phoenix-builder/phoenix-builder-client.js index a6316f57a1..762e9dc341 100644 --- a/src/phoenix-builder/phoenix-builder-client.js +++ b/src/phoenix-builder/phoenix-builder-client.js @@ -76,7 +76,8 @@ define(function (require, exports, module) { id: msg.id, success: true }); - setTimeout(function () { + setTimeout(async function () { + await boot.dismantleTrustRing(); location.reload(); }, 100); }) diff --git a/src/phoenix/trust_ring.js b/src/phoenix/trust_ring.js index a99cf78801..43a86c26e5 100644 --- a/src/phoenix/trust_ring.js +++ b/src/phoenix/trust_ring.js @@ -420,6 +420,11 @@ window.KernalModeTrust = { validateDataSignature, reinstallCreds }; +// Pass the trust ring reference to phoenix-builder (MCP) before it is +// nuked from window. The builder needs dismantleKeyring() for reload. +if (window._phoenixBuilder && window._phoenixBuilder.setKernalModeTrust) { + window._phoenixBuilder.setKernalModeTrust(window.KernalModeTrust); +} if(Phoenix.isSpecRunnerWindow){ window.specRunnerTestKernalModeTrust = window.KernalModeTrust; }