Skip to content
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,9 @@ export interface ITerminalGroupService extends ITerminalInstanceHost {
*/
moveGroup(source: SingleOrMany<ITerminalInstance>, target: ITerminalInstance): void;
moveGroupToEnd(source: SingleOrMany<ITerminalInstance>): void;
moveGroupUp(group: ITerminalGroup): void;
moveGroupDown(group: ITerminalGroup): void;
getGroupsBelow(group: ITerminalGroup): ITerminalGroup[];

moveInstance(source: ITerminalInstance, target: ITerminalInstance, side: 'before' | 'after'): void;
unsplitInstance(instance: ITerminalInstance): void;
Expand Down
52 changes: 52 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminalActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { isKeyboardEvent, isMouseEvent, isPointerEvent, getActiveWindow } from '../../../../base/browser/dom.js';
import { Limiter } from '../../../../base/common/async.js';
import { Action } from '../../../../base/common/actions.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Codicon } from '../../../../base/common/codicons.js';
Expand Down Expand Up @@ -561,6 +562,57 @@ export function registerTerminalActions() {
}
});

registerTerminalAction({
id: TerminalCommandId.MoveTabUp,
title: localize2('workbench.action.terminal.moveTabUp', 'Move Terminal Tab Up'),
precondition: ContextKeyExpr.greater(TerminalContextKeys.groupCount.key, 1),
run: (c, accessor, focusedArgs, allInstanceArgs) => {
const instances = getSelectedViewInstances2(accessor, allInstanceArgs) ?? getSelectedViewInstances2(accessor, focusedArgs);
const instance = instances?.[0] ?? c.groupService.activeInstance;
const group = instance ? c.groupService.getGroupForInstance(instance) : c.groupService.activeGroup;
if (group) {
c.groupService.moveGroupUp(group);
}
}
});

registerTerminalAction({
id: TerminalCommandId.MoveTabDown,
title: localize2('workbench.action.terminal.moveTabDown', 'Move Terminal Tab Down'),
precondition: ContextKeyExpr.greater(TerminalContextKeys.groupCount.key, 1),
run: (c, accessor, focusedArgs, allInstanceArgs) => {
const instances = getSelectedViewInstances2(accessor, allInstanceArgs) ?? getSelectedViewInstances2(accessor, focusedArgs);
const instance = instances?.[0] ?? c.groupService.activeInstance;
const group = instance ? c.groupService.getGroupForInstance(instance) : c.groupService.activeGroup;
if (group) {
c.groupService.moveGroupDown(group);
}
}
});

registerTerminalAction({
id: TerminalCommandId.KillGroupsBelow,
title: localize2('workbench.action.terminal.killGroupsBelow', 'Kill Terminals Below'),
f1: true,
precondition: ContextKeyExpr.greater(TerminalContextKeys.groupCount.key, 1),
run: async (c, accessor, focusedArgs, allInstanceArgs) => {
const instances = getSelectedViewInstances2(accessor, allInstanceArgs) ?? getSelectedViewInstances2(accessor, focusedArgs);
const instance = instances?.[0] ?? c.groupService.activeInstance;
const group = instance ? c.groupService.getGroupForInstance(instance) : c.groupService.activeGroup;
if (group) {
const groupsBelow = c.groupService.getGroupsBelow(group);
const limiter = new Limiter<void>(5);
const disposePromises: Promise<void>[] = [];
for (const g of groupsBelow) {
for (const inst of g.terminalInstances.slice()) {
disposePromises.push(limiter.queue(() => c.service.safeDisposeTerminal(inst)));
}
}
await Promise.all(disposePromises);
Comment thread
iliazlobin marked this conversation as resolved.
}
}
});

