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;