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;