From c175c387742c41cac1b83a3de03677f226519548 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Wed, 2 Sep 2026 14:39:47 -0400 Subject: [PATCH 1/2] Preserve output channel visibility on restart --- client-node-tests/src/integration.test.ts | 46 +++++++++++++++++++++++ client/src/common/client.ts | 20 ++++++++++ client/src/node/main.ts | 2 + 3 files changed, 68 insertions(+) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index c4b4bfbe2..113d826bb 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -242,6 +242,52 @@ suite('Server output', () => { }); }); +suite('Client restart', () => { + + test('Preserves output channel visibility after restart', async () => { + const client = new RestartTestLanguageClient(true); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + }); + + test('Does not restore hidden output channel after restart', async () => { + const client = new RestartTestLanguageClient(false); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + }); +}); + +class RestartTestLanguageClient extends lsclient.LanguageClient { + + public readonly events: string[] = []; + + public constructor(private readonly outputChannelVisible: boolean) { + super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, {}); + } + + public override async start(): Promise { + this.events.push('start'); + } + + public override stop(): Promise { + this.events.push('stop'); + return Promise.resolve(); + } + + protected override isOutputChannelVisible(): boolean { + this.events.push('isOutputChannelVisible'); + return this.outputChannelVisible; + } + + protected override restoreOutputChannelVisibility(wasVisible: boolean): void { + this.events.push(`restoreOutputChannelVisibility:${wasVisible}`); + } +} + suite('Client integration', () => { let client!: lsclient.LanguageClient; diff --git a/client/src/common/client.ts b/client/src/common/client.ts index a5953c6ab..ed6dafd3a 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -858,6 +858,26 @@ export abstract class BaseLanguageClient implements FeatureClient { + if (editor.document.uri.scheme !== 'output') { + return false; + } + const outputResource = `${editor.document.uri.toString(true)}\n${editor.document.fileName}`.toLowerCase(); + return outputResource.includes(outputChannelName); + }); + } + + protected restoreOutputChannelVisibility(wasVisible: boolean): void { + if (wasVisible) { + this.outputChannel.show(true); + } + } + public get traceOutputChannel(): LogOutputChannel { return this._traceOutputChannel ? this._traceOutputChannel : this.outputChannel; } diff --git a/client/src/node/main.ts b/client/src/node/main.ts index 5f91530c4..7f128c5cf 100644 --- a/client/src/node/main.ts +++ b/client/src/node/main.ts @@ -236,6 +236,7 @@ export class LanguageClient extends BaseLanguageClient { } public async restart(): Promise { + const outputChannelVisible = this.isOutputChannelVisible(); await this.stop(); // We are in debug mode. Wait a little before we restart // so that the debug port can be freed. We can safely ignore @@ -247,6 +248,7 @@ export class LanguageClient extends BaseLanguageClient { } else { await this.start(); } + this.restoreOutputChannelVisibility(outputChannelVisible); } protected shutdown(mode: ShutdownMode, timeout: number = 2000): Promise { From 7734809416873c38451d647fabd87a3f156ccfee Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Thu, 10 Sep 2026 04:00:08 -0400 Subject: [PATCH 2/2] Tighten output channel visibility detection --- client-node-tests/src/integration.test.ts | 79 ++++++++++++++++++++--- client/src/common/client.ts | 20 ++++-- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index 113d826bb..3a044ccbc 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -245,19 +245,47 @@ suite('Server output', () => { suite('Client restart', () => { test('Preserves output channel visibility after restart', async () => { - const client = new RestartTestLanguageClient(true); + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'Restart.log') + ]); await client.restart(); - assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:true']); }); test('Does not restore hidden output channel after restart', async () => { - const client = new RestartTestLanguageClient(false); + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.restart.Other.log', 'Other.log') + ]); await client.restart(); - assert.deepStrictEqual(client.events, ['isOutputChannelVisible', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + }); + + test('Detects visible output channel by normalized log channel id suffix', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:ms-vscode.test-extension.ESLint.log', 'ESLint.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Does not match another visible output channel by substring', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:ms-vscode.eslint-extension.Other.log', 'Other.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + + test('Detects output channels with VS Code sanitized log file names', () => { + const client = new RestartTestLanguageClient('C/C++', [ + createOutputTextEditor('output:ms-vscode.test-extension.CC++.log', 'CC++.log') + ]); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); }); }); @@ -265,8 +293,12 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { public readonly events: string[] = []; - public constructor(private readonly outputChannelVisible: boolean) { - super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, {}); + public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[]) { + super('test restart', { module: 'unused', transport: lsclient.TransportKind.ipc }, { outputChannel: createLogOutputChannel(outputChannelName) }); + } + + public isTestOutputChannelVisible(): boolean { + return this.isOutputChannelVisible(); } public override async start(): Promise { @@ -278,9 +310,9 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { return Promise.resolve(); } - protected override isOutputChannelVisible(): boolean { - this.events.push('isOutputChannelVisible'); - return this.outputChannelVisible; + protected override getVisibleTextEditors(): readonly vscode.TextEditor[] { + this.events.push('getVisibleTextEditors'); + return this.visibleTextEditors; } protected override restoreOutputChannelVisibility(wasVisible: boolean): void { @@ -288,6 +320,35 @@ class RestartTestLanguageClient extends lsclient.LanguageClient { } } +function createOutputTextEditor(uri: string, fileName: string): vscode.TextEditor { + return { + document: { + uri: vscode.Uri.parse(uri), + fileName + } + } as vscode.TextEditor; +} + +function createLogOutputChannel(name: string): vscode.LogOutputChannel { + return { + name, + append: () => undefined, + appendLine: () => undefined, + replace: () => undefined, + clear: () => undefined, + show: () => undefined, + hide: () => undefined, + dispose: () => undefined, + logLevel: vscode.LogLevel.Info, + onDidChangeLogLevel: () => ({ dispose: () => undefined }), + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined + } as vscode.LogOutputChannel; +} + suite('Client integration', () => { let client!: lsclient.LanguageClient; diff --git a/client/src/common/client.ts b/client/src/common/client.ts index ed6dafd3a..8d20619e0 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -862,16 +862,19 @@ export abstract class BaseLanguageClient implements FeatureClient { + const outputChannelResource = getOutputChannelResourceName(this._outputChannel.name); + return this.getVisibleTextEditors().some(editor => { if (editor.document.uri.scheme !== 'output') { return false; } - const outputResource = `${editor.document.uri.toString(true)}\n${editor.document.fileName}`.toLowerCase(); - return outputResource.includes(outputChannelName); + return matchesOutputChannelResource(editor.document.uri.toString(true), outputChannelResource) || matchesOutputChannelResource(editor.document.fileName, outputChannelResource); }); } + protected getVisibleTextEditors(): readonly TextEditor[] { + return Window.visibleTextEditors; + } + protected restoreOutputChannelVisibility(wasVisible: boolean): void { if (wasVisible) { this.outputChannel.show(true); @@ -2568,6 +2571,15 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand return result; } +function getOutputChannelResourceName(name: string): string { + return `${name.replace(/[\\/:\*\?"<>\|]/g, '')}.log`.toLowerCase(); +} + +function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { + const normalizedResource = resource.replace(/\\/g, '/').toLowerCase(); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`.${outputChannelResource}`); +} + // Exporting proposed protocol. export namespace ProposedFeatures {