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
40 changes: 28 additions & 12 deletions src/vs/workbench/contrib/extensions/browser/extensionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2244,18 +2244,21 @@ export abstract class AbstractConfigureRecommendedExtensionsAction extends Actio
super(id, label);
}

protected openExtensionsFile(extensionsFileResource: URI): Promise<any> {
return this.getOrCreateExtensionsFile(extensionsFileResource)
.then(({ created, content }) =>
this.getSelectionPosition(content, extensionsFileResource, ['recommendations'])
.then(selection => this.editorService.openEditor({
resource: extensionsFileResource,
options: {
pinned: created,
selection
}
})),
error => Promise.reject(new Error(localize('OpenExtensionsFile.failed', "Unable to create 'extensions.json' file inside the '.vscode' folder ({0}).", error))));
protected async openExtensionsFile(extensionsFileResource: URI): Promise<any> {
try {
const targetResource = await this.resolveExtensionsFileResource(extensionsFileResource);
const { created, content } = await this.getOrCreateExtensionsFile(targetResource);
const selection = await this.getSelectionPosition(content, targetResource, ['recommendations']);
return this.editorService.openEditor({
resource: targetResource,
options: {
pinned: created,
selection
}
});
} catch (error) {
return Promise.reject(new Error(localize('OpenExtensionsFile.failed', "Unable to create 'extensions.json' file inside the '.vscode' folder ({0}).", error)));
}
}

protected openWorkspaceConfigurationFile(workspaceConfigurationFile: URI): Promise<any> {
Expand Down Expand Up @@ -2313,6 +2316,19 @@ export abstract class AbstractConfigureRecommendedExtensionsAction extends Actio
});
});
}

private async resolveExtensionsFileResource(resource: URI): Promise<URI> {
if (await this.fileService.exists(resource)) {
return resource;
}
if (resource.path.endsWith('.json')) {
const jsoncResource = resource.with({ path: `${resource.path.slice(0, -'.json'.length)}.jsonc` });
if (await this.fileService.exists(jsoncResource)) {
return jsoncResource;
}
}
return resource;
}
}

export class ConfigureWorkspaceRecommendedExtensionsAction extends AbstractConfigureRecommendedExtensionsAction {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,16 +318,16 @@ suite('ExtensionRecommendationsService Test', () => {
});
});

