From 8206c9f5eb1ab790882455677c2a9b4b9a21702f Mon Sep 17 00:00:00 2001 From: abose Date: Mon, 28 Sep 2026 14:38:02 +0530 Subject: [PATCH] feat(terminal): name the tabs, and let the user rename them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tab carried the same terminal icon, which told them apart not at all: open three shells and the strip showed three identical glyphs. The name each tab already knew was hidden in the narrow strip, and shown only when the panel was wide enough. So the icon gives up its slot — the strip widens a little and always shows the name. The name is the running process, which says nothing while several shells sit idle at a prompt, all reading "zsh". A pencil on hover lets the user name a tab themselves; the name shows in italics so it reads as theirs, and emptying the field hands the tab back to the process label. Names are kept in global state, so one means the same thing wherever the user is working, with no project switching to reason about. They are keyed by terminal id, which counts from term_1 in creation order and starts over each run: a name belongs to a tab's place in the strip rather than to one shell, which is as close to remembering terminals as this can get while the panel still recreates none of them on boot. Two details the implementation turns on. The pencil's click is bound to the pencil rather than delegated, so it runs before the row's own handler and can stop it there: let it through and the row activates the terminal, which takes focus straight back off the field — the same reason a double click could never work. And a refresh will not rebuild the rows while a field is open, because emptying the list tears the field out, the removal fires blur, and blur commits, landing a half-typed name nobody confirmed. Hovering trades the cwd for the controls rather than reserving room for them, which leaves the name more space, not less, and spares every future control the margin arithmetic that went stale the moment a second one appeared. --- src/extensionsIntegrated/Terminal/main.js | 131 ++++++++++++++++- src/nls/root/strings.js | 1 + src/styles/Extn-Terminal.less | 104 +++++++++++--- test/spec/Terminal-integ-test.js | 165 ++++++++++++++++++++++ 4 files changed, 377 insertions(+), 24 deletions(-) diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index de17a353ce..be2b98e776 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -40,6 +40,7 @@ define(function (require, exports, module) { const DefaultDialogs = require("widgets/DefaultDialogs"); const Strings = require("strings"); const StringUtils = require("utils/StringUtils"); + const StateManager = require("preferences/StateManager"); const Menus = require("command/Menus"); const Commands = require("command/Commands"); @@ -95,6 +96,11 @@ define(function (require, exports, module) { let _clearHintShown = false; // Show clear buffer hint toast only once per session let _projectPath = null; let _restartingTerminals = false; + // id of the tab whose name is being edited, so background refreshes know + // not to rebuild the list out from under the field + let renamingTerminalId = null; + // where tab names the user typed are kept between runs + const STATE_TAB_NAMES = "terminal.tabNames"; let $panel, $contentArea, $shellDropdown, $flyoutList; /** @@ -350,6 +356,8 @@ define(function (require, exports, module) { const instance = new TerminalInstance(nodeConnector, shell, cwd); // Project ownership is independent of directories the user visits in the shell. instance.projectPath = projectPath || (projectRoot ? projectRoot.fullPath : null); + // a name given to this slot in an earlier run comes back with it + instance.customName = _loadTabNames()[instance.id] || null; // Set up callbacks instance.onTitleChanged = _onTerminalTitleChanged; @@ -690,37 +698,141 @@ define(function (require, exports, module) { return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed; } + /** + * Names the user has given terminal tabs, kept globally rather than per + * project so a name means the same thing wherever they are working. + * + * Keyed by terminal id, which counts up from term_1 in creation order and + * starts over each run: a name therefore belongs to a tab's place in the + * strip rather than to one shell, and the second terminal opened next run + * gets the name the second terminal had. That is as close to "remember my + * terminals" as this can get until the panel restores a session at all — + * it recreates nothing on boot today, so there is no terminal to hand a + * name back to. Names are kept when a tab closes for the same reason: + * dropping them would leave nothing to remember by the next run. + * + * @return {Object} id -> name, empty when nothing has been named + */ + function _loadTabNames() { + const stored = StateManager.get(STATE_TAB_NAMES); + return (stored && typeof stored === "object") ? stored : {}; + } + + /** + * Record or forget one tab's name. + * @param {string} id - terminal id + * @param {?string} name - the name, or null to go back to the process label + */ + function _saveTabName(id, name) { + const names = _loadTabNames(); + if (name) { + names[id] = name; + } else { + delete names[id]; + } + StateManager.set(STATE_TAB_NAMES, names); + } + + /** + * Edit a tab's name in place. + * + * The label is normally the running process, which says nothing once + * several idle shells are open — they all read the same. A name the user + * gives sticks to the tab instead, and clearing it hands the tab back to + * the process label. + * + * The field is built on the live row rather than through _updateFlyout, + * because that rebuilds the list from scratch on every process and title + * change and would take the half-typed field with it. Committing writes to + * the instance and re-renders, so the name survives those rebuilds. + * + * @param {Object} inst - the terminal being renamed + * @param {jQuery} $item - its row in the tab strip + */ + function _beginRename(inst, $item) { + const $title = $item.find(".terminal-flyout-title"); + if (!$title.length || $item.find(".terminal-flyout-rename").length) { + return; + } + const $input = $('') + .val(inst.customName || $title.text().trim()); + $item.addClass("renaming"); + renamingTerminalId = inst.id; + $title.hide().after($input); + $input.trigger("focus").trigger("select"); + + let settled = false; + function finish(commit) { + if (settled) { return; } + settled = true; + renamingTerminalId = null; + if (commit) { + const name = $input.val().trim(); + // an emptied field means "go back to naming it after the process" + inst.customName = name || null; + _saveTabName(inst.id, inst.customName); + Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "rename", + name ? "set" : "clear"); + } + $input.remove(); + $title.show(); + $item.removeClass("renaming"); + _updateFlyout(); + } + $input.on("keydown", function (e) { + // the terminal binds plenty of keys; none of them apply while typing here + e.stopPropagation(); + if (e.key === "Enter") { finish(true); } + if (e.key === "Escape") { finish(false); } + }); + $input.on("blur", function () { finish(true); }); + // a double click inside the field shouldn't re-enter renaming + $input.on("dblclick click", function (e) { e.stopPropagation(); }); + } + function _updateFlyout() { + // A rebuild empties the list, which tears the rename field out of the + // document mid-edit; the removal fires blur, and blur commits, so a + // half-typed name lands on the tab without the user ever confirming it. + // Background refreshes can wait the few seconds an edit takes — + // _beginRename renders once it is done. + if (renamingTerminalId) { + return; + } $flyoutList.empty(); for (const inst of terminalInstances) { const proc = processInfo[inst.id] || ""; const basename = proc ? proc.split("/").pop().split("\\").pop() : ""; - // Label: process basename; right side: cwd basename; tooltip: full title. + // Label: the user's own name when they gave one, otherwise the + // 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 label = inst.customName || basename || "Terminal"; const displayTitle = inst._titleStale ? inst.shellProfile.name : inst.title; const cwdName = _extractCwdBasename(displayTitle); const $item = $('
') .attr("data-terminal-id", inst.id) .attr("title", displayTitle) - .toggleClass("active", inst.id === activeTerminalId); + .toggleClass("active", inst.id === activeTerminalId) + // a name the user typed reads as theirs, not as what we detected + .toggleClass("renamed", !!inst.customName); if (!inst.isAlive) { $item.css("opacity", "0.6"); } $item.append(''); - $item.append(''); + $item.append(''); $item.append($('').text(label)); if (cwdName) { $item.append($('').text(cwdName)); } $item.on("click", function (e) { - if (!$(e.target).closest(".terminal-flyout-close").length) { + if (!$(e.target).closest(".terminal-flyout-close, .terminal-flyout-edit").length) { _activateTerminal(inst.id); } }); @@ -728,6 +840,15 @@ define(function (require, exports, module) { e.stopPropagation(); _closeTerminal(inst.id); }); + // Bound on the pencil itself, like the close above, so it runs + // before the row's own handler and can stop the click there. Let it + // through and the row activates the terminal, which takes focus off + // the field and rebuilds the list out from under it. + $item.find(".terminal-flyout-edit").on("click", function (e) { + e.preventDefault(); + e.stopPropagation(); + _beginRename(inst, $item); + }); $flyoutList.append($item); } diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 5aeb05bf56..cd9b8c7be6 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2228,6 +2228,7 @@ define({ "ERROR_NOTHING_SELECTED": "Nothing is selected!", "ERROR_SAVE_FIRST": "Save the document first!", "ERROR_TERMINAL_NOT_FOUND": "Terminal was not found for your OS, you can define a custom Terminal command in the settings", + "TERMINAL_RENAME_TAB": "Rename this terminal", "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?", diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less index e0b6d9de6c..0db9f37f51 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -101,8 +101,10 @@ /* Tab bar: static sidebar, width switches via .terminal-tabs-expanded on container */ .terminal-tab-bar { position: relative; - width: 60px; - min-width: 60px; + /* wide enough for a short name; the strip shows names rather than a row of + identical terminal icons, which told the tabs apart not at all */ + width: 84px; + min-width: 84px; } .terminal-tabs-expanded .terminal-tab-bar { @@ -171,24 +173,12 @@ box-shadow: inset 2px 0 0 #007acc; } -/* Icon column: fixed 30px slot */ -.terminal-flyout-icon { - width: 30px; - min-width: 30px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - flex-shrink: 0; -} - -/* Title & CWD: hidden in collapsed, shown in expanded */ +/* Title always shows; the CWD is what collapsed drops for want of room */ .terminal-flyout-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; - display: none; } .terminal-flyout-cwd { @@ -231,10 +221,6 @@ } /* ─── Expanded mode ─── */ -.terminal-tabs-expanded .terminal-flyout-title { - display: block; -} - .terminal-tabs-expanded .terminal-flyout-cwd { display: block; } @@ -244,6 +230,86 @@ margin-right: 20px; } +/* ─── Tab name and its hover controls ─── */ +/* The row always carries its name. The icon it replaces was the same glyph on + every row, so it said nothing the name does not say better. */ +.terminal-flyout-title { + padding-left: 8px; +} + +/* A name the user typed, set apart from the process name detected for them */ +.terminal-flyout-item.renamed .terminal-flyout-title { + font-style: italic; +} + +/* Rename pencil: sits inboard of the close, so both hover controls share the + right edge and the name's left edge never shifts under the pointer. */ +.terminal-flyout-edit { + position: absolute; + right: 20px; + top: 0; + bottom: 0; + width: 20px; + display: none; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--terminal-tab-text); + background: transparent; + z-index: 1; + cursor: pointer; +} + +.terminal-flyout-item:hover .terminal-flyout-edit { + display: flex; +} + +.terminal-flyout-edit:hover { + color: var(--terminal-tab-active-text); + background: rgba(255, 255, 255, 0.1); +} + +/* Hovering trades the cwd for the controls. Reserving room for them instead + would mean retuning this margin for every control ever added to the right + edge — which is how the 20px above went stale the moment a second one + appeared — and it leaves the name more room, not less. */ +.terminal-flyout-item:hover .terminal-flyout-cwd { + display: none; +} + +.terminal-flyout-item:hover .terminal-flyout-title { + padding-right: 40px; +} + +/* Rename field, sized to sit on the row rather than grow it. + Matched on the attribute as well as the class: the theme's own + input[type="text"] sets a height, padding and border of its own, and + outranks a lone class, so a plain .terminal-flyout-rename would render at + the full row height. This takes the rule above it without !important. */ +input[type="text"].terminal-flyout-rename { + box-sizing: border-box; + flex: 1; + min-width: 0; + height: 20px; + margin-left: 6px; + margin-right: 18px; + padding: 0 4px; + font-size: 12px; + line-height: 18px; + color: var(--terminal-tab-active-text); + background: rgba(0, 0, 0, 0.35); + border: 1px solid #007acc; + border-radius: 2px; + outline: none; +} + +/* Nothing competes with the field while it is open */ +.terminal-flyout-item.renaming .terminal-flyout-edit, +.terminal-flyout-item.renaming .terminal-flyout-close, +.terminal-flyout-item.renaming .terminal-flyout-cwd { + display: none; +} + /* ─── Flyout bottom actions ─── */ .terminal-flyout-actions { diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 9bae0e55a0..7a054a08a7 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -598,6 +598,171 @@ define(function (require, exports, module) { }); }); + describe("Tab renaming", function () { + const STATE_TAB_NAMES = "terminal.tabNames"; + let StateManager, savedNames; + + beforeAll(function () { + StateManager = testWindow.brackets.getModule( + "preferences/StateManager" + ); + // names are stored globally, so put back whatever this + // machine already had once the block is done + savedNames = StateManager.get(STATE_TAB_NAMES); + }); + + afterAll(function () { + StateManager.set(STATE_TAB_NAMES, savedNames); + }); + + /** The row of the one open terminal. */ + function $activeTab() { + return testWindow.$(".terminal-flyout-item.active"); + } + + function tabLabel() { + return $activeTab().find(".terminal-flyout-title").text(); + } + + /** Open the rename field the way the pencil does. */ + function startRename() { + $activeTab().find(".terminal-flyout-edit").click(); + return testWindow.$(".terminal-flyout-rename"); + } + + /** Type into the open field and end the edit with `key`. */ + function commitRename($input, value, key) { + $input.val(value); + const e = testWindow.$.Event("keydown", { key: key }); + $input.trigger(e); + } + + async function openOneTerminal() { + await openTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 1; + }, "terminal to be created", 10000); + await waitForShellReady(); + } + + async function closePanel() { + clickPanelCloseButton(); + await awaitsFor(function () { + return !testWindow.$("#terminal-panel").is(":visible"); + }, "terminal panel to close", 5000); + } + + it("should rename a tab and remember the name for its slot", async function () { + await openOneTerminal(); + const autoLabel = tabLabel(); + + const id = $activeTab().attr("data-terminal-id"); + commitRename(startRename(), "build", "Enter"); + + await awaitsFor(function () { + return tabLabel() === "build"; + }, "tab to take the typed name", 5000); + // marked as the user's own, not a process we detected + expect($activeTab().hasClass("renamed")).toBeTrue(); + expect(tabLabel()).not.toBe(autoLabel); + // and written through, so the slot keeps it for the next run + expect(StateManager.get(STATE_TAB_NAMES)[id]).toBe("build"); + + await closePanel(); + }); + + it("should return a tab to its process label when the name is cleared", async function () { + await openOneTerminal(); + const id = $activeTab().attr("data-terminal-id"); + commitRename(startRename(), "build", "Enter"); + await awaitsFor(function () { + return tabLabel() === "build"; + }, "tab to take the typed name", 5000); + + // an emptied field hands the tab back to the process label + commitRename(startRename(), "", "Enter"); + await awaitsFor(function () { + return tabLabel() !== "build"; + }, "tab to fall back to its process label", 5000); + + expect($activeTab().hasClass("renamed")).toBeFalse(); + expect(StateManager.get(STATE_TAB_NAMES)[id]).toBeUndefined(); + + await closePanel(); + }); + + it("should leave the name alone when the edit is cancelled", async function () { + await openOneTerminal(); + const before = tabLabel(); + + commitRename(startRename(), "discard-me", "Escape"); + await awaitsFor(function () { + return testWindow.$(".terminal-flyout-rename").length === 0; + }, "rename field to close", 5000); + + expect(tabLabel()).toBe(before); + expect($activeTab().hasClass("renamed")).toBeFalse(); + + await closePanel(); + }); + + it("should keep the field open when the tab list refreshes underneath", async function () { + await openOneTerminal(); + const before = tabLabel(); + + const $input = startRename(); + $input.val("half-typed"); + + // A refresh rebuilds the rows. Left unguarded it tears the + // field out, and the removal fires blur, which commits — + // landing a half-typed name nobody confirmed. + triggerFlyoutRefresh(); + triggerFlyoutRefresh(); + + expect(testWindow.$(".terminal-flyout-rename").length).toBe(1); + expect(testWindow.$(".terminal-flyout-rename").val()).toBe("half-typed"); + expect(tabLabel()).toBe(before); + + commitRename(testWindow.$(".terminal-flyout-rename"), "", "Escape"); + await awaitsFor(function () { + return testWindow.$(".terminal-flyout-rename").length === 0; + }, "rename field to close", 5000); + + await closePanel(); + }); + + it("should not switch tabs when the rename pencil is clicked", async function () { + await openOneTerminal(); + clickNewTerminal(); + await awaitsFor(function () { + return getTerminalCount() === 2; + }, "second terminal to be created", 10000); + + const activeBefore = $activeTab().attr("data-terminal-id"); + // the pencil on the *other* row: clicking it must edit that row + // without activating it, or the terminal takes focus off the field + const $other = testWindow.$(".terminal-flyout-item") + .not(".active").first(); + const otherId = $other.attr("data-terminal-id"); + $other.find(".terminal-flyout-edit").click(); + + expect(testWindow.$(".terminal-flyout-rename").length).toBe(1); + expect($activeTab().attr("data-terminal-id")).toBe(activeBefore); + expect(otherId).not.toBe(activeBefore); + + commitRename(testWindow.$(".terminal-flyout-rename"), "", "Escape"); + + // two terminals are open, so closing the panel asks first + 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); + }); + }); + describe("Project-switch banner", function () { let termModule, secondProjectPath;