Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions client-node-tests/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,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<void> {
this.events.push('start');
}

public override stop(): Promise<void> {
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 () => {
Expand Down
50 changes: 50 additions & 0 deletions client/src/common/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -407,6 +417,7 @@ type ResolvedClientOptions = {
synchronize: SynchronizeOptions;
diagnosticCollectionName?: string;
outputChannelName: string;
outputChannelId: string;
revealOutputChannelOn: RevealOutputChannelOn;
stdioEncoding: string;
initializationOptions?: any | (() => any);
Expand Down Expand Up @@ -718,6 +729,7 @@ export abstract class BaseLanguageClient implements FeatureClient<Middleware, La
synchronize: clientOptions.synchronize ?? {},
diagnosticCollectionName: clientOptions.diagnosticCollectionName,
outputChannelName: clientOptions.outputChannelName ?? this._name,
outputChannelId: clientOptions.outputChannelId ?? this._id,
revealOutputChannelOn: clientOptions.revealOutputChannelOn ?? RevealOutputChannelOn.Error,
stdioEncoding: clientOptions.stdioEncoding ?? 'utf8',
initializationOptions: clientOptions.initializationOptions,
Expand Down Expand Up @@ -858,6 +870,29 @@ export abstract class BaseLanguageClient implements FeatureClient<Middleware, La
return this._outputChannel;
}

protected isOutputChannelVisible(): boolean {
if (this._outputChannel === undefined) {
return false;
}
const outputChannelResource = getOutputChannelResourceName(this._clientOptions.outputChannelId, this._outputChannel.name);
return this.getVisibleTextEditors().some(editor => {
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;
}
Expand Down Expand Up @@ -2548,6 +2583,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 {
Expand Down
2 changes: 2 additions & 0 deletions client/src/node/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export class LanguageClient extends BaseLanguageClient {
}

public async restart(): Promise<void> {
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
Expand All @@ -247,6 +248,7 @@ export class LanguageClient extends BaseLanguageClient {
} else {
await this.start();
}
this.restoreOutputChannelVisibility(outputChannelVisible);
}

protected shutdown(mode: ShutdownMode, timeout: number = 2000): Promise<void> {
Expand Down