function setUpFolderWorkspace(folderName: string, recommendedExtensions: string[], ignoredRecommendations: string[] = []): Promise<void> {
return setUpFolder(folderName, recommendedExtensions, ignoredRecommendations);
function setUpFolderWorkspace(folderName: string, recommendedExtensions: string[], ignoredRecommendations: string[] = [], fileName: 'extensions.json' | 'extensions.jsonc' = 'extensions.json'): Promise<void> {
return setUpFolder(folderName, recommendedExtensions, ignoredRecommendations, fileName);
}

async function setUpFolder(folderName: string, recommendedExtensions: string[], ignoredRecommendations: string[] = []): Promise<void> {
async function setUpFolder(folderName: string, recommendedExtensions: string[], ignoredRecommendations: string[] = [], fileName: 'extensions.json' | 'extensions.jsonc' = 'extensions.json'): Promise<void> {
const fileService = instantiationService.get(IFileService);
const folderDir = joinPath(ROOT, folderName);
const workspaceSettingsDir = joinPath(folderDir, '.vscode');
await fileService.createFolder(workspaceSettingsDir);
const configPath = joinPath(workspaceSettingsDir, 'extensions.json');
const configPath = joinPath(workspaceSettingsDir, fileName);
await fileService.writeFile(configPath, VSBuffer.fromString(JSON.stringify({
'recommendations': recommendedExtensions,
'unwantedRecommendations': ignoredRecommendations,
Expand Down Expand Up @@ -382,6 +382,34 @@ suite('ExtensionRecommendationsService Test', () => {
return testNoPromptForValidRecommendations([]);
}));

test('ExtensionRecommendationsService: Workspace recommendations read from extensions.jsonc', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
await setUpFolderWorkspace('myFolderJsonc', mockTestData.validRecommendedExtensions, [], 'extensions.jsonc');
testObject = disposableStore.add(instantiationService.createInstance(ExtensionRecommendationsService));
await testObject.activationPromise;
const recommendations = Object.keys(testObject.getAllRecommendationsWithReason());
assert.strictEqual(recommendations.length, mockTestData.validRecommendedExtensions.length);
mockTestData.validRecommendedExtensions.forEach(extensionId => {
assert.ok(recommendations.includes(extensionId.toLowerCase()));
});
}));

test('ExtensionRecommendationsService: extensions.json preferred over extensions.jsonc', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
await setUpFolderWorkspace('myFolderJsonPreferred', ['fromjsonc'], [], 'extensions.jsonc');
const fileService = instantiationService.get(IFileService);
const folderDir = joinPath(ROOT, 'myFolderJsonPreferred');
const workspaceSettingsDir = joinPath(folderDir, '.vscode');
await fileService.writeFile(joinPath(workspaceSettingsDir, 'extensions.json'), VSBuffer.fromString(JSON.stringify({
'recommendations': ['fromjson'],
'unwantedRecommendations': []
}, null, '\t')));

testObject = disposableStore.add(instantiationService.createInstance(ExtensionRecommendationsService));
await testObject.activationPromise;
const recommendations = Object.keys(testObject.getAllRecommendationsWithReason());
assert.ok(recommendations.includes('fromjson'));
assert.ok(!recommendations.includes('fromjsonc'));
}));

test('ExtensionRecommendationsService: Prompt for valid workspace recommendations', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
await setUpFolderWorkspace('myFolder', mockTestData.recommendedExtensions);
testObject = disposableStore.add(instantiationService.createInstance(ExtensionRecommendationsService));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, dispose, IDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
import { isEqual } from '../../../../base/common/resources.js';
import * as nls from '../../../../nls.js';
import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
import { workbenchConfigurationNodeBase } from '../../../common/configuration.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { EditorInputWithOptions } from '../../../common/editor.js';
import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js';
import { RegisteredEditorPriority, IEditorResolverService } from '../../../services/editor/common/editorResolverService.js';
import { ITextEditorService } from '../../../services/textfile/common/textEditorService.js';
import { DEFAULT_SETTINGS_EDITOR_SETTING, FOLDER_SETTINGS_PATH, IPreferencesService, USE_SPLIT_JSON_SETTING } from '../../../services/preferences/common/preferences.js';
import { DEFAULT_SETTINGS_EDITOR_SETTING, FOLDER_SETTINGS_PATH_CANDIDATES, IPreferencesService, USE_SPLIT_JSON_SETTING } from '../../../services/preferences/common/preferences.js';
import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js';
import { URI } from '../../../../base/common/uri.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { SettingsFileSystemProvider } from './settingsFilesystemProvider.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
Expand All @@ -26,7 +27,7 @@ export class PreferencesContribution extends Disposable implements IWorkbenchCon

static readonly ID = 'workbench.contrib.preferences';

private editorOpeningListener: IDisposable | undefined;
private editorOpeningListener: DisposableStore | undefined;

constructor(
@IFileService fileService: IFileService,
Expand All @@ -53,54 +54,62 @@ export class PreferencesContribution extends Disposable implements IWorkbenchCon
private handleSettingsEditorRegistration(): void {

// dispose any old listener we had
dispose(this.editorOpeningListener);
this.editorOpeningListener?.dispose();

// install editor opening listener unless user has disabled this
if (!!this.configurationService.getValue(USE_SPLIT_JSON_SETTING) || !!this.configurationService.getValue(DEFAULT_SETTINGS_EDITOR_SETTING)) {
this.editorOpeningListener = this.editorResolverService.registerEditor(
'**/settings.json',
{
id: SideBySideEditorInput.ID,
label: nls.localize('splitSettingsEditorLabel', "Split Settings Editor"),
priority: RegisteredEditorPriority.builtin,
},
{},
{
createEditorInput: ({ resource, options }): EditorInputWithOptions => {
// Global User Settings File
if (isEqual(resource, this.userDataProfileService.currentProfile.settingsResource)) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.USER_LOCAL, resource), options };
}
const createEditorInput = (editorInput: any): EditorInputWithOptions => {
const { resource, options } = editorInput;
// Global User Settings File
if (isEqual(resource, this.userDataProfileService.currentProfile.settingsResource)) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.USER_LOCAL, resource), options };
}

// Single Folder Workspace Settings File
const state = this.workspaceService.getWorkbenchState();
if (state === WorkbenchState.FOLDER) {
const folders = this.workspaceService.getWorkspace().folders;
if (isEqual(resource, folders[0].toResource(FOLDER_SETTINGS_PATH))) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.WORKSPACE, resource), options };
}
}
// Single Folder Workspace Settings File
const state = this.workspaceService.getWorkbenchState();
if (state === WorkbenchState.FOLDER) {
const folders = this.workspaceService.getWorkspace().folders;
if (this.matchesFolderSettingsResource(folders[0], resource)) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.WORKSPACE, resource), options };
}
}

