Skip to content

Commit 0a69e05

Browse files
committed
feat(terminal): unified expandable flyout tab bar with process info and close confirmation
Replace the dual-list tab bar (icon tabs + separate flyout) with a single unified flyout that contracts to 30px (icon-only) and expands to 160px on hover. This eliminates sync issues between collapsed and expanded states. - Flyout shows process name (bash, vim, etc.) and cwd basename per tab - Process info refreshes on hover via mouseenter on the flyout - Close confirmation dialog when terminal has active child process - Scrollbar inherits app theme, hidden in collapsed mode - Button labels hidden in collapsed mode to prevent text clipping
1 parent 50a40ef commit 0a69e05

4 files changed

Lines changed: 327 additions & 159 deletions

File tree

src/extensionsIntegrated/Terminal/main.js

Lines changed: 154 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ define(function (require, exports, module) {
3636
const ExtensionUtils = require("utils/ExtensionUtils");
3737
const NodeConnector = require("NodeConnector");
3838
const Mustache = require("thirdparty/mustache/mustache");
39+
const Dialogs = require("widgets/Dialogs");
40+
const Strings = require("strings");
41+
const StringUtils = require("utils/StringUtils");
3942

4043
const TerminalInstance = require("./TerminalInstance");
4144
const ShellProfiles = require("./ShellProfiles");
@@ -50,12 +53,31 @@ define(function (require, exports, module) {
5053
const PANEL_ID = "terminal-panel";
5154
const PANEL_MIN_SIZE = 100;
5255

56+
// Shell process names — if the foreground process is one of these, no child is running
57+
const SHELL_NAMES = new Set([
58+
"bash", "zsh", "fish", "sh", "dash", "ksh", "csh", "tcsh",
59+
"pwsh", "powershell", "cmd.exe", "nu", "elvish", "xonsh",
60+
"login"
61+
]);
62+
63+
/**
64+
* Check if a process name is a shell (handles full paths like /bin/bash)
65+
*/
66+
function _isShellProcess(processName) {
67+
if (!processName) {
68+
return true;
69+
}
70+
const basename = processName.split("/").pop().split("\\").pop();
71+
return SHELL_NAMES.has(basename);
72+
}
73+
5374
// State
5475
let panel = null;
5576
let nodeConnector = null;
5677
let terminalInstances = []; // All terminal instances
5778
let activeTerminalId = null; // Currently visible terminal
58-
let $panel, $tabsList, $contentArea, $shellDropdown;
79+
let processInfo = {}; // id -> processName from PTY
80+
let $panel, $contentArea, $shellDropdown, $flyoutList;
5981

6082
/**
6183
* Create a new NodeConnector for terminal communication
@@ -81,18 +103,21 @@ define(function (require, exports, module) {
81103
panel = WorkspaceManager.createBottomPanel(PANEL_ID, $panel, PANEL_MIN_SIZE);
82104

83105
// Cache DOM references
84-
$tabsList = $panel.find(".terminal-tabs-list");
85106
$contentArea = $panel.find(".terminal-content-area");
86107
$shellDropdown = $panel.find(".terminal-shell-dropdown");
108+
$flyoutList = $panel.find(".terminal-flyout-list");
87109

88-
// "+" button always creates a new terminal with the default shell
89-
$panel.find(".terminal-tab-new-btn").on("click", function (e) {
110+
// "+" button creates a new terminal with the default shell
111+
$panel.find(".terminal-flyout-new-btn").on("click", function (e) {
90112
e.stopPropagation();
91113
_createNewTerminal();
92114
});
93115

94116
// Dropdown chevron button toggles shell selector
95-
$panel.find(".terminal-tab-dropdown-btn").on("click", _onDropdownButtonClick);
117+
$panel.find(".terminal-flyout-dropdown-btn").on("click", _onDropdownButtonClick);
118+
119+
// Refresh process info when user hovers over the flyout
120+
$panel.find(".terminal-tab-flyout").on("mouseenter", _refreshAllProcesses);
96121

97122
// Listen for panel resize
98123
WorkspaceManager.on("workspaceUpdateLayout", _handleResize);
@@ -204,10 +229,7 @@ define(function (require, exports, module) {
204229
// Add to list
205230
terminalInstances.push(instance);
206231

207-
// Create tab
208-
_createTab(instance);
209-
210-
// Activate this terminal
232+
// Activate this terminal (also updates flyout)
211233
_activateTerminal(instance.id);
212234

213235
// Show panel if hidden
@@ -220,39 +242,12 @@ define(function (require, exports, module) {
220242
await instance.spawn();
221243
}
222244

223-
/**
224-
* Create a tab element for a terminal instance
225-
*/
226-
function _createTab(instance) {
227-
const $tab = $('<div class="terminal-tab" data-terminal-id="' + instance.id + '" title="' + _escapeHtml(instance.title) + '">' +
228-
'<i class="fa-solid fa-terminal terminal-tab-icon"></i>' +
229-
'<span class="terminal-tab-close"><i class="fa-solid fa-xmark"></i></span>' +
230-
'</div>');
231-
232-
$tab.on("click", function (e) {
233-
if (!$(e.target).closest(".terminal-tab-close").length) {
234-
_activateTerminal(instance.id);
235-
}
236-
});
237-
238-
$tab.find(".terminal-tab-close").on("click", function (e) {
239-
e.stopPropagation();
240-
_closeTerminal(instance.id);
241-
});
242-
243-
$tabsList.append($tab);
244-
}
245-
246245
/**
247246
* Activate a terminal tab (show it, hide others)
248247
*/
249248
function _activateTerminal(id) {
250249
activeTerminalId = id;
251250

252-
// Update tabs
253-
$tabsList.find(".terminal-tab").removeClass("active");
254-
$tabsList.find('.terminal-tab[data-terminal-id="' + id + '"]').addClass("active");
255-
256251
// Show/hide terminal containers
257252
for (const inst of terminalInstances) {
258253
if (inst.id === id) {
@@ -261,23 +256,46 @@ define(function (require, exports, module) {
261256
inst.hide();
262257
}
263258
}
259+
260+
_updateFlyout();
264261
}
265262

266263
/**
267-
* Close a terminal instance
264+
* Close a terminal instance, confirming first if a child process is running
268265
*/
269-
function _closeTerminal(id) {
266+
async function _closeTerminal(id) {
270267
const idx = terminalInstances.findIndex(t => t.id === id);
271268
if (idx === -1) {
272269
return;
273270
}
274271

275272
const instance = terminalInstances[idx];
273+
274+
// Check for active child process before closing
275+
if (instance.isAlive) {
276+
try {
277+
const result = await nodeConnector.execPeer("getTerminalProcess", {id});
278+
const processName = result.process || "";
279+
if (processName && !_isShellProcess(processName)) {
280+
const message = StringUtils.format(
281+
Strings.TERMINAL_CLOSE_CONFIRM_MSG, _escapeHtml(processName)
282+
);
283+
const dialog = Dialogs.showConfirmDialog(
284+
Strings.TERMINAL_CLOSE_CONFIRM_TITLE, message
285+
);
286+
const buttonId = await dialog.getPromise();
287+
if (buttonId !== Dialogs.DIALOG_BTN_OK) {
288+
return;
289+
}
290+
}
291+
} catch (e) {
292+
// Terminal may already be dead; proceed with close
293+
}
294+
}
295+
276296
instance.dispose();
277297
terminalInstances.splice(idx, 1);
278-
279-
// Remove tab
280-
$tabsList.find('.terminal-tab[data-terminal-id="' + id + '"]').remove();
298+
delete processInfo[id];
281299

282300
// If we closed the active terminal, activate another
283301
if (activeTerminalId === id) {
@@ -294,6 +312,8 @@ define(function (require, exports, module) {
294312
panel.hide();
295313
_updateToolbarIcon(false);
296314
}
315+
316+
_updateFlyout();
297317
}
298318

299319
/**
@@ -326,20 +346,105 @@ define(function (require, exports, module) {
326346
}
327347

328348
/**
329-
* Handle terminal title change
349+
* Handle terminal title change — also fetches and displays the foreground process
330350
*/
331351
function _onTerminalTitleChanged(id, title) {
332-
const $tab = $tabsList.find('.terminal-tab[data-terminal-id="' + id + '"]');
333-
$tab.attr("title", title);
352+
_updateFlyout();
353+
_updateTabProcess(id);
354+
}
355+
356+
/**
357+
* Fetch and display the foreground process for a terminal tab
358+
*/
359+
function _updateTabProcess(id) {
360+
const instance = terminalInstances.find(t => t.id === id);
361+
if (!instance || !instance.isAlive) {
362+
return;
363+
}
364+
nodeConnector.execPeer("getTerminalProcess", {id}).then(function (result) {
365+
const newProc = result.process || "";
366+
if (processInfo[id] !== newProc) {
367+
processInfo[id] = newProc;
368+
_updateFlyout();
369+
}
370+
}).catch(function () {
371+
// Terminal may have been closed; ignore
372+
});
373+
}
374+
375+
/**
376+
* Refresh process info for all alive terminals.
377+
* Called on flyout hover so the tab bar is up-to-date when the user looks.
378+
*/
379+
function _refreshAllProcesses() {
380+
for (const inst of terminalInstances) {
381+
if (inst.isAlive) {
382+
_updateTabProcess(inst.id);
383+
}
384+
}
385+
}
386+
387+
/**
388+
* Rebuild the flyout panel to reflect current tabs
389+
*/
390+
/**
391+
* Extract the last directory name from a terminal title.
392+
* Title format is typically "user@host: /path/to/dir" or "user@host: ~/path/to/dir".
393+
*/
394+
function _extractCwdBasename(title) {
395+
const colonIdx = title.indexOf(": ");
396+
const pathPart = colonIdx >= 0 ? title.slice(colonIdx + 2) : title;
397+
const trimmed = pathPart.replace(/\/+$/, "");
398+
const lastSlash = trimmed.lastIndexOf("/");
399+
return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
400+
}
401+
402+
function _updateFlyout() {
403+
$flyoutList.empty();
404+
for (const inst of terminalInstances) {
405+
const proc = processInfo[inst.id] || "";
406+
const basename = proc ? proc.split("/").pop().split("\\").pop() : "";
407+
408+
// Label: process basename; right side: cwd basename; tooltip: full title
409+
const label = basename || "Terminal";
410+
const cwdName = _extractCwdBasename(inst.title);
411+
412+
const $item = $('<div class="terminal-flyout-item"></div>')
413+
.attr("data-terminal-id", inst.id)
414+
.attr("title", inst.title)
415+
.toggleClass("active", inst.id === activeTerminalId);
416+
417+
if (!inst.isAlive) {
418+
$item.css("opacity", "0.6");
419+
}
420+
421+
$item.append('<span class="terminal-flyout-icon"><i class="fa-solid fa-terminal"></i></span>');
422+
$item.append($('<span class="terminal-flyout-title"></span>').text(label));
423+
if (cwdName) {
424+
$item.append($('<span class="terminal-flyout-cwd"></span>').text(cwdName));
425+
}
426+
$item.append('<span class="terminal-flyout-close"><i class="fa-solid fa-xmark"></i></span>');
427+
428+
$item.on("click", function (e) {
429+
if (!$(e.target).closest(".terminal-flyout-close").length) {
430+
_activateTerminal(inst.id);
431+
}
432+
});
433+
$item.find(".terminal-flyout-close").on("click", function (e) {
434+
e.stopPropagation();
435+
_closeTerminal(inst.id);
436+
});
437+
438+
$flyoutList.append($item);
439+
}
334440
}
335441

336442
/**
337443
* Handle terminal process exit
338444
*/
339445
function _onTerminalProcessExit(id, exitCode) {
340-
// Update tab styling to indicate dead process
341-
const $tab = $tabsList.find('.terminal-tab[data-terminal-id="' + id + '"]');
342-
$tab.css("opacity", "0.6");
446+
delete processInfo[id];
447+
_updateFlyout();
343448
}
344449

345450
/**
@@ -412,6 +517,7 @@ define(function (require, exports, module) {
412517
inst.dispose();
413518
}
414519
terminalInstances = [];
520+
processInfo = {};
415521
}
416522

417523
// Register commands
@@ -443,7 +549,7 @@ define(function (require, exports, module) {
443549
ShellProfiles.init(nodeConnector).then(function () {
444550
const shells = ShellProfiles.getShells();
445551
if (shells.length <= 1) {
446-
$panel.find(".terminal-tab-dropdown-btn").addClass("forced-hidden");
552+
$panel.find(".terminal-flyout-dropdown-btn").addClass("forced-hidden");
447553
}
448554
_populateShellDropdown();
449555
});

src/extensionsIntegrated/Terminal/terminal-panel.html

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
<div class="terminal-body">
33
<div class="terminal-content-area"></div>
44
<div class="terminal-tab-bar">
5-
<div class="terminal-tabs-list"></div>
6-
<div class="terminal-new-dropdown-wrapper">
7-
<button class="terminal-tab-new-btn" title="{{Strings.CMD_NEW_TERMINAL}}">
8-
<i class="fa-solid fa-plus"></i>
9-
</button>
10-
<button class="terminal-tab-dropdown-btn" title="Select Default Shell">
11-
<i class="fa-solid fa-chevron-up"></i>
12-
</button>
13-
<div class="terminal-shell-dropdown forced-hidden"></div>
5+
<div class="terminal-tab-flyout">
6+
<div class="terminal-flyout-list"></div>
7+
<div class="terminal-flyout-actions">
8+
<button class="terminal-flyout-new-btn" title="{{Strings.CMD_NEW_TERMINAL}}">
9+
<span class="terminal-flyout-btn-icon"><i class="fa-solid fa-plus"></i></span>
10+
<span class="terminal-btn-label">New Terminal</span>
11+
</button>
12+
<button class="terminal-flyout-dropdown-btn" title="Select Default Shell">
13+
<span class="terminal-flyout-btn-icon"><i class="fa-solid fa-chevron-up"></i></span>
14+
</button>
15+
<div class="terminal-shell-dropdown forced-hidden"></div>
16+
</div>
1417
</div>
1518
</div>
1619
</div>

src/nls/root/strings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,6 +1484,8 @@ define({
14841484
"ERROR_NOTHING_SELECTED": "Nothing is selected!",
14851485
"ERROR_SAVE_FIRST": "Save the document first!",
14861486
"ERROR_TERMINAL_NOT_FOUND": "Terminal was not found for your OS, you can define a custom Terminal command in the settings",
1487+
"TERMINAL_CLOSE_CONFIRM_TITLE": "Active Process Running",
1488+
"TERMINAL_CLOSE_CONFIRM_MSG": "Terminal has an active process running: <b>{0}</b>.<br>Are you sure you want to close it?",
14871489
"EXTENDED_COMMIT_MESSAGE": "EXTENDED",
14881490
"GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026",
14891491
"GIT_COMMIT": "Git commit\u2026",

0 commit comments

Comments
 (0)