registerTerminalAction({
id: TerminalCommandId.RunSelectedText,
title: localize2('workbench.action.terminal.runSelectedText', 'Run Selected Text In Active Terminal'),
Expand Down
41 changes: 41 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminalGroupService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe
super();

const terminalGroupCountContextKey = TerminalContextKeys.groupCount.bindTo(this._contextKeyService);
const terminalActiveGroupIsLastContextKey = TerminalContextKeys.activeGroupIsLast.bindTo(this._contextKeyService);
this._register(Event.runAndSubscribe(this.onDidChangeGroups, () => terminalGroupCountContextKey.set(this.groups.length)));
this._register(Event.runAndSubscribe(Event.any(this.onDidChangeActiveGroup, this.onDidChangeGroups), () => {
const idx = this.activeGroupIndex;
terminalActiveGroupIsLastContextKey.set(idx >= 0 && idx === this.groups.length - 1);
}));

const splitTerminalActiveContextKey = TerminalContextKeys.splitTerminalActive.bindTo(this._contextKeyService);
this._register(Event.runAndSubscribe(this.onDidFocusInstance, () => {
Expand Down Expand Up @@ -398,6 +403,42 @@ export class TerminalGroupService extends Disposable implements ITerminalGroupSe
this._onDidChangeInstances.fire();
}

moveGroupUp(group: ITerminalGroup): void {
const index = this.groups.indexOf(group);
if (index <= 0) {
return;
}
this.groups.splice(index - 1, 0, this.groups.splice(index, 1)[0]);
if (this.activeGroupIndex === index) {
this.activeGroupIndex = index - 1;
} else if (this.activeGroupIndex === index - 1) {
this.activeGroupIndex = index;
}
this._onDidChangeInstances.fire();
}

moveGroupDown(group: ITerminalGroup): void {
const index = this.groups.indexOf(group);
if (index < 0 || index >= this.groups.length - 1) {
return;
}
this.groups.splice(index + 1, 0, this.groups.splice(index, 1)[0]);
if (this.activeGroupIndex === index) {
this.activeGroupIndex = index + 1;
} else if (this.activeGroupIndex === index + 1) {
this.activeGroupIndex = index;
}
this._onDidChangeInstances.fire();
}

getGroupsBelow(group: ITerminalGroup): ITerminalGroup[] {
const index = this.groups.indexOf(group);
if (index < 0) {
return [];
}
return this.groups.slice(index + 1);
}
Comment thread
iliazlobin marked this conversation as resolved.

moveInstance(source: ITerminalInstance, target: ITerminalInstance, side: 'before' | 'after') {
const sourceGroup = this.getGroupForInstance(source);
const targetGroup = this.getGroupForInstance(target);
Expand Down
12 changes: 12 additions & 0 deletions src/vs/workbench/contrib/terminal/browser/terminalMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,18 @@ export function setupTerminalMenus(): void {
},
group: TerminalContextMenuGroup.Kill,
}
},
{
id: MenuId.TerminalTabContext,
item: {
command: {
id: TerminalCommandId.KillGroupsBelow,
title: localize('killGroupsBelow', 'Kill Terminals Below')
},
when: ContextKeyExpr.and(ContextKeyExpr.greater(TerminalContextKeys.groupCount.key, 1), TerminalContextKeys.activeGroupIsLast.toNegated()),
group: TerminalContextMenuGroup.Kill,
order: 1
}
Comment thread
iliazlobin marked this conversation as resolved.
}
Comment thread
iliazlobin marked this conversation as resolved.
]
);
Expand Down
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/terminal/common/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,9 @@ export const enum TerminalCommandId {
FocusInstance = 'workbench.action.terminal.focusInstance',
FocusNext = 'workbench.action.terminal.focusNext',
FocusPrevious = 'workbench.action.terminal.focusPrevious',
MoveTabUp = 'workbench.action.terminal.moveTabUp',
MoveTabDown = 'workbench.action.terminal.moveTabDown',
KillGroupsBelow = 'workbench.action.terminal.killGroupsBelow',
Paste = 'workbench.action.terminal.paste',
PastePwsh = 'workbench.action.terminal.pastePwsh',
PasteSelection = 'workbench.action.terminal.pasteSelection',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const enum TerminalContextKeyStrings {
ShellType = 'terminalShellType',
InTerminalRunCommandPicker = 'inTerminalRunCommandPicker',
TerminalShellIntegrationEnabled = 'terminalShellIntegrationEnabled',
DictationInProgress = 'terminalDictationInProgress'
DictationInProgress = 'terminalDictationInProgress',
ActiveGroupIsLast = 'terminalActiveGroupIsLast'
}

export namespace TerminalContextKeys {
Expand All @@ -63,6 +64,9 @@ export namespace TerminalContextKeys {
/** The current number of terminal groups. */
export const groupCount = new RawContextKey<number>(TerminalContextKeyStrings.GroupCount, 0, true);

/** Whether the active terminal group is the last one in the list. */
export const activeGroupIsLast = new RawContextKey<boolean>(TerminalContextKeyStrings.ActiveGroupIsLast, false, true);

/** Whether the terminal tabs view is narrow. */
export const tabsNarrow = new RawContextKey<boolean>(TerminalContextKeyStrings.TabsNarrow, false, true);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { deepStrictEqual, strictEqual } from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { ITerminalGroup } from '../../browser/terminal.js';
import { TerminalGroupService } from '../../browser/terminalGroupService.js';
import { IViewsService } from '../../../../services/views/common/viewsService.js';
import { TestViewsService, workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';

function makeGroup(id: number): ITerminalGroup {
return { terminalInstances: [], id, setVisible: () => { } } as unknown as ITerminalGroup;
}

suite('Workbench - TerminalGroupService', () => {
const store = ensureNoDisposablesAreLeakedInTestSuite();

let service: TerminalGroupService;

setup(() => {
const instantiationService = workbenchInstantiationService(undefined, store);
instantiationService.stub(IViewsService, new TestViewsService());
service = store.add(instantiationService.createInstance(TerminalGroupService));
});

suite('moveGroupUp', () => {
test('moving the first group is a no-op', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupUp(a);
deepStrictEqual(service.groups, [a, b, c]);
});

test('moving an unknown group is a no-op', () => {
const [a, b] = [makeGroup(1), makeGroup(2)];
service.groups.push(a, b);
service.moveGroupUp(makeGroup(99));
deepStrictEqual(service.groups, [a, b]);
});

test('moving a middle group swaps positions correctly', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupUp(b);
deepStrictEqual(service.groups, [b, a, c]);
});

test('moving the last group up swaps with previous', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupUp(c);
deepStrictEqual(service.groups, [a, c, b]);
});

test('activeGroupIndex follows the moved group', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.activeGroupIndex = 1; // b is active
service.moveGroupUp(b);
strictEqual(service.activeGroupIndex, 0);
});

test('activeGroupIndex updates when active group is displaced', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.activeGroupIndex = 0; // a is active, b moves up over it
service.moveGroupUp(b);
strictEqual(service.activeGroupIndex, 1);
});
});

suite('moveGroupDown', () => {
test('moving the last group is a no-op', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupDown(c);
deepStrictEqual(service.groups, [a, b, c]);
});

test('moving an unknown group is a no-op', () => {
const [a, b] = [makeGroup(1), makeGroup(2)];
service.groups.push(a, b);
service.moveGroupDown(makeGroup(99));
deepStrictEqual(service.groups, [a, b]);
});

test('moving a middle group swaps positions correctly', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupDown(b);
deepStrictEqual(service.groups, [a, c, b]);
});

test('moving the first group down swaps with next', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.moveGroupDown(a);
deepStrictEqual(service.groups, [b, a, c]);
});

test('activeGroupIndex follows the moved group', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.activeGroupIndex = 1; // b is active
service.moveGroupDown(b);
strictEqual(service.activeGroupIndex, 2);
});

