From 6cef16ccf103c83586869f9461c50c7fadc5e347 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:28:30 +0800 Subject: [PATCH 1/3] Add configurable Compose task terminal closing --- extensions/vscode-containers/package.json | 8 ++++++++ extensions/vscode-containers/package.nls.json | 1 + .../src/commands/compose/compose.ts | 2 ++ .../src/commands/containers/composeGroup.ts | 17 ++++++++++++----- .../runners/TaskCommandRunnerFactory.ts | 10 +++++----- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/extensions/vscode-containers/package.json b/extensions/vscode-containers/package.json index c9656f64..14c957ae 100644 --- a/extensions/vscode-containers/package.json +++ b/extensions/vscode-containers/package.json @@ -2555,6 +2555,14 @@ "default": true, "description": "%vscode-containers.config.containers.composeDetached%" }, + "containers.closeComposeTaskTerminal": { + "type": "boolean", + "default": false, + "description": "%vscode-containers.config.containers.closeComposeTaskTerminal%", + "tags": [ + "advanced" + ] + }, "containers.showRemoteWorkspaceWarning": { "type": "boolean", "default": true, diff --git a/extensions/vscode-containers/package.nls.json b/extensions/vscode-containers/package.nls.json index 349b01c7..6c4284c9 100644 --- a/extensions/vscode-containers/package.nls.json +++ b/extensions/vscode-containers/package.nls.json @@ -206,6 +206,7 @@ "vscode-containers.config.docker.languageserver.formatter.ignoreMultilineInstructions": "Controls whether the Dockerfile formatter should ignore instructions that span multiple lines when formatting", "vscode-containers.config.containers.composeBuild": "Set to true to include --build option when compose command is invoked", "vscode-containers.config.containers.composeDetached": "Set to true to include --d (detached) option when compose command is invoked", + "vscode-containers.config.containers.closeComposeTaskTerminal": "Automatically close the terminal after a Compose command run by Container Tools completes.", "vscode-containers.config.containers.showRemoteWorkspaceWarning": "Set to true to prompt to switch from \"UI\" extension mode to \"Workspace\" extension mode if an operation is not supported in UI mode.", "vscode-containers.config.containers.scaffolding.templatePath": "The path to use for scaffolding templates.", "vscode-containers.config.containers.containerCommand": "Command to use for container actions (e.g. `docker` command). If the executable path contains whitespace, it needs to be quoted appropriately. If unset, the extension will attempt to auto-detect the command to use.", diff --git a/extensions/vscode-containers/src/commands/compose/compose.ts b/extensions/vscode-containers/src/commands/compose/compose.ts index 0338c2a7..f741e528 100644 --- a/extensions/vscode-containers/src/commands/compose/compose.ts +++ b/extensions/vscode-containers/src/commands/compose/compose.ts @@ -47,6 +47,7 @@ async function compose(context: IActionContext, commands: ('up' | 'down' | 'upSu const configOptions: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration(configPrefix); const build: boolean = configOptions.get('composeBuild', true); const detached: boolean = configOptions.get('composeDetached', true); + const closeTaskTerminal: boolean = configOptions.get('closeComposeTaskTerminal', false); for (const command of commands) { if (selectedItems.length === 0) { @@ -77,6 +78,7 @@ async function compose(context: IActionContext, commands: ('up' | 'down' | 'upSu const taskCRF = new TaskCommandRunnerFactory({ taskName: client.displayName, workspaceFolder: folder, + close: closeTaskTerminal, }); await taskCRF.getCommandRunner()(terminalCommand); diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index 17dc9729..c34fa9fc 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -7,6 +7,7 @@ import { IActionContext } from '@microsoft/vscode-azext-utils'; import { CommonOrchestratorCommandOptions, IContainerOrchestratorClient, LogsCommandOptions, VoidCommandResponse } from '@microsoft/vscode-container-client'; import * as path from 'path'; import { l10n, Uri, workspace } from 'vscode'; +import { configPrefix } from '../../constants'; import { ext } from '../../extensionVariables'; import { TaskCommandRunnerFactory } from '../../runtimes/runners/TaskCommandRunnerFactory'; import { ContainerGroupTreeItem } from '../../tree/containers/ContainerGroupTreeItem'; @@ -30,19 +31,19 @@ export async function composeGroupLogs(context: IActionContext, node: ContainerG }, node, { follow: true, tail: 1000 }); } export async function composeGroupStart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.start(options), node); + return composeGroup(context, (client, options) => client.start(options), node, undefined, shouldCloseComposeTaskTerminal()); } export async function composeGroupStop(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.stop(options), node); + return composeGroup(context, (client, options) => client.stop(options), node, undefined, shouldCloseComposeTaskTerminal()); } export async function composeGroupRestart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.restart(options), node); + return composeGroup(context, (client, options) => client.restart(options), node, undefined, shouldCloseComposeTaskTerminal()); } export async function composeGroupDown(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.down(options), node); + return composeGroup(context, (client, options) => client.down(options), node, undefined, shouldCloseComposeTaskTerminal()); } type AdditionalOptions = Omit; @@ -51,7 +52,8 @@ async function composeGroup( context: IActionContext, composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, node: ContainerGroupTreeItem, - additionalOptions?: AdditionalOptions + additionalOptions?: AdditionalOptions, + closeTaskTerminal?: boolean ): Promise { if (!node) { await ext.containersTree.refresh(context); @@ -84,11 +86,16 @@ async function composeGroup( const taskCRF = new TaskCommandRunnerFactory({ taskName: client.displayName, cwd: workingDirectory, + close: closeTaskTerminal, }); await taskCRF.getCommandRunner()(composeCommandCallback(client, options)); } +function shouldCloseComposeTaskTerminal(): boolean { + return workspace.getConfiguration(configPrefix).get('closeComposeTaskTerminal', false); +} + /** * Gets the accurate label map for a compose container group. * diff --git a/extensions/vscode-containers/src/runtimes/runners/TaskCommandRunnerFactory.ts b/extensions/vscode-containers/src/runtimes/runners/TaskCommandRunnerFactory.ts index 66ccd0cc..51f2653e 100644 --- a/extensions/vscode-containers/src/runtimes/runners/TaskCommandRunnerFactory.ts +++ b/extensions/vscode-containers/src/runtimes/runners/TaskCommandRunnerFactory.ts @@ -15,6 +15,7 @@ interface TaskCommandRunnerOptions { alwaysRunNew?: boolean; rejectOnError?: boolean; focus?: boolean; + close?: boolean; env?: never; // Environment is not needed and should not be used, because VSCode adds it already (due to using `ExtensionContext.environmentVariableCollection`) } @@ -57,11 +58,10 @@ async function executeAsTask(options: TaskCommandRunnerOptions, command: string, task.definition.idRandomizer = Math.random(); } - if (options.focus) { - task.presentationOptions = { - focus: true, - }; - } + task.presentationOptions = { + focus: options.focus, + close: options.close, + }; const taskExecution = await vscode.tasks.executeTask(task); From 78bb44efb5f18dd648031c766865b426a621cd84 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:14:04 +0800 Subject: [PATCH 2/3] Test task terminal presentation options --- .../runners/TaskCommandRunnerFactory.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts diff --git a/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts b/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts new file mode 100644 index 00000000..d6843af7 --- /dev/null +++ b/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.md in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from 'chai'; +import * as vscode from 'vscode'; +import { TaskCommandRunnerFactory } from '../../../runtimes/runners/TaskCommandRunnerFactory'; + +suite('(unit) TaskCommandRunnerFactory', () => { + async function executeTask(options: { close?: boolean; focus?: boolean }): Promise { + const taskName = `TaskCommandRunnerFactory test ${Date.now()} ${Math.random()}`; + let presentationOptions: vscode.TaskPresentationOptions | undefined; + + const taskStarted = new Promise(resolve => { + const disposable = vscode.tasks.onDidStartTask(event => { + if (event.execution.task.name === taskName) { + presentationOptions = event.execution.task.presentationOptions; + disposable.dispose(); + resolve(); + } + }); + }); + + const commandResponse = process.platform === 'win32' ? + { command: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/c', 'exit', '0'] } : + { command: '/bin/sh', args: ['-c', 'exit 0'] }; + + const runner = new TaskCommandRunnerFactory({ taskName, ...options }).getCommandRunner(); + await Promise.all([runner(commandResponse), taskStarted]); + + expect(presentationOptions).not.to.be.undefined; + return presentationOptions; + } + + test('Leaves presentation options unset when they are not provided', async () => { + const presentationOptions = await executeTask({}); + + expect(presentationOptions.focus).to.be.undefined; + expect(presentationOptions.close).to.be.undefined; + }); + + test('Preserves explicitly disabled presentation options', async () => { + const presentationOptions = await executeTask({ focus: false, close: false }); + + expect(presentationOptions.focus).to.equal(false); + expect(presentationOptions.close).to.equal(false); + }); + + test('Enables terminal closing without changing focus', async () => { + const presentationOptions = await executeTask({ close: true }); + + expect(presentationOptions.focus).to.be.undefined; + expect(presentationOptions.close).to.equal(true); + }); +}); From 044aaa6d4fc12f605358365f7c26cb6fb435ac99 Mon Sep 17 00:00:00 2001 From: Zhibo Lin <147509942+LE0-Lin@users.noreply.github.com> Date: Mon, 24 Aug 2026 22:25:44 +0800 Subject: [PATCH 3/3] Address compose terminal review feedback --- extensions/vscode-containers/package.nls.json | 2 +- .../src/commands/containers/composeGroup.ts | 17 ++++++----------- .../runners/TaskCommandRunnerFactory.test.ts | 16 +++++++++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/extensions/vscode-containers/package.nls.json b/extensions/vscode-containers/package.nls.json index 6c4284c9..6c9f3577 100644 --- a/extensions/vscode-containers/package.nls.json +++ b/extensions/vscode-containers/package.nls.json @@ -206,7 +206,7 @@ "vscode-containers.config.docker.languageserver.formatter.ignoreMultilineInstructions": "Controls whether the Dockerfile formatter should ignore instructions that span multiple lines when formatting", "vscode-containers.config.containers.composeBuild": "Set to true to include --build option when compose command is invoked", "vscode-containers.config.containers.composeDetached": "Set to true to include --d (detached) option when compose command is invoked", - "vscode-containers.config.containers.closeComposeTaskTerminal": "Automatically close the terminal after a Compose command run by Container Tools completes.", + "vscode-containers.config.containers.closeComposeTaskTerminal": "Automatically close the terminal after a Compose command run by Container Tools is finished.", "vscode-containers.config.containers.showRemoteWorkspaceWarning": "Set to true to prompt to switch from \"UI\" extension mode to \"Workspace\" extension mode if an operation is not supported in UI mode.", "vscode-containers.config.containers.scaffolding.templatePath": "The path to use for scaffolding templates.", "vscode-containers.config.containers.containerCommand": "Command to use for container actions (e.g. `docker` command). If the executable path contains whitespace, it needs to be quoted appropriately. If unset, the extension will attempt to auto-detect the command to use.", diff --git a/extensions/vscode-containers/src/commands/containers/composeGroup.ts b/extensions/vscode-containers/src/commands/containers/composeGroup.ts index c34fa9fc..fa0edd92 100644 --- a/extensions/vscode-containers/src/commands/containers/composeGroup.ts +++ b/extensions/vscode-containers/src/commands/containers/composeGroup.ts @@ -31,19 +31,19 @@ export async function composeGroupLogs(context: IActionContext, node: ContainerG }, node, { follow: true, tail: 1000 }); } export async function composeGroupStart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.start(options), node, undefined, shouldCloseComposeTaskTerminal()); + return composeGroup(context, (client, options) => client.start(options), node); } export async function composeGroupStop(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.stop(options), node, undefined, shouldCloseComposeTaskTerminal()); + return composeGroup(context, (client, options) => client.stop(options), node); } export async function composeGroupRestart(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.restart(options), node, undefined, shouldCloseComposeTaskTerminal()); + return composeGroup(context, (client, options) => client.restart(options), node); } export async function composeGroupDown(context: IActionContext, node: ContainerGroupTreeItem): Promise { - return composeGroup(context, (client, options) => client.down(options), node, undefined, shouldCloseComposeTaskTerminal()); + return composeGroup(context, (client, options) => client.down(options), node); } type AdditionalOptions = Omit; @@ -52,8 +52,7 @@ async function composeGroup( context: IActionContext, composeCommandCallback: (client: IContainerOrchestratorClient, options: TOptions) => Promise, node: ContainerGroupTreeItem, - additionalOptions?: AdditionalOptions, - closeTaskTerminal?: boolean + additionalOptions?: AdditionalOptions ): Promise { if (!node) { await ext.containersTree.refresh(context); @@ -86,16 +85,12 @@ async function composeGroup( const taskCRF = new TaskCommandRunnerFactory({ taskName: client.displayName, cwd: workingDirectory, - close: closeTaskTerminal, + close: workspace.getConfiguration(configPrefix).get('closeComposeTaskTerminal', false), }); await taskCRF.getCommandRunner()(composeCommandCallback(client, options)); } -function shouldCloseComposeTaskTerminal(): boolean { - return workspace.getConfiguration(configPrefix).get('closeComposeTaskTerminal', false); -} - /** * Gets the accurate label map for a compose container group. * diff --git a/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts b/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts index d6843af7..055b90c3 100644 --- a/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts +++ b/extensions/vscode-containers/src/test/runtimes/runners/TaskCommandRunnerFactory.test.ts @@ -11,11 +11,13 @@ suite('(unit) TaskCommandRunnerFactory', () => { async function executeTask(options: { close?: boolean; focus?: boolean }): Promise { const taskName = `TaskCommandRunnerFactory test ${Date.now()} ${Math.random()}`; let presentationOptions: vscode.TaskPresentationOptions | undefined; + let taskExecution: vscode.TaskExecution | undefined; const taskStarted = new Promise(resolve => { const disposable = vscode.tasks.onDidStartTask(event => { if (event.execution.task.name === taskName) { presentationOptions = event.execution.task.presentationOptions; + taskExecution = event.execution; disposable.dispose(); resolve(); } @@ -23,11 +25,15 @@ suite('(unit) TaskCommandRunnerFactory', () => { }); const commandResponse = process.platform === 'win32' ? - { command: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/c', 'exit', '0'] } : - { command: '/bin/sh', args: ['-c', 'exit 0'] }; - - const runner = new TaskCommandRunnerFactory({ taskName, ...options }).getCommandRunner(); - await Promise.all([runner(commandResponse), taskStarted]); + { command: process.env.ComSpec ?? 'cmd.exe', args: ['/d', '/c', 'ping', '-n', '30', '127.0.0.1'] } : + { command: '/bin/sh', args: ['-c', 'sleep 30'] }; + + const runner = new TaskCommandRunnerFactory({ taskName, alwaysRunNew: true, ...options }).getCommandRunner(); + const runnerPromise = runner(commandResponse); + await taskStarted; + await new Promise(resolve => setTimeout(resolve, 100)); + taskExecution?.terminate(); + await runnerPromise; expect(presentationOptions).not.to.be.undefined; return presentationOptions;