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.
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/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}`);
+ }
+ }
+ }
+}
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/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/command/Commands.js b/src/command/Commands.js
index 95a47fcf71..30403d1cbe 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()
@@ -345,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 9e713ecd19..c803191e33 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);
/*
@@ -314,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);
}
@@ -357,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/TerminalInstance.js b/src/extensionsIntegrated/Terminal/TerminalInstance.js
index b196efc814..837a90458b 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;
@@ -266,26 +271,79 @@ define(function (require, exports, module) {
};
/**
- * Fit the terminal to its container
+ * Fit the terminal to its container.
+ *
+ * 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 {
- this.fitAddon.fit();
- } catch (e) {
- // Container might not be visible yet
+ if (!this.fitAddon || !this.$container || !this.$container.is(":visible")) {
+ return;
+ }
+
+ try {
+ // 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 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--;
+ }
+
+ // 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();
- }, 50);
+ }, 300);
};
/**
diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js
index dbe2b75b37..e8bdd28463 100644
--- a/src/extensionsIntegrated/Terminal/main.js
+++ b/src/extensionsIntegrated/Terminal/main.js
@@ -30,16 +30,19 @@ 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");
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");
+ const Menus = require("command/Menus");
+ 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");
@@ -48,8 +51,12 @@ 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 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;
@@ -70,7 +77,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);
}
@@ -81,6 +89,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;
/**
@@ -106,11 +115,27 @@ 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");
$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();
@@ -126,6 +151,19 @@ 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();
+ }
+ _showFocusHintToast();
+ }
+ });
+
// Listen for theme changes via MutationObserver on body class
const observer = new MutationObserver(function () {
_updateAllThemes();
@@ -204,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);
}
}
@@ -254,9 +304,15 @@ define(function (require, exports, module) {
// Show panel if hidden
if (!panel.isVisible()) {
panel.show();
- _updateToolbarIcon(true);
}
+ // 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();
}
@@ -329,7 +385,7 @@ define(function (require, exports, module) {
// If no terminals left, hide the panel
if (terminalInstances.length === 0) {
panel.hide();
- _updateToolbarIcon(false);
+
}
_updateFlyout();
@@ -365,9 +421,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) {
+ function _onTerminalTitleChanged(id) {
+ const instance = terminalInstances.find(t => t.id === id);
+ if (instance) {
+ instance._titleStale = false;
+ }
_updateFlyout();
_updateTabProcess(id);
}
@@ -383,7 +444,19 @@ 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 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();
}
}).catch(function () {
@@ -424,13 +497,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) {
@@ -467,23 +543,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);
+ async function _showTerminal() {
+ if (terminalInstances.length === 0) {
+ await _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();
}
}
}
@@ -508,15 +589,20 @@ define(function (require, exports, module) {
}
/**
- * Update toolbar icon active state
+ * Show a one-time toast hint about Shift+Escape to switch focus
*/
- function _updateToolbarIcon(isActive) {
- const $icon = $("#toolbar-terminal");
- if (isActive) {
- $icon.addClass("selected-button");
- } else {
- $icon.removeClass("selected-button");
+ function _showFocusHintToast() {
+ if (_focusToastShown) {
+ return;
}
+ _focusToastShown = true;
+
+ const shortcutKey = 'Shift+Esc';
+ const message = StringUtils.format(Strings.TERMINAL_FOCUS_HINT, shortcutKey);
+ NotificationUI.showToastOn($contentArea[0], message, {
+ autoCloseTimeS: 5,
+ dismissOnClick: true
+ });
}
/**
@@ -529,7 +615,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) {
@@ -539,15 +626,80 @@ 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("Toggle Terminal", CMD_TOGGLE_TERMINAL, _togglePanel);
+ 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);
+ });
- // Add menu item
- const fileMenu = Menus.getMenu(Menus.AppMenuBar.FILE_MENU);
- if (fileMenu) {
- fileMenu.addMenuItem(CMD_NEW_TERMINAL, null, Menus.AFTER, "file.close");
- }
+ // 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 () {
@@ -558,11 +710,70 @@ 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);
+ // Gate user-initiated panel close (X button): confirm if needed, then
+ // dispose all terminals. Programmatic hide() just collapses the panel
+ // without disposing terminals.
+ panel.registerOnCloseRequestedHandler(async function () {
+ // Query all terminals in parallel to avoid sequential 2s waits on Windows
+ const aliveInstances = terminalInstances.filter(inst => inst.isAlive);
+ const results = await Promise.all(aliveInstances.map(function (inst) {
+ return nodeConnector.execPeer("getTerminalProcess", {id: inst.id})
+ .catch(function () { return {process: ""}; });
+ }));
+ const activeProcesses = [];
+ for (const result of results) {
+ if (result.process && !_isShellProcess(result.process)) {
+ activeProcesses.push(result.process);
+ }
+ }
+
+ 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
+ await _disposeAllAsync();
+ 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 — dispose everything
+ await _disposeAllAsync();
+ activeTerminalId = null;
+ _updateFlyout();
+ return true;
+ });
// Detect shells
ShellProfiles.init(nodeConnector).then(function () {
@@ -581,6 +792,36 @@ 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;
+
+ 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;
+ };
+ }
});
diff --git a/src/index.html b/src/index.html
index 700df9f3c8..f569fc9eb2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -995,11 +995,11 @@
-
+
diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js
index 34ff0b665d..fa76f2918b 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",
@@ -685,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",
@@ -1254,7 +1256,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",
@@ -1487,6 +1489,17 @@ 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_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",
+ "TERMINAL_CLEAR": "Clear Terminal",
"EXTENDED_COMMIT_MESSAGE": "EXTENDED",
"GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026",
"GIT_COMMIT": "Git commit\u2026",
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;
}
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;
}
diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less
index 68512dcdbc..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 {
@@ -390,23 +391,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;
@@ -416,3 +400,4 @@
color: var(--terminal-tab-text);
font-size: 13px;
}
+
diff --git a/src/styles/brackets.less b/src/styles/brackets.less
index 3580935369..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 {
@@ -3381,6 +3387,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/styles/images/app-drawer.svg b/src/styles/images/app-drawer.svg
new file mode 100644
index 0000000000..dd1070f605
--- /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 f6e6edda54..6f5291a45d 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.
@@ -71,7 +72,7 @@ define(function (require, exports, module) {
id: "terminal",
icon: "fa-solid fa-terminal",
label: "Terminal",
- commandID: "terminal.toggle",
+ commandID: Commands.VIEW_TERMINAL,
nativeOnly: true
}
];
@@ -177,13 +178,51 @@ 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.
+ 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"
+ }));
+ }
+ }
+
+ // 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()) {
+ _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();
+ _addTabIcon();
+ }
+ $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");
}
});
diff --git a/src/view/PanelView.js b/src/view/PanelView.js
index 7cd2a4e971..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
*/
@@ -412,6 +441,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
@@ -486,7 +524,7 @@ define(function (require, exports, module) {
if (panelId) {
let panel = _panelMap[panelId];
if (panel) {
- panel.hide();
+ panel.requestClose();
}
}
});
@@ -765,12 +803,43 @@ 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.
+ * @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.getActiveBottomPanel = getActiveBottomPanel;
+ exports.showNextPanel = showNextPanel;
exports.hideAllOpenPanels = hideAllOpenPanels;
exports.exitMaximizeOnResize = exitMaximizeOnResize;
exports.enterMaximizeOnResize = enterMaximizeOnResize;
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
*/
diff --git a/src/view/WorkspaceManager.js b/src/view/WorkspaceManager.js
index dd2c899417..faaad7b894 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");
/**
@@ -212,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();
@@ -389,15 +395,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;
});
@@ -503,12 +501,32 @@ 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)
+ );
+ 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);
recomputeLayout(true);
}
@@ -631,23 +649,70 @@ 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.
+ * @private
+ * @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;
}
- // pressing escape when focused on editor will hide the bottom panel container
+ function _handleEscapeKey() {
+ return _togglePanels();
+ }
+
+ /**
+ * 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
+ let activePanel = PanelView.getActiveBottomPanel();
+ if(!activePanel || !activePanel.isVisible()){
+ _togglePanels();
+ activePanel = PanelView.getActiveBottomPanel();
+ }
+ 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;
@@ -666,9 +731,7 @@ define(function (require, exports, module) {
return;
}
- if (event.keyCode === KeyEvent.DOM_VK_ESCAPE) {
- _handleEscapeKey();
- }
+ _handleEscapeKey();
event.stopPropagation();
event.preventDefault();
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/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/FileFilters-integ-test.js b/test/spec/FileFilters-integ-test.js
index 34ba8017d6..062cf202ae 100644
--- a/test/spec/FileFilters-integ-test.js
+++ b/test/spec/FileFilters-integ-test.js
@@ -207,10 +207,17 @@ 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"] &&
+ !!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
expect(FindInFiles.searchModel.results[testPath + "/test1.css"]).toBeFalsy();
expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy();
@@ -219,7 +226,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 +271,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 +328,12 @@ 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"] &&
+ !!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();
expect(FindInFiles.searchModel.results[testPath + "/test1.html"]).toBeTruthy();
await closeSearchBar();
diff --git a/test/spec/MainViewManager-integ-test.js b/test/spec/MainViewManager-integ-test.js
index a3a895da0b..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
+
+ `;
+ 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();
+ expect(testWindow.document.activeElement).not.toBe(_$("#focus-test-input")[0]);
+
+ 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 () {
@@ -1237,5 +1319,171 @@ 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 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());
+ });
+ });
+
+ 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);
+ });
+ });
});
});
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");
+ });
+ });
});
});
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/SpecRunnerUtils.js b/test/spec/SpecRunnerUtils.js
index 871d3938af..beef9a2aaa 100644
--- a/test/spec/SpecRunnerUtils.js
+++ b/test/spec/SpecRunnerUtils.js
@@ -54,6 +54,30 @@ 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
+ }
+ }
+ // 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() {
return FileSystem;
}
diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js
new file mode 100644
index 0000000000..9cc256f62e
--- /dev/null
+++ b/test/spec/Terminal-integ-test.js
@@ -0,0 +1,814 @@
+/*
+ * 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, afterEach, awaitsFor, spyOn */
+
+define(function (require, exports, module) {
+
+ if (!Phoenix.isNativeApp) {
+ return;
+ }
+
+ const SpecRunnerUtils = require("spec/SpecRunnerUtils");
+ const Strings = require("strings");
+
+ const IS_WINDOWS = Phoenix.platform === "win";
+ const IS_MAC = Phoenix.platform === "mac";
+
+ 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 () {
+ // Dispose all terminal PTY processes before teardown.
+ // panel.hide() keeps terminals alive by design, so we
+ // must explicitly kill them.
+ if (testWindow) {
+ try {
+ const termModule = testWindow.brackets.getModule(
+ "extensionsIntegrated/Terminal/main"
+ );
+ if (termModule && termModule._disposeAll) {
+ await termModule._disposeAll();
+ }
+ } catch (e) {
+ // test window may already be torn down
+ }
+ }
+ const panel = WorkspaceManager
+ ? WorkspaceManager.getPanelForID(PANEL_ID)
+ : null;
+ if (panel && panel.isVisible()) {
+ panel.hide();
+ }
+ testWindow = null;
+ __PR = null;
+ WorkspaceManager = null;
+ 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() {
+ 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();
+ }
+
+ /**
+ * 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() {
+ 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.$(
+ ".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.
+ */
+ 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);
+
+ if (IS_WINDOWS || IS_MAC) {
+ // On Windows, PowerShell/cmd set the title to
+ // 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, 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("pwd\r");
+
+ const expectedPath = getNativeProjectPath();
+ const projectDirName = expectedPath
+ .split("/").pop().split("\\").pop();
+
+ const termModule = testWindow.brackets
+ .getModule(
+ "extensionsIntegrated/Terminal/main"
+ );
+ await awaitsFor(function () {
+ 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);
+ }
+ });
+
+ 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 waitForShellReady();
+
+ 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 waitForShellReady();
+
+ 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 waitForShellReady();
+
+ // Start a long-running node process
+ await writeToTerminal(
+ 'node -e "setTimeout(()=>{},60000)"\r'
+ );
+ await waitForActiveProcess("node");
+
+ 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 waitForShellReady();
+
+ await writeToTerminal(
+ 'node -e "setTimeout(()=>{},60000)"\r'
+ );
+ await waitForActiveProcess("node");
+
+ 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("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);
+
+ // 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
+ 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("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 () {
+ 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);
+ });
+ });
+ });
+});