diff --git a/src/extensionsIntegrated/Terminal/main.js b/src/extensionsIntegrated/Terminal/main.js index 060136b47f..c0f608923d 100644 --- a/src/extensionsIntegrated/Terminal/main.js +++ b/src/extensionsIntegrated/Terminal/main.js @@ -93,6 +93,8 @@ define(function (require, exports, module) { let originalDefaultShellName = null; // System-detected default shell name let _focusToastShown = false; // Show focus hint toast only once per session let _clearHintShown = false; // Show clear buffer hint toast only once per session + let _projectPath = null; + let _restartingTerminals = false; let $panel, $contentArea, $shellDropdown, $flyoutList; /** @@ -186,7 +188,7 @@ define(function (require, exports, module) { const shells = ShellProfiles.getShells(); const defaultShell = ShellProfiles.getDefaultShell(); $shellDropdown.empty(); - for (const shell of shells) { + shells.forEach(function (shell) { const isSelected = defaultShell && defaultShell.name === shell.name; const $check = $(''); if (isSelected) { @@ -197,6 +199,9 @@ define(function (require, exports, module) { .append($check) .append($('').text(shell.name)); $item.on("click", function () { + if (_restartingTerminals) { + return; + } _hideShellDropdown(); ShellProfiles.setDefaultShell(shell.name); _populateShellDropdown(); @@ -209,7 +214,7 @@ define(function (require, exports, module) { _createNewTerminalWithShell(shell); }); $shellDropdown.append($item); - } + }); } /** @@ -272,6 +277,9 @@ define(function (require, exports, module) { * Create a new terminal with the default shell */ async function _createNewTerminal(cwdOverride) { + if (_restartingTerminals) { + return; + } const shell = ShellProfiles.getDefaultShell(); return _createNewTerminalWithShell(shell, cwdOverride); } @@ -286,17 +294,12 @@ define(function (require, exports, module) { if (cwd.startsWith(tauriPrefix)) { cwd = Phoenix.fs.getTauriPlatformPath(cwd); } - if (cwd.length > 1 && (cwd.endsWith("/") || cwd.endsWith("\\"))) { + if (cwd.length > 1 && !/^[a-z]:[\\/]$/i.test(cwd) && (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 - */ /** * Map an OS shell name (e.g. "powershell.exe", "bash.exe") to a short * family label so the metrics server's per-event length budget stays @@ -315,7 +318,14 @@ define(function (require, exports, module) { return n || "unknown"; } - async function _createNewTerminalWithShell(shell, cwdOverride) { + /** + * Create a terminal using a shell profile and an optional VFS directory. + * @param {Object} shell Shell profile to use. + * @param {string} [cwdOverride] Directory to use instead of the project root. + * @param {string} [projectPath] Owning project when restarting during a project switch. + * @return {Promise} The new terminal, if a shell is available. + */ + async function _createNewTerminalWithShell(shell, cwdOverride, projectPath) { if (!shell) { console.error("Terminal: No shell available"); Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "new", "noShell"); @@ -326,11 +336,11 @@ define(function (require, exports, module) { _shellMetricLabel(shell.name)); // Get cwd: use override if provided, otherwise fall back to project root + const projectRoot = ProjectManager.getProjectRoot(); let cwd; if (cwdOverride) { cwd = _toNativePath(cwdOverride); } else { - const projectRoot = ProjectManager.getProjectRoot(); if (projectRoot) { cwd = _toNativePath(projectRoot.fullPath); } @@ -338,6 +348,8 @@ define(function (require, exports, module) { // Create instance 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); // Set up callbacks instance.onTitleChanged = _onTerminalTitleChanged; @@ -376,6 +388,120 @@ define(function (require, exports, module) { // Spawn PTY process await instance.spawn(); + return instance; + } + + /** Remove the project-switch notice without changing any terminal session. */ + function _hideProjectBanner() { + if ($contentArea) { + $contentArea.find(".terminal-project-banner").remove(); + } + } + + /** Show one notice for all bottom-panel terminals, naming the current project. */ + function _showProjectBanner() { + _hideProjectBanner(); + const root = ProjectManager.getProjectRoot(); + if (!root || !terminalInstances.some(inst => inst.projectPath !== root.fullPath)) { + return; + } + const $banner = $('
'); + $banner.append($('
') + .text(StringUtils.format(Strings.TERMINAL_PROJECT_CHANGED, root.name))); + const path = _toNativePath(root.fullPath); + $banner.append($('
') + .text(StringUtils.format(Strings.TERMINAL_PROJECT_RESTART_PATH, path)).attr("title", path)); + const $actions = $('
'); + $actions.append($('') + .text(Strings.TERMINAL_PROJECT_KEEP).on("click", function () { + _hideProjectBanner(); + const active = _getActiveTerminal(); + if (active) { + active.focus(); + } + })); + $actions.append($('') + .text(Strings.TERMINAL_PROJECT_RESTART).attr("title", Strings.TERMINAL_PROJECT_RESTART_WARNING) + .on("click", _restartTerminalsInProject)); + $actions.find("button").prop("disabled", _restartingTerminals); + $banner.append($actions); + $contentArea.append($banner); + } + + /** Notify on actual project changes; shell navigation and panel toggles leave sessions alone. */ + function _onProjectOpen() { + const root = ProjectManager.getProjectRoot(); + const path = root ? root.fullPath : null; + if (path !== _projectPath) { + _projectPath = path; + _showProjectBanner(); + } + } + + /** + * Query child processes using the same platform-specific lookup as terminal tabs. + * @return {Promise} Active child process names. + */ + async function _getActiveProcesses() { + const results = await Promise.all(terminalInstances.filter(inst => inst.isAlive).map(function (inst) { + return nodeConnector.execPeer("getTerminalProcess", {id: inst.id}) + .catch(function () { return {process: ""}; }); + })); + return results.filter(result => result.process && !_isShellProcess(result.process)) + .map(result => result.process); + } + + /** Restart all bottom-panel tabs in the selected project, preserving shells, order and selection. */ + async function _restartTerminalsInProject() { + const root = ProjectManager.getProjectRoot(); + if (_restartingTerminals || !root || !terminalInstances.length) { + return; + } + const path = root.fullPath; + _restartingTerminals = true; + $contentArea.find(".terminal-project-actions button").prop("disabled", true); + try { + const activeProcesses = await _getActiveProcesses(); + if (activeProcesses.length) { + const dialog = Dialogs.showModalDialog(DefaultDialogs.DIALOG_ID_INFO, + Strings.TERMINAL_RESTART_CONFIRM_TITLE, + Strings.TERMINAL_RESTART_CONFIRM_MSG, [ + {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: Strings.TERMINAL_PROJECT_RESTART} + ]); + if (await dialog.getPromise() !== Dialogs.DIALOG_BTN_OK) { + return; + } + } + // A project can change while process lookup or confirmation is pending. + const currentRoot = ProjectManager.getProjectRoot(); + if (!currentRoot || currentRoot.fullPath !== path) { + return; + } + const profiles = terminalInstances.map(inst => inst.shellProfile); + const activeIndex = terminalInstances.findIndex(inst => inst.id === activeTerminalId); + await _disposeAllAsync(); + activeTerminalId = null; + _updateFlyout(); + const replacements = []; + for (const profile of profiles) { + replacements.push(await _createNewTerminalWithShell(profile, path, path)); + } + if (replacements[activeIndex]) { + _activateTerminal(replacements[activeIndex].id); + } + // Keep a notice for a newer project selected during the restart. + if (_projectPath !== path) { + _showProjectBanner(); + } + } catch (err) { + console.error("Terminal: Failed to restart terminals:", err); + _showProjectBanner(); + } finally { + _restartingTerminals = false; + $contentArea.find(".terminal-project-actions button").prop("disabled", false); + } } /** @@ -401,6 +527,9 @@ define(function (require, exports, module) { * Close a terminal instance, confirming first if a child process is running */ async function _closeTerminal(id) { + if (_restartingTerminals) { + return; + } const idx = terminalInstances.findIndex(t => t.id === id); if (idx === -1) { return; @@ -433,6 +562,9 @@ define(function (require, exports, module) { instance.dispose(); terminalInstances.splice(idx, 1); delete processInfo[id]; + if ($contentArea.find(".terminal-project-banner").length) { + _showProjectBanner(); + } Metrics.countEvent(Metrics.EVENT_TYPE.TERMINAL, "close", "user"); // If we closed the active terminal, activate another @@ -447,6 +579,7 @@ define(function (require, exports, module) { // If no terminals left, hide the panel if (terminalInstances.length === 0) { + _hideProjectBanner(); panel.hide(); } @@ -708,22 +841,23 @@ define(function (require, exports, module) { return false; } const el = document.activeElement; - if (!el || !$contentArea[0].contains(el)) { + if (!el || !$(el).closest(".terminal-instance-container").length) { return false; } + const inBottomPanel = $contentArea[0].contains(el); const ctrlOrMeta = event.ctrlKey || event.metaKey; const key = event.key.toLowerCase(); // Ctrl+K (Cmd+K on mac): clear terminal scrollback - if (ctrlOrMeta && !event.shiftKey && key === "k") { + if (inBottomPanel && ctrlOrMeta && !event.shiftKey && key === "k") { event.preventDefault(); _clearActiveTerminal(); return true; } // Show clear buffer hint on Ctrl+L - if (ctrlOrMeta && !event.shiftKey && key === "l") { + if (inBottomPanel && ctrlOrMeta && !event.shiftKey && key === "l") { _showClearBufferHintToast(); } @@ -800,6 +934,7 @@ define(function (require, exports, module) { } terminalInstances = []; processInfo = {}; + _hideProjectBanner(); } /** @@ -922,23 +1057,18 @@ define(function (require, exports, module) { _initNodeConnector(); _createPanel(); _createToolbarButton(); + const root = ProjectManager.getProjectRoot(); + _projectPath = root ? root.fullPath : null; + ProjectManager.on("projectOpen.terminal", _onProjectOpen); // 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); - } + if (_restartingTerminals) { + return false; } + const activeProcesses = await _getActiveProcesses(); let title, message, confirmText; const count = terminalInstances.length; diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index e22cc483c0..fd86c62978 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2230,6 +2230,13 @@ define({ "TERMINAL_FOCUS_HINT": "Press {0} to switch between editor and terminal", "TERMINAL_CLEAR": "Clear Terminal", "TERMINAL_CLEAR_BUFFER_HINT": "💡 Press {0} to clear terminal buffer", + "TERMINAL_PROJECT_CHANGED": "Project changed to {0}. Existing terminals have kept their locations.", + "TERMINAL_PROJECT_RESTART_PATH": "Restart in: {0}", + "TERMINAL_PROJECT_RESTART_WARNING": "Restarting opens every terminal in this folder, stops running processes and clears terminal output.", + "TERMINAL_PROJECT_KEEP": "Keep Terminals", + "TERMINAL_PROJECT_RESTART": "Restart All in This Project", + "TERMINAL_RESTART_CONFIRM_TITLE": "Restart All Terminals?", + "TERMINAL_RESTART_CONFIRM_MSG": "Terminals have active processes. Restarting will stop them and clear terminal output. Continue?", "EXTENDED_COMMIT_MESSAGE": "EXTENDED", "GETTING_STAGED_DIFF_PROGRESS": "Getting diff of staged files\u2026", "GIT_COMMIT": "Git commit\u2026", diff --git a/src/styles/Extn-Terminal.less b/src/styles/Extn-Terminal.less index ed54b2dd50..83524a34ae 100644 --- a/src/styles/Extn-Terminal.less +++ b/src/styles/Extn-Terminal.less @@ -365,6 +365,40 @@ overflow: hidden; } +.terminal-project-banner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 5; + box-sizing: border-box; + max-height: 100%; + overflow: auto; + padding: 8px 10px; + background: var(--terminal-toolbar-bg); + color: var(--terminal-foreground); + border-top: 1px solid var(--terminal-border); + border-left: 3px solid var(--terminal-ansi-yellow); + box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.2); + white-space: normal; + + .terminal-project-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 4px 0; + font-weight: 600; + } + + .terminal-project-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + } +} + .terminal-instance-container { position: absolute; top: 0; diff --git a/test/spec/Terminal-integ-test.js b/test/spec/Terminal-integ-test.js index 9ebca685f8..a0a94f098c 100644 --- a/test/spec/Terminal-integ-test.js +++ b/test/spec/Terminal-integ-test.js @@ -18,7 +18,7 @@ * */ -/*global describe, it, expect, beforeAll, afterAll, afterEach, awaitsFor, spyOn */ +/*global describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, spyOn */ define(function (require, exports, module) { @@ -28,6 +28,7 @@ define(function (require, exports, module) { const SpecRunnerUtils = require("spec/SpecRunnerUtils"); const Strings = require("strings"); + const StringUtils = require("utils/StringUtils"); const IS_WINDOWS = Phoenix.platform === "win"; const IS_MAC = Phoenix.platform === "mac"; @@ -597,6 +598,276 @@ define(function (require, exports, module) { }); }); + describe("Project-switch banner", function () { + let termModule, secondProjectPath; + + beforeAll(async function () { + termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + await SpecRunnerUtils.createTempDirectory(); + secondProjectPath = SpecRunnerUtils.getTempDirectory(); + }); + + beforeEach(async function () { + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterEach(async function () { + if (isDialogOpen()) { + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(); + } + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + }, 30000); + + afterAll(async function () { + await SpecRunnerUtils.removeTempDirectory(); + }); + + /** + * Open a real shell and wait for its first prompt output. + * @return {Promise} The ready terminal. + */ + async function openReadyTerminal() { + await openTerminal(); + await waitForShellReady(); + const instance = termModule._getActiveTerminal(); + await instance.firstDataReceived; + return instance; + } + + /** + * Verify the shell's actual directory without depending on its prompt format. + * @param {TerminalInstance} instance The shell to query. + * @param {string} path Expected native directory. + */ + async function expectWorkingDirectory(instance, path) { + const shell = instance.shellProfile.path.split(/[\\/]/).pop().toLowerCase(); + const command = shell === "cmd.exe" ? "cd" + : /^(powershell|pwsh)(\.exe)?$/.test(shell) ? "(Get-Location).Path" : "pwd"; + await instance.firstDataReceived; + await termModule.getNodeConnector().execPeer("writeTerminal", {id: instance.id, data: command + "\r"}); + await awaitsFor(function () { + const buffer = instance.terminal.buffer.active; + let text = ""; + for (let i = 0; i < buffer.length; i++) { + text += buffer.getLine(i).translateToString(); + } + return text.includes(path); + }, "shell to report the new project directory", 10000); + } + + /** + * Report a busy terminal while keeping real PTY creation, input and disposal. + * @return {jasmine.Spy} Connector spy for checking restart calls. + */ + function reportActiveProcess() { + const connector = termModule.getNodeConnector(); + const execPeer = connector.execPeer.bind(connector); + return spyOn(connector, "execPeer").and.callFake(function (method, params) { + if (method === "getTerminalProcess") { + return Promise.resolve({process: "test-running-task"}); + } + return execPeer(method, params); + }); + } + + it("does not show a banner when no terminals exist", async function () { + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + const instance = await openReadyTerminal(); + expect(instance.cwd).toBe(getNativeProjectPath()); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + }, 30000); + + it("keeps sessions and dismisses the notice until the next project switch", async function () { + const instance = await openReadyTerminal(); + await writeToTerminal("cd ..\r"); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + expect(testWindow.$(".terminal-project-path").text()) + .toBe(StringUtils.format(Strings.TERMINAL_PROJECT_RESTART_PATH, getNativeProjectPath())); + testWindow.$(".terminal-project-keep").click(); + + const panel = WorkspaceManager.getPanelForID(PANEL_ID); + panel.hide(); + panel.show(); + testWindow.brackets.test.ProjectManager.trigger("projectOpen"); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + }, 30000); + + it("clears the banner when returning to the original project without restarting", async function () { + const instance = await openReadyTerminal(); + await writeToTerminal("cd ..\r"); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + }, 30000); + + it("keeps the banner while tabs from another project remain", async function () { + const first = await openReadyTerminal(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + await __PR.execCommand(termModule.CMD_NEW_TERMINAL); + const second = termModule._getActiveTerminal(); + await second.firstDataReceived; + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + testWindow.$('.terminal-flyout-item[data-terminal-id="' + second.id + '"] .terminal-flyout-close') + .click(); + await awaitsFor(function () { + return second._disposed && getTerminalCount() === 1; + }, "the other project's terminal to close", 10000); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(first); + expect(first.isAlive).toBeTrue(); + }, 30000); + + it("restarts every tab in the new project and preserves its shell and selection", async function () { + const first = await openReadyTerminal(); + const ShellProfiles = testWindow.brackets.getModule("extensionsIntegrated/Terminal/ShellProfiles"); + // Distinct profiles using an installed shell keep this portable to machines with only one shell. + const profileSpy = spyOn(ShellProfiles, "getDefaultShell").and.returnValue( + Object.assign({}, first.shellProfile, {name: "Secondary test shell"}) + ); + await __PR.execCommand(termModule.CMD_NEW_TERMINAL); + profileSpy.and.callThrough(); + const second = termModule._getActiveTerminal(); + await second.firstDataReceived; + testWindow.$('.terminal-flyout-item[data-terminal-id="' + first.id + '"]').click(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + const path = getNativeProjectPath(); + testWindow.$(".terminal-project-restart").click(); + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return getTerminalCount() === 2 && active && active.isAlive && active.id !== first.id + && testWindow.$(".terminal-flyout-item.active").index() === 0; + }, "both terminals to restart and the first tab to remain selected", 15000); + + expect(first._disposed).toBeTrue(); + expect(second._disposed).toBeTrue(); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + const replacements = testWindow.$(".terminal-flyout-item").map(function () { + return testWindow.$(this).attr("data-terminal-id"); + }).get(); + const originals = [first, second]; + for (let i = 0; i < replacements.length; i++) { + testWindow.$('.terminal-flyout-item[data-terminal-id="' + replacements[i] + '"]').click(); + const instance = termModule._getActiveTerminal(); + expect(instance.id).not.toBe(originals[i].id); + expect(instance.shellProfile).toEqual(originals[i].shellProfile); + expect(instance.cwd).toBe(path); + await expectWorkingDirectory(instance, path); + } + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + }, 30000); + + it("confirms active processes and leaves sessions untouched when canceled", async function () { + const instance = await openReadyTerminal(); + const connectorSpy = reportActiveProcess(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + expect(getDialogTitle()).toBe(Strings.TERMINAL_RESTART_CONFIRM_TITLE); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_CANCEL); + await __PR.waitForModalDialogClosed(); + await awaitsFor(function () { + return !testWindow.$(".terminal-project-restart").prop("disabled"); + }, "restart action to be available again", 3000); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(instance.isAlive).toBeTrue(); + expect(connectorSpy.calls.allArgs().some(args => args[0] === "killTerminal")).toBeFalse(); + + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + await awaitsFor(function () { + const active = termModule._getActiveTerminal(); + return active && active.id !== instance.id && active.isAlive; + }, "confirmed restart to replace the terminal", 10000); + expect(instance._disposed).toBeTrue(); + expect(termModule._getActiveTerminal().cwd).toBe(getNativeProjectPath()); + }, 30000); + + it("does not restart into a stale project if the project changes during confirmation", async function () { + const instance = await openReadyTerminal(); + const connectorSpy = reportActiveProcess(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + testWindow.$(".terminal-project-restart").click(); + await __PR.waitForModalDialog(); + await SpecRunnerUtils.loadProjectInTestWindow(testProjectPath); + __PR.clickDialogButtonID(__PR.Dialogs.DIALOG_BTN_OK); + await __PR.waitForModalDialogClosed(); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + expect(termModule._getActiveTerminal()).toBe(instance); + expect(connectorSpy.calls.allArgs().some(args => args[0] === "killTerminal")).toBeFalse(); + }, 30000); + + it("retains the notice while hidden and removes it when all terminals close", async function () { + await openReadyTerminal(); + const panel = WorkspaceManager.getPanelForID(PANEL_ID); + panel.hide(); + await SpecRunnerUtils.loadProjectInTestWindow(secondProjectPath); + expect(panel.isVisible()).toBeFalse(); + panel.show(); + expect(testWindow.$(".terminal-project-banner").is(":visible")).toBeTrue(); + await termModule._disposeAll(); + expect(testWindow.$(".terminal-project-banner").length).toBe(0); + }, 30000); + }); + + it("should forward Alt+Up to the terminal instead of a Phoenix command", async function () { + const termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + let inputListener; + try { + await openTerminal(); + await waitForShellReady(); + const instance = termModule._getActiveTerminal(); + const KeyBindingManager = testWindow.brackets.getModule("command/KeyBindingManager"); + const binding = KeyBindingManager.getKeymap()["Alt-Up"]; + const command = testWindow.brackets.test.CommandManager.get(binding.commandID); + const execute = spyOn(command, "execute") + .and.returnValue(testWindow.$.Deferred().resolve().promise()); + const input = []; + inputListener = instance.terminal.onData(function (data) { input.push(data); }); + instance.focus(); + await awaitsFor(function () { + return testWindow.document.activeElement === instance.terminal.textarea; + }, "terminal input to have focus", 3000); + + const key = {key: "ArrowUp", code: "ArrowUp", keyCode: 38, which: 38, + altKey: true, bubbles: true, cancelable: true}; + instance.terminal.textarea.dispatchEvent(new testWindow.KeyboardEvent("keydown", key)); + instance.terminal.textarea.dispatchEvent(new testWindow.KeyboardEvent("keyup", key)); + await awaitsFor(function () { + return input.includes("\x1b[1;3A"); + }, "Alt+Up escape sequence to reach the terminal", 3000); + expect(execute).not.toHaveBeenCalled(); + } finally { + if (inputListener) { + inputListener.dispose(); + } + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + } + }); + describe("Context menu commands", function () { let CommandManager; @@ -632,6 +903,43 @@ define(function (require, exports, module) { testWindow.brackets.test.CommandManager; }); + it("should open Copy, Paste and Clear Terminal on right-click", + async function () { + const Menus = testWindow.brackets.test.Menus; + const ctxMenu = Menus.getContextMenu("terminal-context-menu"); + const termModule = testWindow.brackets.getModule("extensionsIntegrated/Terminal/main"); + const commandStates = ["terminal.copy", "terminal.paste", "terminal.clear"].map(function (id) { + const command = CommandManager.get(id); + return {command, enabled: command.getEnabled()}; + }); + try { + await openTerminal(); + await waitForShellReady(); + const active = getActiveTerminal(); + active.terminal.clearSelection(); + active.$container.find(".xterm-screen").trigger(testWindow.$.Event("contextmenu", { + pageX: 100, + pageY: 100 + })); + await awaitsFor(function () { + return testWindow.$("#terminal-context-menu.open > .dropdown-menu").is(":visible"); + }, "terminal context menu to open on right-click", 3000); + + const labels = testWindow.$("#terminal-context-menu .menu-name").map(function () { + return testWindow.$(this).text(); + }).get(); + expect(labels).toEqual([Strings.CMD_COPY, Strings.CMD_PASTE, Strings.TERMINAL_CLEAR]); + expect(CommandManager.get("terminal.copy").getEnabled()).toBeFalse(); + expect(CommandManager.get("terminal.paste").getEnabled()).toBeTrue(); + expect(CommandManager.get("terminal.clear").getEnabled()).toBeTrue(); + } finally { + ctxMenu.close(); + commandStates.forEach(function (state) { state.command.setEnabled(state.enabled); }); + await termModule._disposeAll(); + WorkspaceManager.getPanelForID(PANEL_ID).hide(); + } + }); + it("should clear the terminal screen", async function () { await openTerminal(); diff --git a/tracking-repos.json b/tracking-repos.json index d2a173a22c..ed1a8bc1b9 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "ac067fcd943e145b0ba3f92782678f661371b9a1" + "commitID": "9e1ee1d4f8e5eb181a63329977cabebb3caec4d9" } }