test('activeGroupIndex updates when active group is displaced', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
service.activeGroupIndex = 2; // c is active, b moves down over it
service.moveGroupDown(b);
strictEqual(service.activeGroupIndex, 1);
});
});

suite('getGroupsBelow', () => {
test('returns empty array for unknown group', () => {
const [a, b] = [makeGroup(1), makeGroup(2)];
service.groups.push(a, b);
deepStrictEqual(service.getGroupsBelow(makeGroup(99)), []);
});

test('returns empty array for the last group', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
deepStrictEqual(service.getGroupsBelow(c), []);
});

test('returns all groups below a middle group', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
deepStrictEqual(service.getGroupsBelow(b), [c]);
});

test('returns all groups below the first group', () => {
const [a, b, c] = [makeGroup(1), makeGroup(2), makeGroup(3)];
service.groups.push(a, b, c);
deepStrictEqual(service.getGroupsBelow(a), [b, c]);
});
});
});
3 changes: 3 additions & 0 deletions src/vs/workbench/test/browser/workbenchTestServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,9 @@ export class TestTerminalGroupService implements ITerminalGroupService {
getGroupForInstance(instance: ITerminalInstance): ITerminalGroup | undefined { throw new Error('Method not implemented.'); }
moveGroup(source: ITerminalInstance | ITerminalInstance[], target: ITerminalInstance): void { throw new Error('Method not implemented.'); }
moveGroupToEnd(source: ITerminalInstance | ITerminalInstance[]): void { throw new Error('Method not implemented.'); }
moveGroupUp(_group: ITerminalGroup): void { throw new Error('Method not implemented.'); }
moveGroupDown(_group: ITerminalGroup): void { throw new Error('Method not implemented.'); }
getGroupsBelow(_group: ITerminalGroup): ITerminalGroup[] { throw new Error('Method not implemented.'); }
moveInstance(source: ITerminalInstance, target: ITerminalInstance, side: 'before' | 'after'): void { throw new Error('Method not implemented.'); }
unsplitInstance(instance: ITerminalInstance): void { throw new Error('Method not implemented.'); }
joinInstances(instances: ITerminalInstance[]): void { throw new Error('Method not implemented.'); }
Expand Down