// Multi Folder Workspace Settings File
else if (state === WorkbenchState.WORKSPACE) {
const folders = this.workspaceService.getWorkspace().folders;
for (const folder of folders) {
if (isEqual(resource, folder.toResource(FOLDER_SETTINGS_PATH))) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.WORKSPACE_FOLDER, resource), options };
}
}
// Multi Folder Workspace Settings File
else if (state === WorkbenchState.WORKSPACE) {
const folders = this.workspaceService.getWorkspace().folders;
for (const folder of folders) {
if (this.matchesFolderSettingsResource(folder, resource)) {
return { editor: this.preferencesService.createSplitJsonEditorInput(ConfigurationTarget.WORKSPACE_FOLDER, resource), options };
}

return { editor: this.textEditorService.createTextEditor({ resource }), options };
}
}
);

return { editor: this.textEditorService.createTextEditor({ resource }), options };
};

const descriptor = {
id: SideBySideEditorInput.ID,
label: nls.localize('splitSettingsEditorLabel', "Split Settings Editor"),
priority: RegisteredEditorPriority.builtin,
};
const disposables = ['**/settings.json', '**/settings.jsonc'].map(pattern => this.editorResolverService.registerEditor(
pattern,
descriptor,
{},
{ createEditorInput }
));
this.editorOpeningListener = new DisposableStore();
disposables.forEach(d => this.editorOpeningListener!.add(d));
}
}
override dispose(): void {
dispose(this.editorOpeningListener);
this.editorOpeningListener?.dispose();
super.dispose();
}

private matchesFolderSettingsResource(folder: IWorkspaceFolder, resource: URI): boolean {
return FOLDER_SETTINGS_PATH_CANDIDATES.some(path => isEqual(resource, folder.toResource(path)));
}
}


Expand Down
80 changes: 59 additions & 21 deletions src/vs/workbench/services/configuration/browser/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { isEmptyObject, isObject } from '../../../../base/common/types.js';
import { DefaultConfiguration as BaseDefaultConfiguration } from '../../../../platform/configuration/common/configurations.js';
import { IJSONEditingService } from '../common/jsonEditing.js';
import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js';
import { ResourceSet } from '../../../../base/common/map.js';

