diff --git a/client-node-tests/src/integration.test.ts b/client-node-tests/src/integration.test.ts index d42dc5eb8..624d371a8 100644 --- a/client-node-tests/src/integration.test.ts +++ b/client-node-tests/src/integration.test.ts @@ -294,6 +294,149 @@ suite('Server output', () => { }); }); +suite('Client restart', () => { + + test('Preserves output channel visibility after restart', async () => { + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.test-extension.Restart.log', 'ms-vscode.test-extension.Restart.log') + ], 'ms-vscode.test-extension'); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:true']); + }); + + test('Does not restore hidden output channel after restart', async () => { + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:ms-vscode.test-extension.Other.log', 'ms-vscode.test-extension.Other.log') + ], 'ms-vscode.test-extension'); + + await client.restart(); + + assert.deepStrictEqual(client.events, ['getVisibleTextEditors', 'stop', 'start', 'restoreOutputChannelVisibility:false']); + }); + + test('Detects visible output channel by normalized log channel id', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:dbaeumer.vscode-eslint.ESLint.log', 'dbaeumer.vscode-eslint.ESLint.log') + ], 'dbaeumer.vscode-eslint'); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Uses language client id as the default output channel id', () => { + const client = new RestartTestLanguageClient('Restart', [ + createOutputTextEditor('output:test-restart.Restart.log', 'test-restart.Restart.log') + ], undefined); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Does not match the same output channel name from another extension', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:publisher.other-extension.ESLint.log', 'publisher.other-extension.ESLint.log') + ], 'dbaeumer.vscode-eslint'); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + + test('Does not match output channel names with different casing', () => { + const client = new RestartTestLanguageClient('Server', [ + createOutputTextEditor('output:ms-vscode.test-extension.server.log', 'ms-vscode.test-extension.server.log') + ], 'ms-vscode.test-extension'); + + assert.strictEqual(client.isTestOutputChannelVisible(), false); + }); + + test('Detects visible output channel by file name', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:unknown', 'dbaeumer.vscode-eslint.ESLint.log') + ], 'dbaeumer.vscode-eslint'); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); + + test('Does not match another visible output channel by substring', () => { + const client = new RestartTestLanguageClient('ESLint', [ + createOutputTextEditor('output:publisher.eslint-extension.Other.log', 'publisher.eslint-extension.Other.log') + ], 'dbaeumer.vscode-eslint'); + + 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.cpptools.CC++.log', 'ms-vscode.cpptools.CC++.log') + ], 'ms-vscode.cpptools'); + + assert.strictEqual(client.isTestOutputChannelVisible(), true); + }); +}); + +class RestartTestLanguageClient extends lsclient.LanguageClient { + + public readonly events: string[] = []; + + public constructor(outputChannelName: string, private readonly visibleTextEditors: readonly vscode.TextEditor[], outputChannelId: string | undefined) { + const clientOptions: lsclient.LanguageClientOptions = { outputChannel: createLogOutputChannel(outputChannelName) }; + if (outputChannelId !== undefined) { + clientOptions.outputChannelId = outputChannelId; + } + super('test-restart', 'Test Restart Language Server', { module: 'unused', transport: lsclient.TransportKind.ipc }, clientOptions); + } + + public isTestOutputChannelVisible(): boolean { + return this.isOutputChannelVisible(); + } + + public override async start(): Promise { + this.events.push('start'); + } + + public override stop(): Promise { + this.events.push('stop'); + return Promise.resolve(); + } + + protected override getVisibleTextEditors(): readonly vscode.TextEditor[] { + this.events.push('getVisibleTextEditors'); + return this.visibleTextEditors; + } + + protected override restoreOutputChannelVisibility(wasVisible: boolean): void { + this.events.push(`restoreOutputChannelVisibility:${wasVisible}`); + } +} + +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('Socket transport', () => { test('Uses an OS-assigned port when transport.port is 0', async () => { diff --git a/client/src/common/client.ts b/client/src/common/client.ts index ba3e06c06..d0fe74996 100644 --- a/client/src/common/client.ts +++ b/client/src/common/client.ts @@ -359,6 +359,16 @@ export type LanguageClientOptions = { diagnosticCollectionName?: string; outputChannel?: LogOutputChannel; outputChannelName?: string; + /** + * The identifier VS Code uses as the prefix for this client's log output channel resource. + * + * For example, if an extension with id `publisher.extension` creates a log output channel + * named `Language`, VS Code represents the visible output document as + * `publisher.extension.Language.log`. Supplying the extension id here lets the client + * distinguish this channel from another extension's channel with the same name when preserving + * output channel visibility across restarts. If omitted, the language client id is used. + */ + outputChannelId?: string; traceOutputChannel?: LogOutputChannel; revealOutputChannelOn?: RevealOutputChannelOn; /** @@ -407,6 +417,7 @@ type ResolvedClientOptions = { synchronize: SynchronizeOptions; diagnosticCollectionName?: string; outputChannelName: string; + outputChannelId: string; revealOutputChannelOn: RevealOutputChannelOn; stdioEncoding: string; initializationOptions?: any | (() => any); @@ -718,6 +729,7 @@ export abstract class BaseLanguageClient implements FeatureClient { + if (editor.document.uri.scheme !== 'output') { + return false; + } + 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); + } + } + public get traceOutputChannel(): LogOutputChannel { return this._traceOutputChannel ? this._traceOutputChannel : this.outputChannel; } @@ -2571,6 +2606,21 @@ function createConnection(input: MessageReader, output: MessageWriter, errorHand return result; } +function getOutputChannelResourceName(id: string, name: string): string { + const resourceId = sanitizeOutputChannelResourceSegment(id); + const resourceName = sanitizeOutputChannelResourceSegment(name); + return `${resourceId}.${resourceName}.log`; +} + +function sanitizeOutputChannelResourceSegment(value: string): string { + return value.replace(/[\\/:\*\?"<>\|]/g, ''); +} + +function matchesOutputChannelResource(resource: string, outputChannelResource: string): boolean { + const normalizedResource = resource.replace(/\\/g, '/'); + return normalizedResource === outputChannelResource || normalizedResource.endsWith(`/${outputChannelResource}`) || normalizedResource.endsWith(`:${outputChannelResource}`); +} + // Exporting proposed protocol. export namespace ProposedFeatures { diff --git a/client/src/node/main.ts b/client/src/node/main.ts index 585d501bd..89a218921 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 {