Skip to content

Commit 7ee3f7d

Browse files
authored
Show prompt asking user to install formatter extension (#20861)
For #19653
1 parent b9c4ff7 commit 7ee3f7d

File tree

11 files changed

+574
-31
lines changed

11 files changed

+574
-31
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client/common/application/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgu
6262
['workbench.action.quickOpen']: [string];
6363
['workbench.action.openWalkthrough']: [string | { category: string; step: string }, boolean | undefined];
6464
['workbench.extensions.installExtension']: [
65-
Uri | 'ms-python.python',
65+
Uri | string,
6666
(
6767
| {
6868
installOnlyNewlyAddedFromExtensionPackVSIX?: boolean;

src/client/common/experiments/groups.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ export enum ShowToolsExtensionPrompt {
1010
export enum TerminalEnvVarActivation {
1111
experiment = 'pythonTerminalEnvVarActivation',
1212
}
13+
14+
export enum ShowFormatterExtensionPrompt {
15+
experiment = 'pythonPromptNewFormatterExt',
16+
}

src/client/common/utils/localize.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,24 @@ export namespace ToolsExtensions {
473473
export const installPylintExtension = l10n.t('Install Pylint extension');
474474
export const installFlake8Extension = l10n.t('Install Flake8 extension');
475475
export const installISortExtension = l10n.t('Install isort extension');
476+
477+
export const selectBlackFormatterPrompt = l10n.t(
478+
'You have Black formatter extension installed, would you like to use that as the default formatter?',
479+
);
480+
481+
export const selectAutopep8FormatterPrompt = l10n.t(
482+
'You have Autopep8 formatter extension installed, would you like to use that as the default formatter?',
483+
);
484+
485+
export const selectMultipleFormattersPrompt = l10n.t(
486+
'You have multiple formatters installed, would you like to select one as the default formatter?',
487+
);
488+
489+
export const installBlackFormatterPrompt = l10n.t(
490+
'You triggered formatting with Black, would you like to install one of our new formatter extensions? This will also set it as the default formatter for Python.',
491+
);
492+
493+
export const installAutopep8FormatterPrompt = l10n.t(
494+
'You triggered formatting with Autopep8, would you like to install one of our new formatter extension? This will also set it as the default formatter for Python.',
495+
);
476496
}

src/client/common/vscodeApis/extensionsApi.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33

44
import * as path from 'path';
55
import * as fs from 'fs-extra';
6-
import { Extension, extensions } from 'vscode';
6+
import * as vscode from 'vscode';
77
import { PVSC_EXTENSION_ID } from '../constants';
88

9-
export function getExtension<T = unknown>(extensionId: string): Extension<T> | undefined {
10-
return extensions.getExtension(extensionId);
9+
export function getExtension<T = unknown>(extensionId: string): vscode.Extension<T> | undefined {
10+
return vscode.extensions.getExtension(extensionId);
1111
}
1212

1313
export function isExtensionEnabled(extensionId: string): boolean {
14-
return extensions.getExtension(extensionId) !== undefined;
14+
return vscode.extensions.getExtension(extensionId) !== undefined;
1515
}
1616

1717
export function isExtensionDisabled(extensionId: string): boolean {
@@ -28,3 +28,7 @@ export function isExtensionDisabled(extensionId: string): boolean {
2828
}
2929
return false;
3030
}
31+
32+
export function isInsider(): boolean {
33+
return vscode.env.appName.includes('Insider');
34+
}
Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,45 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import {
5-
CancellationToken,
6-
ConfigurationScope,
7-
GlobPattern,
8-
Uri,
9-
workspace,
10-
WorkspaceConfiguration,
11-
WorkspaceEdit,
12-
WorkspaceFolder,
13-
} from 'vscode';
4+
import * as vscode from 'vscode';
145
import { Resource } from '../types';
156

16-
export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined {
17-
return workspace.workspaceFolders;
7+
export function getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] | undefined {
8+
return vscode.workspace.workspaceFolders;
189
}
1910

20-
export function getWorkspaceFolder(uri: Resource): WorkspaceFolder | undefined {
21-
return uri ? workspace.getWorkspaceFolder(uri) : undefined;
11+
export function getWorkspaceFolder(uri: Resource): vscode.WorkspaceFolder | undefined {
12+
return uri ? vscode.workspace.getWorkspaceFolder(uri) : undefined;
2213
}
2314

2415
export function getWorkspaceFolderPaths(): string[] {
25-
return workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? [];
16+
return vscode.workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? [];
2617
}
2718

28-
export function getConfiguration(section?: string, scope?: ConfigurationScope | null): WorkspaceConfiguration {
29-
return workspace.getConfiguration(section, scope);
19+
export function getConfiguration(
20+
section?: string,
21+
scope?: vscode.ConfigurationScope | null,
22+
): vscode.WorkspaceConfiguration {
23+
return vscode.workspace.getConfiguration(section, scope);
3024
}
3125

32-
export function applyEdit(edit: WorkspaceEdit): Thenable<boolean> {
33-
return workspace.applyEdit(edit);
26+
export function applyEdit(edit: vscode.WorkspaceEdit): Thenable<boolean> {
27+
return vscode.workspace.applyEdit(edit);
3428
}
3529

3630
export function findFiles(
37-
include: GlobPattern,
38-
exclude?: GlobPattern | null,
31+
include: vscode.GlobPattern,
32+
exclude?: vscode.GlobPattern | null,
3933
maxResults?: number,
40-
token?: CancellationToken,
41-
): Thenable<Uri[]> {
42-
return workspace.findFiles(include, exclude, maxResults, token);
34+
token?: vscode.CancellationToken,
35+
): Thenable<vscode.Uri[]> {
36+
return vscode.workspace.findFiles(include, exclude, maxResults, token);
37+
}
38+
39+
export function onDidSaveTextDocument(
40+
listener: (e: vscode.TextDocument) => unknown,
41+
thisArgs?: unknown,
42+
disposables?: vscode.Disposable[],
43+
): vscode.Disposable {
44+
return vscode.workspace.onDidSaveTextDocument(listener, thisArgs, disposables);
4345
}

src/client/extensionActivation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { WorkspaceService } from './common/application/workspace';
6262
import { DynamicPythonDebugConfigurationService } from './debugger/extension/configuration/dynamicdebugConfigurationService';
6363
import { registerCreateEnvironmentFeatures } from './pythonEnvironments/creation/createEnvApi';
6464
import { IInterpreterQuickPick } from './interpreter/configuration/types';
65+
import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt';
6566

6667
export async function activateComponents(
6768
// `ext` is passed to any extra activation funcs.
@@ -206,13 +207,15 @@ async function activateLegacy(ext: ExtensionState): Promise<ActivationResult> {
206207
});
207208

208209
// register a dynamic configuration provider for 'python' debug type
209-
context.subscriptions.push(
210+
disposables.push(
210211
debug.registerDebugConfigurationProvider(
211212
DebuggerTypeName,
212213
serviceContainer.get<DynamicPythonDebugConfigurationService>(IDynamicDebugConfigurationService),
213214
DebugConfigurationProviderTriggerKind.Dynamic,
214215
),
215216
);
217+
218+
registerInstallFormatterPrompt(serviceContainer);
216219
}
217220
}
218221

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { Uri } from 'vscode';
5+
import { IDisposableRegistry } from '../../common/types';
6+
import { Common, ToolsExtensions } from '../../common/utils/localize';
7+
import { isExtensionEnabled } from '../../common/vscodeApis/extensionsApi';
8+
import { showInformationMessage } from '../../common/vscodeApis/windowApis';
9+
import { getConfiguration, onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis';
10+
import { IServiceContainer } from '../../ioc/types';
11+
import {
12+
doNotShowPromptState,
13+
inFormatterExtensionExperiment,
14+
installFormatterExtension,
15+
updateDefaultFormatter,
16+
} from './promptUtils';
17+
import { AUTOPEP8_EXTENSION, BLACK_EXTENSION, IInstallFormatterPrompt } from './types';
18+
19+
const SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY = 'showFormatterExtensionInstallPrompt';
20+
21+
export class InstallFormatterPrompt implements IInstallFormatterPrompt {
22+
private shownThisSession = false;
23+
24+
constructor(private readonly serviceContainer: IServiceContainer) {}
25+
26+
public async showInstallFormatterPrompt(resource?: Uri): Promise<void> {
27+
if (!inFormatterExtensionExperiment(this.serviceContainer)) {
28+
return;
29+
}
30+
31+
const promptState = doNotShowPromptState(SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY, this.serviceContainer);
32+
if (this.shownThisSession || promptState.value) {
33+
return;
34+
}
35+
36+
const config = getConfiguration('python', resource);
37+
const formatter = config.get<string>('formatting.provider', 'none');
38+
if (!['autopep8', 'black'].includes(formatter)) {
39+
return;
40+
}
41+
42+
const editorConfig = getConfiguration('editor', { uri: resource, languageId: 'python' });
43+
const defaultFormatter = editorConfig.get<string>('defaultFormatter', '');
44+
if ([BLACK_EXTENSION, AUTOPEP8_EXTENSION].includes(defaultFormatter)) {
45+
return;
46+
}
47+
48+
const black = isExtensionEnabled(BLACK_EXTENSION);
49+
const autopep8 = isExtensionEnabled(AUTOPEP8_EXTENSION);
50+
51+
let selection: string | undefined;
52+
53+
if (black || autopep8) {
54+
this.shownThisSession = true;
55+
if (black && autopep8) {
56+
selection = await showInformationMessage(
57+
ToolsExtensions.selectMultipleFormattersPrompt,
58+
'Black',
59+
'Autopep8',
60+
Common.doNotShowAgain,
61+
);
62+
} else if (black) {
63+
selection = await showInformationMessage(
64+
ToolsExtensions.selectBlackFormatterPrompt,
65+
Common.bannerLabelYes,
66+
Common.doNotShowAgain,
67+
);
68+
if (selection === Common.bannerLabelYes) {
69+
selection = 'Black';
70+
}
71+
} else if (autopep8) {
72+
selection = await showInformationMessage(
73+
ToolsExtensions.selectAutopep8FormatterPrompt,
74+
Common.bannerLabelYes,
75+
Common.doNotShowAgain,
76+
);
77+
if (selection === Common.bannerLabelYes) {
78+
selection = 'Autopep8';
79+
}
80+
}
81+
} else if (formatter === 'black' && !black) {
82+
this.shownThisSession = true;
83+
selection = await showInformationMessage(
84+
ToolsExtensions.installBlackFormatterPrompt,
85+
'Black',
86+
'Autopep8',
87+
Common.doNotShowAgain,
88+
);
89+
} else if (formatter === 'autopep8' && !autopep8) {
90+
this.shownThisSession = true;
91+
selection = await showInformationMessage(
92+
ToolsExtensions.installAutopep8FormatterPrompt,
93+
'Black',
94+
'Autopep8',
95+
Common.doNotShowAgain,
96+
);
97+
}
98+
99+
if (selection === 'Black') {
100+
if (black) {
101+
await updateDefaultFormatter(BLACK_EXTENSION, resource);
102+
} else {
103+
await installFormatterExtension(BLACK_EXTENSION, resource);
104+
}
105+
} else if (selection === 'Autopep8') {
106+
if (autopep8) {
107+
await updateDefaultFormatter(AUTOPEP8_EXTENSION, resource);
108+
} else {
109+
await installFormatterExtension(AUTOPEP8_EXTENSION, resource);
110+
}
111+
} else if (selection === Common.doNotShowAgain) {
112+
await promptState.updateValue(true);
113+
}
114+
}
115+
}
116+
117+
export function registerInstallFormatterPrompt(serviceContainer: IServiceContainer): void {
118+
const disposables = serviceContainer.get<IDisposableRegistry>(IDisposableRegistry);
119+
const installFormatterPrompt = new InstallFormatterPrompt(serviceContainer);
120+
disposables.push(
121+
onDidSaveTextDocument(async (e) => {
122+
const editorConfig = getConfiguration('editor', { uri: e.uri, languageId: 'python' });
123+
if (e.languageId === 'python' && editorConfig.get<boolean>('formatOnSave')) {
124+
await installFormatterPrompt.showInstallFormatterPrompt(e.uri);
125+
}
126+
}),
127+
);
128+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { ConfigurationTarget, Uri } from 'vscode';
5+
import { ShowFormatterExtensionPrompt } from '../../common/experiments/groups';
6+
import { IExperimentService, IPersistentState, IPersistentStateFactory } from '../../common/types';
7+
import { executeCommand } from '../../common/vscodeApis/commandApis';
8+
import { isInsider } from '../../common/vscodeApis/extensionsApi';
9+
import { getConfiguration, getWorkspaceFolder } from '../../common/vscodeApis/workspaceApis';
10+
import { IServiceContainer } from '../../ioc/types';
11+
12+
export function inFormatterExtensionExperiment(serviceContainer: IServiceContainer): boolean {
13+
const experiment = serviceContainer.get<IExperimentService>(IExperimentService);
14+
return experiment.inExperimentSync(ShowFormatterExtensionPrompt.experiment);
15+
}
16+
17+
export function doNotShowPromptState(key: string, serviceContainer: IServiceContainer): IPersistentState<boolean> {
18+
const persistFactory = serviceContainer.get<IPersistentStateFactory>(IPersistentStateFactory);
19+
const promptState = persistFactory.createWorkspacePersistentState<boolean>(key, false);
20+
return promptState;
21+
}
22+
23+
export async function updateDefaultFormatter(extensionId: string, resource?: Uri): Promise<void> {
24+
const scope = getWorkspaceFolder(resource) ? ConfigurationTarget.Workspace : ConfigurationTarget.Global;
25+
26+
const config = getConfiguration('python', resource);
27+
const editorConfig = getConfiguration('editor', { uri: resource, languageId: 'python' });
28+
await editorConfig.update('defaultFormatter', extensionId, scope, true);
29+
await config.update('formatting.provider', 'none', scope);
30+
}
31+
32+
export async function installFormatterExtension(extensionId: string, resource?: Uri): Promise<void> {
33+
await executeCommand('workbench.extensions.installExtension', extensionId, {
34+
installPreReleaseVersion: isInsider(),
35+
});
36+
37+
await updateDefaultFormatter(extensionId, resource);
38+
}

src/client/providers/prompts/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
export const BLACK_EXTENSION = 'ms-python.black-formatter';
5+
export const AUTOPEP8_EXTENSION = 'ms-python.autopep8';
6+
7+
export interface IInstallFormatterPrompt {
8+
showInstallFormatterPrompt(): Promise<void>;
9+
}

0 commit comments

Comments
 (0)