diff --git a/spec/config-spec.js b/spec/config-spec.js index 60f94857..ca868387 100644 --- a/spec/config-spec.js +++ b/spec/config-spec.js @@ -323,6 +323,12 @@ describe('Call to colorBrightWhite()', () => { }) }) +describe('Call to allowHiddenToStayActive()', () => { + it('return false', () => { + expect(configDefaults.allowHiddenToStayActive).toBe(false) + }) +}) + describe('Call to leaveOpenAfterExit()', () => { it('return true', () => { expect(configDefaults.leaveOpenAfterExit).toBe(true) diff --git a/spec/element-spec.js b/spec/element-spec.js index 5084669c..845fde15 100644 --- a/spec/element-spec.js +++ b/spec/element-spec.js @@ -1787,7 +1787,9 @@ describe('XTerminalElement', () => { it('focusOnTerminal()', () => { spyOn(this.element.terminal, 'focus') + spyOn(this.element.model, 'setActive') this.element.focusOnTerminal() + expect(this.element.model.setActive).toHaveBeenCalled() expect(this.element.terminal.focus).toHaveBeenCalled() }) diff --git a/spec/model-spec.js b/spec/model-spec.js index f052b982..2a7655a4 100644 --- a/spec/model-spec.js +++ b/spec/model-spec.js @@ -243,6 +243,11 @@ describe('XTerminalModel', () => { expect(this.model.getTitle()).toBe(expected) }) + it('getTitle() when active', () => { + spyOn(this.model, 'isActiveTerminal').and.returnValue(true) + expect(this.model.getTitle()).toBe('* X Terminal') + }) + it('getElement()', () => { const expected = { somekey: 'somevalue' } this.model.element = expected @@ -472,17 +477,112 @@ describe('XTerminalModel', () => { expect(this.model.element.ptyProcess.write.calls.allArgs()).toEqual([[expectedText]]) }) - it('setNewPane(event)', async () => { + it('setActive()', async function () { + const pane = atom.workspace.getCenter().getActivePane() const uri = 'x-terminal://somesessionid/' const terminalsSet = new Set() - const model = new XTerminalModel({ + const model1 = new XTerminalModel({ uri: uri, terminals_set: terminalsSet, }) - await model.initializedPromise - const expected = {} - model.setNewPane(expected) - expect(model.pane).toBe(expected) + await model1.initializedPromise + pane.addItem(model1) + model1.setNewPane(pane) + const model2 = new XTerminalModel({ + uri: uri, + terminals_set: terminalsSet, + }) + await model2.initializedPromise + pane.addItem(model2) + model2.setNewPane(pane) + expect(model1.activeIndex).toBe(0) + expect(model2.activeIndex).toBe(1) + model2.setActive() + expect(model1.activeIndex).toBe(1) + expect(model2.activeIndex).toBe(0) + }) + + describe('setNewPane', () => { + it('(mock)', async () => { + const expected = { getContainer: () => ({ getLocation: () => {} }) } + this.model.setNewPane(expected) + expect(this.model.pane).toBe(expected) + expect(this.model.dock).toBe(null) + }) + + it('(center)', async () => { + const pane = atom.workspace.getCenter().getActivePane() + this.model.setNewPane(pane) + expect(this.model.pane).toBe(pane) + expect(this.model.dock).toBe(null) + }) + + it('(left)', async () => { + const dock = atom.workspace.getLeftDock() + const pane = dock.getActivePane() + this.model.setNewPane(pane) + expect(this.model.pane).toBe(pane) + expect(this.model.dock).toBe(dock) + }) + + it('(right)', async () => { + const dock = atom.workspace.getRightDock() + const pane = dock.getActivePane() + this.model.setNewPane(pane) + expect(this.model.pane).toBe(pane) + expect(this.model.dock).toBe(dock) + }) + + it('(bottom)', async () => { + const dock = atom.workspace.getBottomDock() + const pane = dock.getActivePane() + this.model.setNewPane(pane) + expect(this.model.pane).toBe(pane) + expect(this.model.dock).toBe(dock) + }) + }) + + it('isVisible() in pane', () => { + const pane = atom.workspace.getCenter().getActivePane() + this.model.setNewPane(pane) + expect(this.model.isVisible()).toBe(false) + pane.setActiveItem(this.model) + expect(this.model.isVisible()).toBe(true) + }) + + it('isVisible() in dock', () => { + const dock = atom.workspace.getBottomDock() + const pane = dock.getActivePane() + this.model.setNewPane(pane) + pane.setActiveItem(this.model) + expect(this.model.isVisible()).toBe(false) + dock.show() + expect(this.model.isVisible()).toBe(true) + }) + + it('isActiveTerminal() visible and active', () => { + this.model.activeIndex = 0 + spyOn(this.model, 'isVisible').and.returnValue(true) + expect(this.model.isActiveTerminal()).toBe(true) + }) + + it('isActiveTerminal() visible and not active', () => { + this.model.activeIndex = 1 + spyOn(this.model, 'isVisible').and.returnValue(true) + expect(this.model.isActiveTerminal()).toBe(false) + }) + + it('isActiveTerminal() invisible and active', () => { + this.model.activeIndex = 0 + spyOn(this.model, 'isVisible').and.returnValue(false) + expect(this.model.isActiveTerminal()).toBe(false) + }) + + it('isActiveTerminal() allowHiddenToStayActive', () => { + atom.config.set('x-terminal.terminalSettings.allowHiddenToStayActive', true) + this.model.activeIndex = 0 + spyOn(this.model, 'isVisible').and.returnValue(false) + expect(this.model.isActiveTerminal()).toBe(true) }) it('toggleProfileMenu()', () => { diff --git a/spec/utils-spec.js b/spec/utils-spec.js index fb03a43c..e7176ef0 100644 --- a/spec/utils-spec.js +++ b/spec/utils-spec.js @@ -43,4 +43,67 @@ describe('Utilities', () => { expect(hLine.classList.contains('x-terminal-profile-menu-element-hline')).toBe(true) expect(hLine.textContent).toBe('.') }) + + describe('recalculateActive()', () => { + const createTerminals = (num = 1) => { + const terminals = [] + for (let i = 0; i < num; i++) { + terminals.push({ + activeIndex: i, + isVisible () {}, + emitter: { + emit () {}, + }, + }) + } + return terminals + } + + it('active first', () => { + const terminals = createTerminals(2) + const terminalsSet = new Set(terminals) + utils.recalculateActive(terminalsSet, terminals[1]) + expect(terminals[0].activeIndex).toBe(1) + expect(terminals[1].activeIndex).toBe(0) + }) + + it('visible before hidden', () => { + const terminals = createTerminals(2) + const terminalsSet = new Set(terminals) + spyOn(terminals[1], 'isVisible').and.returnValue(true) + utils.recalculateActive(terminalsSet) + expect(terminals[0].activeIndex).toBe(1) + expect(terminals[1].activeIndex).toBe(0) + }) + + it('allowHiddenToStayActive', () => { + atom.config.set('x-terminal.terminalSettings.allowHiddenToStayActive', true) + const terminals = createTerminals(2) + const terminalsSet = new Set(terminals) + spyOn(terminals[1], 'isVisible').and.returnValue(true) + utils.recalculateActive(terminalsSet) + expect(terminals[0].activeIndex).toBe(0) + expect(terminals[1].activeIndex).toBe(1) + }) + + it('lower active index first', () => { + const terminals = createTerminals(2) + const terminalsSet = new Set(terminals) + terminals[0].activeIndex = 1 + terminals[1].activeIndex = 0 + utils.recalculateActive(terminalsSet) + expect(terminals[0].activeIndex).toBe(1) + expect(terminals[1].activeIndex).toBe(0) + }) + + it('emit did-change-title', () => { + const terminals = createTerminals(2) + const terminalsSet = new Set(terminals) + spyOn(terminals[0].emitter, 'emit') + spyOn(terminals[1].emitter, 'emit') + utils.recalculateActive(terminalsSet) + expect(terminals[0].emitter.emit).toHaveBeenCalledWith('did-change-title') + expect(terminals[1].emitter.emit).toHaveBeenCalledWith('did-change-title') + }) + }) }) diff --git a/src/config.js b/src/config.js index e9123ac9..7db3cd00 100644 --- a/src/config.js +++ b/src/config.js @@ -62,6 +62,7 @@ export function resetConfigDefaults () { colorBrightMagenta: '#ad7fa8', colorBrightCyan: '#34e2e2', colorBrightWhite: '#eeeeec', + allowHiddenToStayActive: false, leaveOpenAfterExit: true, allowRelaunchingTerminalsOnStartup: true, relaunchTerminalOnStartup: true, @@ -384,6 +385,12 @@ export const config = configOrder({ toMenuSetting: (val) => val, }, }, + allowHiddenToStayActive: { + title: 'Allow Hidden Terminal To Stay Active', + description: 'When an active terminal is hidden keep it active until another terminal is focused.', + type: 'boolean', + default: configDefaults.allowHiddenToStayActive, + }, leaveOpenAfterExit: { title: 'Leave Open After Exit', description: 'Whether to leave terminal emulators open after their shell processes have exited.', diff --git a/src/model.js b/src/model.js index b8614968..7841e4da 100644 --- a/src/model.js +++ b/src/model.js @@ -21,6 +21,7 @@ import { Emitter } from 'atom' +import { recalculateActive } from './utils' import { XTerminalProfilesSingleton } from './profiles' import fs from 'fs-extra' @@ -48,7 +49,7 @@ class XTerminalModel { this.profilesSingleton = XTerminalProfilesSingleton.instance this.profile = this.profilesSingleton.createProfileDataFromUri(this.uri) this.terminals_set = this.options.terminals_set - this.active = false + this.activeIndex = this.terminals_set.size this.element = null this.pane = null this.title = DEFAULT_TITLE @@ -126,7 +127,8 @@ class XTerminalModel { } getTitle () { - return this.title + return (this.isActiveTerminal() ? '* ' : '') + this.title + // return this.activeIndex + '|' + this.title } getElement () { @@ -232,18 +234,33 @@ class XTerminalModel { } setActive () { - for (const terminal of this.terminals_set) { - terminal.active = false - } - this.active = true + recalculateActive(this.terminals_set, this) + } + + isVisible () { + return this.pane && this.pane.getActiveItem() === this && (!this.dock || this.dock.isVisible()) } - isActive () { - return this.active + isActiveTerminal () { + return this.activeIndex === 0 && (atom.config.get('x-terminal.terminalSettings.allowHiddenToStayActive') || this.isVisible()) } setNewPane (pane) { this.pane = pane + const location = this.pane.getContainer().getLocation() + switch (location) { + case 'left': + this.dock = atom.workspace.getLeftDock() + break + case 'right': + this.dock = atom.workspace.getRightDock() + break + case 'bottom': + this.dock = atom.workspace.getBottomDock() + break + default: + this.dock = null + } } toggleProfileMenu () { diff --git a/src/utils.js b/src/utils.js index 74b757d8..dead996c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -31,3 +31,32 @@ export function createHorizontalLine () { hLine.appendChild(document.createTextNode('.')) return hLine } + +export function recalculateActive (terminalsSet, active) { + const allowHidden = atom.config.get('x-terminal.terminalSettings.allowHiddenToStayActive') + const terminals = [...terminalsSet] + terminals.sort((a, b) => { + // active before other + if (active && a === active) { + return -1 + } + if (active && b === active) { + return 1 + } + if (!allowHidden) { + // visible before hidden + if (a.isVisible() && !b.isVisible()) { + return -1 + } + if (!a.isVisible() && b.isVisible()) { + return 1 + } + } + // lower activeIndex before higher activeIndex + return a.activeIndex - b.activeIndex + }) + terminals.forEach((t, i) => { + t.activeIndex = i + t.emitter.emit('did-change-title') + }) +} diff --git a/src/x-terminal.js b/src/x-terminal.js index 2197a0e3..840bc427 100644 --- a/src/x-terminal.js +++ b/src/x-terminal.js @@ -23,6 +23,7 @@ import { CompositeDisposable } from 'atom' import { CONFIG_DATA } from './config' +import { recalculateActive } from './utils' import { XTerminalElement } from './element' import { XTerminalModel, isXTerminalModel } from './model' import { X_TERMINAL_BASE_URI, XTerminalProfilesSingleton } from './profiles' @@ -74,112 +75,145 @@ class XTerminalSingleton { })) } - // Register view provider for terminal emulator item. - this.disposables.add(atom.views.addViewProvider(XTerminalModel, (atomXtermModel) => { - const atomXtermElement = new XTerminalElement() - atomXtermElement.initialize(atomXtermModel) - return atomXtermElement - })) - - // Register view provider for terminal emulator profile menu item. - this.disposables.add(atom.views.addViewProvider(XTerminalProfileMenuModel, (atomXtermProfileMenuModel) => { - const atomXtermProfileMenuElement = new XTerminalProfileMenuElement() - atomXtermProfileMenuElement.initialize(atomXtermProfileMenuModel) - return atomXtermProfileMenuElement - })) - - // Register view profile for modal items. - this.disposables.add(atom.views.addViewProvider(XTerminalDeleteProfileModel, (atomXtermDeleteProfileModel) => { - const atomXtermDeleteProfileElement = new XTerminalDeleteProfileElement() - atomXtermDeleteProfileElement.initialize(atomXtermDeleteProfileModel) - return atomXtermDeleteProfileElement - })) - this.disposables.add(atom.views.addViewProvider(XTerminalOverwriteProfileModel, (atomXtermOverwriteProfileModel) => { - const atomXtermOverwriteProfileElement = new XTerminalOverwriteProfileElement() - atomXtermOverwriteProfileElement.initialize(atomXtermOverwriteProfileModel) - return atomXtermOverwriteProfileElement - })) - this.disposables.add(atom.views.addViewProvider(XTerminalSaveProfileModel, (atomXtermSaveProfileModel) => { - const atomXtermSaveProfileElement = new XTerminalSaveProfileElement() - atomXtermSaveProfileElement.initialize(atomXtermSaveProfileModel) - return atomXtermSaveProfileElement - })) - - // Add opener for terminal emulator item. - this.disposables.add(atom.workspace.addOpener((uri) => { - if (uri.startsWith(X_TERMINAL_BASE_URI)) { - const item = new XTerminalModel({ - uri: uri, - terminals_set: this.terminals_set, - }) - return item - } - })) - - // Set callback to run on current and future panes. - this.disposables.add(atom.workspace.observePanes((pane) => { - // In callback, set another callback to run on current and future items. - this.disposables.add(pane.observeItems((item) => { - // In callback, set current pane for terminal items. + this.disposables.add( + // Register view provider for terminal emulator item. + atom.views.addViewProvider(XTerminalModel, (atomXtermModel) => { + const atomXtermElement = new XTerminalElement() + atomXtermElement.initialize(atomXtermModel) + return atomXtermElement + }), + // Register view provider for terminal emulator profile menu item. + atom.views.addViewProvider(XTerminalProfileMenuModel, (atomXtermProfileMenuModel) => { + const atomXtermProfileMenuElement = new XTerminalProfileMenuElement() + atomXtermProfileMenuElement.initialize(atomXtermProfileMenuModel) + return atomXtermProfileMenuElement + }), + // Register view profile for modal items. + atom.views.addViewProvider(XTerminalDeleteProfileModel, (atomXtermDeleteProfileModel) => { + const atomXtermDeleteProfileElement = new XTerminalDeleteProfileElement() + atomXtermDeleteProfileElement.initialize(atomXtermDeleteProfileModel) + return atomXtermDeleteProfileElement + }), + atom.views.addViewProvider(XTerminalOverwriteProfileModel, (atomXtermOverwriteProfileModel) => { + const atomXtermOverwriteProfileElement = new XTerminalOverwriteProfileElement() + atomXtermOverwriteProfileElement.initialize(atomXtermOverwriteProfileModel) + return atomXtermOverwriteProfileElement + }), + atom.views.addViewProvider(XTerminalSaveProfileModel, (atomXtermSaveProfileModel) => { + const atomXtermSaveProfileElement = new XTerminalSaveProfileElement() + atomXtermSaveProfileElement.initialize(atomXtermSaveProfileModel) + return atomXtermSaveProfileElement + }), + + // Add opener for terminal emulator item. + atom.workspace.addOpener((uri) => { + if (uri.startsWith(X_TERMINAL_BASE_URI)) { + const item = new XTerminalModel({ + uri: uri, + terminals_set: this.terminals_set, + }) + return item + } + }), + + // Set callback to run on current and future panes. + atom.workspace.observePanes((pane) => { + // In callback, set another callback to run on current and future items. + this.disposables.add(pane.observeItems((item) => { + // In callback, set current pane for terminal items. + if (isXTerminalModel(item)) { + item.setNewPane(pane) + } + recalculateActive(this.terminals_set) + })) + recalculateActive(this.terminals_set) + }), + + // Add callbacks to run for current and future active items on active panes. + atom.workspace.observeActivePaneItem((item) => { + // In callback, focus specifically on terminal when item is terminal item. if (isXTerminalModel(item)) { - item.setNewPane(pane) + item.focusOnTerminal() } - })) - })) - - // Add callbacks to run for current and future active items on active panes. - this.disposables.add(atom.workspace.observeActivePaneItem((item) => { - // In callback, focus specifically on terminal when item is terminal item. - if (isXTerminalModel(item)) { - item.focusOnTerminal() - } - })) - - // Add commands. - this.disposables.add(atom.commands.add('atom-workspace', { - 'x-terminal:open': () => this.open( - this.profilesSingleton.generateNewUri(), - this.addDefaultPosition(), - ), - 'x-terminal:open-center': () => this.openInCenterOrDock(atom.workspace), - 'x-terminal:open-split-up': () => this.open( - this.profilesSingleton.generateNewUri(), - { split: 'up' }, - ), - 'x-terminal:open-split-down': () => this.open( - this.profilesSingleton.generateNewUri(), - { split: 'down' }, - ), - 'x-terminal:open-split-left': () => this.open( - this.profilesSingleton.generateNewUri(), - { split: 'left' }, - ), - 'x-terminal:open-split-right': () => this.open( - this.profilesSingleton.generateNewUri(), - { split: 'right' }, - ), - 'x-terminal:open-split-bottom-dock': () => this.openInCenterOrDock(atom.workspace.getBottomDock()), - 'x-terminal:open-split-left-dock': () => this.openInCenterOrDock(atom.workspace.getLeftDock()), - 'x-terminal:open-split-right-dock': () => this.openInCenterOrDock(atom.workspace.getRightDock()), - 'x-terminal:toggle-profile-menu': () => this.toggleProfileMenu(), - 'x-terminal:reorganize': () => this.reorganize('current'), - 'x-terminal:reorganize-top': () => this.reorganize('top'), - 'x-terminal:reorganize-bottom': () => this.reorganize('bottom'), - 'x-terminal:reorganize-left': () => this.reorganize('left'), - 'x-terminal:reorganize-right': () => this.reorganize('right'), - 'x-terminal:reorganize-bottom-dock': () => this.reorganize('bottom-dock'), - 'x-terminal:reorganize-left-dock': () => this.reorganize('left-dock'), - 'x-terminal:reorganize-right-dock': () => this.reorganize('right-dock'), - 'x-terminal:close-all': () => this.exitAllTerminals(), - 'x-terminal:insert-selected-text': () => this.insertSelection(), - 'x-terminal:run-selected-text': () => this.runSelection(), - })) - this.disposables.add(atom.commands.add('x-terminal', { - 'x-terminal:close': () => this.close(), - 'x-terminal:restart': () => this.restart(), - 'x-terminal:copy': () => this.copy(), - 'x-terminal:paste': () => this.paste(), - })) + recalculateActive(this.terminals_set) + }), + + atom.workspace.getRightDock().observeVisible((visible) => { + if (visible) { + const item = atom.workspace.getRightDock().getActivePaneItem() + if (isXTerminalModel(item)) { + item.focusOnTerminal() + } + } + recalculateActive(this.terminals_set) + }), + + atom.workspace.getLeftDock().observeVisible((visible) => { + if (visible) { + const item = atom.workspace.getLeftDock().getActivePaneItem() + if (isXTerminalModel(item)) { + item.focusOnTerminal() + } + } + recalculateActive(this.terminals_set) + }), + + atom.workspace.getBottomDock().observeVisible((visible) => { + if (visible) { + const item = atom.workspace.getBottomDock().getActivePaneItem() + if (isXTerminalModel(item)) { + item.focusOnTerminal() + } + } + recalculateActive(this.terminals_set) + }), + + // Add commands. + atom.commands.add('atom-workspace', { + 'x-terminal:open': () => this.open( + this.profilesSingleton.generateNewUri(), + this.addDefaultPosition(), + ), + 'x-terminal:open-center': () => this.openInCenterOrDock(atom.workspace), + 'x-terminal:open-split-up': () => this.open( + this.profilesSingleton.generateNewUri(), + { split: 'up' }, + ), + 'x-terminal:open-split-down': () => this.open( + this.profilesSingleton.generateNewUri(), + { split: 'down' }, + ), + 'x-terminal:open-split-left': () => this.open( + this.profilesSingleton.generateNewUri(), + { split: 'left' }, + ), + 'x-terminal:open-split-right': () => this.open( + this.profilesSingleton.generateNewUri(), + { split: 'right' }, + ), + 'x-terminal:open-split-bottom-dock': () => this.openInCenterOrDock(atom.workspace.getBottomDock()), + 'x-terminal:open-split-left-dock': () => this.openInCenterOrDock(atom.workspace.getLeftDock()), + 'x-terminal:open-split-right-dock': () => this.openInCenterOrDock(atom.workspace.getRightDock()), + 'x-terminal:toggle-profile-menu': () => this.toggleProfileMenu(), + 'x-terminal:reorganize': () => this.reorganize('current'), + 'x-terminal:reorganize-top': () => this.reorganize('top'), + 'x-terminal:reorganize-bottom': () => this.reorganize('bottom'), + 'x-terminal:reorganize-left': () => this.reorganize('left'), + 'x-terminal:reorganize-right': () => this.reorganize('right'), + 'x-terminal:reorganize-bottom-dock': () => this.reorganize('bottom-dock'), + 'x-terminal:reorganize-left-dock': () => this.reorganize('left-dock'), + 'x-terminal:reorganize-right-dock': () => this.reorganize('right-dock'), + 'x-terminal:close-all': () => this.exitAllTerminals(), + 'x-terminal:insert-selected-text': () => this.insertSelection(), + 'x-terminal:run-selected-text': () => this.runSelection(), + }), + atom.commands.add('x-terminal', { + 'x-terminal:close': () => this.close(), + 'x-terminal:restart': () => this.restart(), + 'x-terminal:copy': () => this.copy(), + 'x-terminal:paste': () => this.paste(), + }), + ) } deactivate () { @@ -262,7 +296,7 @@ class XTerminalSingleton { getActiveTerminal () { const terminals = [...this.terminals_set] - return terminals.find(t => t.isActive()) + return terminals.find(t => t.isActiveTerminal()) } insertSelection () {