export class DefaultConfiguration extends BaseDefaultConfiguration {

Expand Down Expand Up @@ -231,6 +232,7 @@ export class UserConfiguration extends Disposable {
class FileServiceBasedConfiguration extends Disposable {

private readonly allResources: URI[];
private readonly resolvedStandaloneConfigurationResources = new Map<string, URI>();
private _folderSettingsModelParser: ConfigurationModelParser;
private _folderSettingsParseOptions: ConfigurationParseOptions;
private _standAloneConfigurations: ConfigurationModel[];
Expand All @@ -249,7 +251,13 @@ class FileServiceBasedConfiguration extends Disposable {
private readonly logService: ILogService,
) {
super();
this.allResources = [this.settingsResource, ...this.standAloneConfigurationResources.map(([, resource]) => resource)];
const resourceSet = new ResourceSet();
for (const resource of [this.settingsResource, ...this.standAloneConfigurationResources.map(([, resource]) => resource)]) {
for (const candidate of this.getResourceCandidates(resource)) {
resourceSet.add(candidate);
}
}
this.allResources = Array.from(resourceSet);
this._register(combinedDisposable(...this.allResources.map(resource => combinedDisposable(
this.fileService.watch(uriIdentityService.extUri.dirname(resource)),
// Also listen to the resource incase the resource is a symlink - https://github.com/microsoft/vscode/issues/118134
Expand All @@ -269,29 +277,57 @@ class FileServiceBasedConfiguration extends Disposable {
}

async resolveContents(donotResolveSettings?: boolean): Promise<[string | undefined, [string, string | undefined][]]> {
this.resolvedStandaloneConfigurationResources.clear();

const resolveContents = async (resources: URI[]): Promise<(string | undefined)[]> => {
return Promise.all(resources.map(async resource => {
try {
const content = await this.fileService.readFile(resource, { atomic: true });
return content.value.toString();
} catch (error) {
this.logService.trace(`Error while resolving configuration file '${resource.toString()}': ${errors.getErrorMessage(error)}`);
if ((<FileOperationError>error).fileOperationResult !== FileOperationResult.FILE_NOT_FOUND
&& (<FileOperationError>error).fileOperationResult !== FileOperationResult.FILE_NOT_DIRECTORY) {
this.logService.error(error);
}
const settingsResult = await this.resolveConfigurationContent(this.settingsResource, !!donotResolveSettings);
const standAloneConfigurationContents = await Promise.all(this.standAloneConfigurationResources.map(async ([key, resource]) => {
const result = await this.resolveConfigurationContent(resource);
if (result.resource) {
this.resolvedStandaloneConfigurationResources.set(key, result.resource);
} else {
this.resolvedStandaloneConfigurationResources.delete(key);
}
return <[string, string | undefined]>[key, result.content];
}));

return [settingsResult.content, standAloneConfigurationContents];
}

private async resolveConfigurationContent(resource: URI, skip?: boolean): Promise<{ content: string | undefined; resource: URI | undefined }> {
if (skip) {
return { content: undefined, resource: undefined };
}

const candidates = this.getResourceCandidates(resource);
for (const candidate of candidates) {
try {
const content = await this.fileService.readFile(candidate, { atomic: true });
return { content: content.value.toString(), resource: candidate };
} catch (error) {
this.logService.trace(`Error while resolving configuration file '${candidate.toString()}': ${errors.getErrorMessage(error)}`);
if (this.shouldFallbackToAlternative(error)) {
continue;
}
return '{}';
}));
};
this.logService.error(error);
break;
}
}

return { content: '{}', resource: undefined };
}

const [[settingsContent], standAloneConfigurationContents] = await Promise.all([
donotResolveSettings ? Promise.resolve([undefined]) : resolveContents([this.settingsResource]),
resolveContents(this.standAloneConfigurationResources.map(([, resource]) => resource)),
]);
private getResourceCandidates(resource: URI): URI[] {
const candidates = [resource];
const path = resource.path;
if (path.endsWith('.json')) {
candidates.push(resource.with({ path: `${path.slice(0, -'.json'.length)}.jsonc` }));
}
return candidates;
}

return [settingsContent, standAloneConfigurationContents.map((content, index) => ([this.standAloneConfigurationResources[index][0], content]))];
private shouldFallbackToAlternative(error: unknown): boolean {
const fileOperationResult = (error as FileOperationError)?.fileOperationResult;
return fileOperationResult === FileOperationResult.FILE_NOT_FOUND || fileOperationResult === FileOperationResult.FILE_NOT_DIRECTORY;
}

async loadConfiguration(settingsConfiguration?: ConfigurationModel): Promise<ConfigurationModel> {
Expand All @@ -309,7 +345,9 @@ class FileServiceBasedConfiguration extends Disposable {
for (let index = 0; index < standAloneConfigurationContents.length; index++) {
const contents = standAloneConfigurationContents[index][1];
if (contents !== undefined) {
const standAloneConfigurationModelParser = new StandaloneConfigurationModelParser(this.standAloneConfigurationResources[index][1].toString(), this.standAloneConfigurationResources[index][0], this.logService);
const [key, resource] = this.standAloneConfigurationResources[index];
const resolvedResource = this.resolvedStandaloneConfigurationResources.get(key) ?? resource;
const standAloneConfigurationModelParser = new StandaloneConfigurationModelParser(resolvedResource.toString(), key, this.logService);
standAloneConfigurationModelParser.parse(contents);
this._standAloneConfigurations.push(standAloneConfigurationModelParser.configurationModel);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { IAnyWorkspaceIdentifier } from '../../../../platform/workspace/common/w
export const FOLDER_CONFIG_FOLDER_NAME = '.vscode';
export const FOLDER_SETTINGS_NAME = 'settings';
export const FOLDER_SETTINGS_PATH = `${FOLDER_CONFIG_FOLDER_NAME}/${FOLDER_SETTINGS_NAME}.json`;
export const FOLDER_SETTINGS_JSONC_PATH = `${FOLDER_CONFIG_FOLDER_NAME}/${FOLDER_SETTINGS_NAME}.jsonc`;
export const FOLDER_SETTINGS_PATH_CANDIDATES = [FOLDER_SETTINGS_PATH, FOLDER_SETTINGS_JSONC_PATH];

export const defaultSettingsSchemaId = 'vscode://schemas/settings/default';
export const userSettingsSchemaId = 'vscode://schemas/settings/user';
Expand Down
Loading