Path: blob/main/src/vs/workbench/services/filesConfiguration/common/filesConfigurationService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { localize } from '../../../../nls.js';6import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';7import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';8import { Event, Emitter } from '../../../../base/common/event.js';9import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';10import { RawContextKey, IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js';11import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';12import { IFilesConfiguration, AutoSaveConfiguration, HotExitConfiguration, FILES_READONLY_INCLUDE_CONFIG, FILES_READONLY_EXCLUDE_CONFIG, IFileStatWithMetadata, IFileService, IBaseFileStat, hasReadonlyCapability, IFilesConfigurationNode } from '../../../../platform/files/common/files.js';13import { equals } from '../../../../base/common/objects.js';14import { URI } from '../../../../base/common/uri.js';15import { isWeb } from '../../../../base/common/platform.js';16import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';17import { ResourceGlobMatcher } from '../../../common/resources.js';18import { GlobalIdleValue } from '../../../../base/common/async.js';19import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';20import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';21import { LRUCache, ResourceMap } from '../../../../base/common/map.js';22import { IMarkdownString } from '../../../../base/common/htmlContent.js';23import { EditorInput } from '../../../common/editor/editorInput.js';24import { EditorResourceAccessor, SaveReason, SideBySideEditor } from '../../../common/editor.js';25import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js';26import { ITextResourceConfigurationService } from '../../../../editor/common/services/textResourceConfiguration.js';27import { IStringDictionary } from '../../../../base/common/collections.js';2829export const AutoSaveAfterShortDelayContext = new RawContextKey<boolean>('autoSaveAfterShortDelayContext', false, true);3031export interface IAutoSaveConfiguration {32autoSave?: 'afterDelay' | 'onFocusChange' | 'onWindowChange';33autoSaveDelay?: number;34autoSaveWorkspaceFilesOnly?: boolean;35autoSaveWhenNoErrors?: boolean;36}3738interface ICachedAutoSaveConfiguration extends IAutoSaveConfiguration {3940// Some extra state that we cache to reduce the amount41// of lookup we have to do since auto save methods42// are being called very often, e.g. when content changes4344isOutOfWorkspace?: boolean;45isShortAutoSaveDelay?: boolean;46}4748export const enum AutoSaveMode {49OFF,50AFTER_SHORT_DELAY,51AFTER_LONG_DELAY,52ON_FOCUS_CHANGE,53ON_WINDOW_CHANGE54}5556export const enum AutoSaveDisabledReason {57SETTINGS = 1,58OUT_OF_WORKSPACE,59ERRORS,60DISABLED61}6263export type IAutoSaveMode = IEnabledAutoSaveMode | IDisabledAutoSaveMode;6465export interface IEnabledAutoSaveMode {66readonly mode: AutoSaveMode.AFTER_SHORT_DELAY | AutoSaveMode.AFTER_LONG_DELAY | AutoSaveMode.ON_FOCUS_CHANGE | AutoSaveMode.ON_WINDOW_CHANGE;67}6869export interface IDisabledAutoSaveMode {70readonly mode: AutoSaveMode.OFF;71readonly reason: AutoSaveDisabledReason;72}7374export const IFilesConfigurationService = createDecorator<IFilesConfigurationService>('filesConfigurationService');7576export interface IFilesConfigurationService {7778readonly _serviceBrand: undefined;7980//#region Auto Save8182readonly onDidChangeAutoSaveConfiguration: Event<void>;8384readonly onDidChangeAutoSaveDisabled: Event<URI>;8586getAutoSaveConfiguration(resourceOrEditor: EditorInput | URI | undefined): IAutoSaveConfiguration;8788hasShortAutoSaveDelay(resourceOrEditor: EditorInput | URI | undefined): boolean;8990getAutoSaveMode(resourceOrEditor: EditorInput | URI | undefined, saveReason?: SaveReason): IAutoSaveMode;9192toggleAutoSave(): Promise<void>;9394enableAutoSaveAfterShortDelay(resourceOrEditor: EditorInput | URI): IDisposable;95disableAutoSave(resourceOrEditor: EditorInput | URI): IDisposable;9697//#endregion9899//#region Configured Readonly100101readonly onDidChangeReadonly: Event<void>;102103isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString;104105updateReadonly(resource: URI, readonly: true | false | 'toggle' | 'reset'): Promise<void>;106107//#endregion108109readonly onDidChangeFilesAssociation: Event<void>;110111readonly isHotExitEnabled: boolean;112113readonly hotExitConfiguration: string | undefined;114115preventSaveConflicts(resource: URI, language?: string): boolean;116}117118export class FilesConfigurationService extends Disposable implements IFilesConfigurationService {119120declare readonly _serviceBrand: undefined;121122private static readonly DEFAULT_AUTO_SAVE_MODE = isWeb ? AutoSaveConfiguration.AFTER_DELAY : AutoSaveConfiguration.OFF;123private static readonly DEFAULT_AUTO_SAVE_DELAY = 1000;124125private static readonly READONLY_MESSAGES = {126providerReadonly: { value: localize('providerReadonly', "Editor is read-only because the file system of the file is read-only."), isTrusted: true },127sessionReadonly: { value: localize({ key: 'sessionReadonly', comment: ['Please do not translate the word "command", it is part of our internal syntax which must not change', '{Locked="](command:{0})"}'] }, "Editor is read-only because the file was set read-only in this session. [Click here](command:{0}) to set writeable.", 'workbench.action.files.setActiveEditorWriteableInSession'), isTrusted: true },128configuredReadonly: { value: localize({ key: 'configuredReadonly', comment: ['Please do not translate the word "command", it is part of our internal syntax which must not change', '{Locked="](command:{0})"}'] }, "Editor is read-only because the file was set read-only via settings. [Click here](command:{0}) to configure or [toggle for this session](command:{1}).", `workbench.action.openSettings?${encodeURIComponent('["files.readonly"]')}`, 'workbench.action.files.toggleActiveEditorReadonlyInSession'), isTrusted: true },129fileLocked: { value: localize({ key: 'fileLocked', comment: ['Please do not translate the word "command", it is part of our internal syntax which must not change', '{Locked="](command:{0})"}'] }, "Editor is read-only because of file permissions. [Click here](command:{0}) to set writeable anyway.", 'workbench.action.files.setActiveEditorWriteableInSession'), isTrusted: true },130fileReadonly: { value: localize('fileReadonly', "Editor is read-only because the file is read-only."), isTrusted: true }131};132133private readonly _onDidChangeAutoSaveConfiguration = this._register(new Emitter<void>());134readonly onDidChangeAutoSaveConfiguration = this._onDidChangeAutoSaveConfiguration.event;135136private readonly _onDidChangeAutoSaveDisabled = this._register(new Emitter<URI>());137readonly onDidChangeAutoSaveDisabled = this._onDidChangeAutoSaveDisabled.event;138139private readonly _onDidChangeFilesAssociation = this._register(new Emitter<void>());140readonly onDidChangeFilesAssociation = this._onDidChangeFilesAssociation.event;141142private readonly _onDidChangeReadonly = this._register(new Emitter<void>());143readonly onDidChangeReadonly = this._onDidChangeReadonly.event;144145private currentGlobalAutoSaveConfiguration: IAutoSaveConfiguration;146private currentFilesAssociationConfiguration: IStringDictionary<string> | undefined;147private currentHotExitConfiguration: string;148149private readonly autoSaveConfigurationCache = new LRUCache<URI, ICachedAutoSaveConfiguration>(1000);150151private readonly autoSaveAfterShortDelayOverrides = new ResourceMap<number /* counter */>();152private readonly autoSaveDisabledOverrides = new ResourceMap<number /* counter */>();153154private readonly autoSaveAfterShortDelayContext: IContextKey<boolean>;155156private readonly readonlyIncludeMatcher = this._register(new GlobalIdleValue(() => this.createReadonlyMatcher(FILES_READONLY_INCLUDE_CONFIG)));157private readonly readonlyExcludeMatcher = this._register(new GlobalIdleValue(() => this.createReadonlyMatcher(FILES_READONLY_EXCLUDE_CONFIG)));158private configuredReadonlyFromPermissions: boolean | undefined;159160private readonly sessionReadonlyOverrides = new ResourceMap<boolean>(resource => this.uriIdentityService.extUri.getComparisonKey(resource));161162constructor(163@IContextKeyService contextKeyService: IContextKeyService,164@IConfigurationService private readonly configurationService: IConfigurationService,165@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,166@IEnvironmentService private readonly environmentService: IEnvironmentService,167@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,168@IFileService private readonly fileService: IFileService,169@IMarkerService private readonly markerService: IMarkerService,170@ITextResourceConfigurationService private readonly textResourceConfigurationService: ITextResourceConfigurationService171) {172super();173174this.autoSaveAfterShortDelayContext = AutoSaveAfterShortDelayContext.bindTo(contextKeyService);175176const configuration = configurationService.getValue<IFilesConfiguration>();177178this.currentGlobalAutoSaveConfiguration = this.computeAutoSaveConfiguration(undefined, configuration.files);179this.currentFilesAssociationConfiguration = configuration?.files?.associations;180this.currentHotExitConfiguration = configuration?.files?.hotExit || HotExitConfiguration.ON_EXIT;181182this.onFilesConfigurationChange(configuration, false);183184this.registerListeners();185}186187private createReadonlyMatcher(config: string) {188const matcher = this._register(new ResourceGlobMatcher(189resource => this.configurationService.getValue(config, { resource }),190event => event.affectsConfiguration(config),191this.contextService,192this.configurationService193));194195this._register(matcher.onExpressionChange(() => this._onDidChangeReadonly.fire()));196197return matcher;198}199200isReadonly(resource: URI, stat?: IBaseFileStat): boolean | IMarkdownString {201202// if the entire file system provider is readonly, we respect that203// and do not allow to change readonly. we take this as a hint that204// the provider has no capabilities of writing.205const provider = this.fileService.getProvider(resource.scheme);206if (provider && hasReadonlyCapability(provider)) {207return provider.readOnlyMessage ?? FilesConfigurationService.READONLY_MESSAGES.providerReadonly;208}209210// session override always wins over the others211const sessionReadonlyOverride = this.sessionReadonlyOverrides.get(resource);212if (typeof sessionReadonlyOverride === 'boolean') {213return sessionReadonlyOverride === true ? FilesConfigurationService.READONLY_MESSAGES.sessionReadonly : false;214}215216if (217this.uriIdentityService.extUri.isEqualOrParent(resource, this.environmentService.userRoamingDataHome) ||218this.uriIdentityService.extUri.isEqual(resource, this.contextService.getWorkspace().configuration ?? undefined)219) {220return false; // explicitly exclude some paths from readonly that we need for configuration221}222223// configured glob patterns win over stat information224if (this.readonlyIncludeMatcher.value.matches(resource)) {225return !this.readonlyExcludeMatcher.value.matches(resource) ? FilesConfigurationService.READONLY_MESSAGES.configuredReadonly : false;226}227228// check if file is locked and configured to treat as readonly229if (this.configuredReadonlyFromPermissions && stat?.locked) {230return FilesConfigurationService.READONLY_MESSAGES.fileLocked;231}232233// check if file is marked readonly from the file system provider234if (stat?.readonly) {235return FilesConfigurationService.READONLY_MESSAGES.fileReadonly;236}237238return false;239}240241async updateReadonly(resource: URI, readonly: true | false | 'toggle' | 'reset'): Promise<void> {242if (readonly === 'toggle') {243let stat: IFileStatWithMetadata | undefined = undefined;244try {245stat = await this.fileService.resolve(resource, { resolveMetadata: true });246} catch (error) {247// ignore248}249250readonly = !this.isReadonly(resource, stat);251}252253if (readonly === 'reset') {254this.sessionReadonlyOverrides.delete(resource);255} else {256this.sessionReadonlyOverrides.set(resource, readonly);257}258259this._onDidChangeReadonly.fire();260}261262private registerListeners(): void {263264// Files configuration changes265this._register(this.configurationService.onDidChangeConfiguration(e => {266if (e.affectsConfiguration('files')) {267this.onFilesConfigurationChange(this.configurationService.getValue<IFilesConfiguration>(), true);268}269}));270}271272protected onFilesConfigurationChange(configuration: IFilesConfiguration, fromEvent: boolean): void {273274// Auto Save275this.currentGlobalAutoSaveConfiguration = this.computeAutoSaveConfiguration(undefined, configuration.files);276this.autoSaveConfigurationCache.clear();277this.autoSaveAfterShortDelayContext.set(this.getAutoSaveMode(undefined).mode === AutoSaveMode.AFTER_SHORT_DELAY);278if (fromEvent) {279this._onDidChangeAutoSaveConfiguration.fire();280}281282// Check for change in files associations283const filesAssociation = configuration?.files?.associations;284if (!equals(this.currentFilesAssociationConfiguration, filesAssociation)) {285this.currentFilesAssociationConfiguration = filesAssociation;286if (fromEvent) {287this._onDidChangeFilesAssociation.fire();288}289}290291// Hot exit292const hotExitMode = configuration?.files?.hotExit;293if (hotExitMode === HotExitConfiguration.OFF || hotExitMode === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE) {294this.currentHotExitConfiguration = hotExitMode;295} else {296this.currentHotExitConfiguration = HotExitConfiguration.ON_EXIT;297}298299// Readonly300const readonlyFromPermissions = Boolean(configuration?.files?.readonlyFromPermissions);301if (readonlyFromPermissions !== Boolean(this.configuredReadonlyFromPermissions)) {302this.configuredReadonlyFromPermissions = readonlyFromPermissions;303if (fromEvent) {304this._onDidChangeReadonly.fire();305}306}307}308309getAutoSaveConfiguration(resourceOrEditor: EditorInput | URI | undefined): ICachedAutoSaveConfiguration {310const resource = this.toResource(resourceOrEditor);311if (resource) {312let resourceAutoSaveConfiguration = this.autoSaveConfigurationCache.get(resource);313if (!resourceAutoSaveConfiguration) {314resourceAutoSaveConfiguration = this.computeAutoSaveConfiguration(resource, this.textResourceConfigurationService.getValue<IFilesConfigurationNode>(resource, 'files'));315this.autoSaveConfigurationCache.set(resource, resourceAutoSaveConfiguration);316}317318return resourceAutoSaveConfiguration;319}320321return this.currentGlobalAutoSaveConfiguration;322}323324private computeAutoSaveConfiguration(resource: URI | undefined, filesConfiguration: IFilesConfigurationNode | undefined): ICachedAutoSaveConfiguration {325let autoSave: 'afterDelay' | 'onFocusChange' | 'onWindowChange' | undefined;326let autoSaveDelay: number | undefined;327let autoSaveWorkspaceFilesOnly: boolean | undefined;328let autoSaveWhenNoErrors: boolean | undefined;329330let isOutOfWorkspace: boolean | undefined;331let isShortAutoSaveDelay: boolean | undefined;332333switch (filesConfiguration?.autoSave ?? FilesConfigurationService.DEFAULT_AUTO_SAVE_MODE) {334case AutoSaveConfiguration.AFTER_DELAY: {335autoSave = 'afterDelay';336autoSaveDelay = typeof filesConfiguration?.autoSaveDelay === 'number' && filesConfiguration.autoSaveDelay >= 0 ? filesConfiguration.autoSaveDelay : FilesConfigurationService.DEFAULT_AUTO_SAVE_DELAY;337isShortAutoSaveDelay = autoSaveDelay <= FilesConfigurationService.DEFAULT_AUTO_SAVE_DELAY;338break;339}340341case AutoSaveConfiguration.ON_FOCUS_CHANGE:342autoSave = 'onFocusChange';343break;344345case AutoSaveConfiguration.ON_WINDOW_CHANGE:346autoSave = 'onWindowChange';347break;348}349350if (filesConfiguration?.autoSaveWorkspaceFilesOnly === true) {351autoSaveWorkspaceFilesOnly = true;352353if (resource && !this.contextService.isInsideWorkspace(resource)) {354isOutOfWorkspace = true;355isShortAutoSaveDelay = undefined; // out of workspace file are not auto saved with this configuration356}357}358359if (filesConfiguration?.autoSaveWhenNoErrors === true) {360autoSaveWhenNoErrors = true;361isShortAutoSaveDelay = undefined; // this configuration disables short auto save delay362}363364return {365autoSave,366autoSaveDelay,367autoSaveWorkspaceFilesOnly,368autoSaveWhenNoErrors,369isOutOfWorkspace,370isShortAutoSaveDelay371};372}373374private toResource(resourceOrEditor: EditorInput | URI | undefined): URI | undefined {375if (resourceOrEditor instanceof EditorInput) {376return EditorResourceAccessor.getOriginalUri(resourceOrEditor, { supportSideBySide: SideBySideEditor.PRIMARY });377}378379return resourceOrEditor;380}381382hasShortAutoSaveDelay(resourceOrEditor: EditorInput | URI | undefined): boolean {383const resource = this.toResource(resourceOrEditor);384385if (resource && this.autoSaveAfterShortDelayOverrides.has(resource)) {386return true; // overridden to be enabled after short delay387}388389if (this.getAutoSaveConfiguration(resource).isShortAutoSaveDelay) {390return !resource || !this.autoSaveDisabledOverrides.has(resource);391}392393return false;394}395396getAutoSaveMode(resourceOrEditor: EditorInput | URI | undefined, saveReason?: SaveReason): IAutoSaveMode {397const resource = this.toResource(resourceOrEditor);398if (resource && this.autoSaveAfterShortDelayOverrides.has(resource)) {399return { mode: AutoSaveMode.AFTER_SHORT_DELAY }; // overridden to be enabled after short delay400}401402if (resource && this.autoSaveDisabledOverrides.has(resource)) {403return { mode: AutoSaveMode.OFF, reason: AutoSaveDisabledReason.DISABLED };404}405406const autoSaveConfiguration = this.getAutoSaveConfiguration(resource);407if (typeof autoSaveConfiguration.autoSave === 'undefined') {408return { mode: AutoSaveMode.OFF, reason: AutoSaveDisabledReason.SETTINGS };409}410411if (typeof saveReason === 'number') {412if (413(autoSaveConfiguration.autoSave === 'afterDelay' && saveReason !== SaveReason.AUTO) ||414(autoSaveConfiguration.autoSave === 'onFocusChange' && saveReason !== SaveReason.FOCUS_CHANGE && saveReason !== SaveReason.WINDOW_CHANGE) ||415(autoSaveConfiguration.autoSave === 'onWindowChange' && saveReason !== SaveReason.WINDOW_CHANGE)416) {417return { mode: AutoSaveMode.OFF, reason: AutoSaveDisabledReason.SETTINGS };418}419}420421if (resource) {422if (autoSaveConfiguration.autoSaveWorkspaceFilesOnly && autoSaveConfiguration.isOutOfWorkspace) {423return { mode: AutoSaveMode.OFF, reason: AutoSaveDisabledReason.OUT_OF_WORKSPACE };424}425426if (autoSaveConfiguration.autoSaveWhenNoErrors && this.markerService.read({ resource, take: 1, severities: MarkerSeverity.Error }).length > 0) {427return { mode: AutoSaveMode.OFF, reason: AutoSaveDisabledReason.ERRORS };428}429}430431switch (autoSaveConfiguration.autoSave) {432case 'afterDelay':433if (typeof autoSaveConfiguration.autoSaveDelay === 'number' && autoSaveConfiguration.autoSaveDelay <= FilesConfigurationService.DEFAULT_AUTO_SAVE_DELAY) {434// Explicitly mark auto save configurations as long running435// if they are configured to not run when there are errors.436// The rationale here is that errors may come in after auto437// save has been scheduled and then further delay the auto438// save until resolved.439return { mode: autoSaveConfiguration.autoSaveWhenNoErrors ? AutoSaveMode.AFTER_LONG_DELAY : AutoSaveMode.AFTER_SHORT_DELAY };440}441return { mode: AutoSaveMode.AFTER_LONG_DELAY };442case 'onFocusChange':443return { mode: AutoSaveMode.ON_FOCUS_CHANGE };444case 'onWindowChange':445return { mode: AutoSaveMode.ON_WINDOW_CHANGE };446}447}448449async toggleAutoSave(): Promise<void> {450const currentSetting = this.configurationService.getValue('files.autoSave');451452let newAutoSaveValue: string;453if ([AutoSaveConfiguration.AFTER_DELAY, AutoSaveConfiguration.ON_FOCUS_CHANGE, AutoSaveConfiguration.ON_WINDOW_CHANGE].some(setting => setting === currentSetting)) {454newAutoSaveValue = AutoSaveConfiguration.OFF;455} else {456newAutoSaveValue = AutoSaveConfiguration.AFTER_DELAY;457}458459return this.configurationService.updateValue('files.autoSave', newAutoSaveValue);460}461462enableAutoSaveAfterShortDelay(resourceOrEditor: EditorInput | URI): IDisposable {463const resource = this.toResource(resourceOrEditor);464if (!resource) {465return Disposable.None;466}467468const counter = this.autoSaveAfterShortDelayOverrides.get(resource) ?? 0;469this.autoSaveAfterShortDelayOverrides.set(resource, counter + 1);470471return toDisposable(() => {472const counter = this.autoSaveAfterShortDelayOverrides.get(resource) ?? 0;473if (counter <= 1) {474this.autoSaveAfterShortDelayOverrides.delete(resource);475} else {476this.autoSaveAfterShortDelayOverrides.set(resource, counter - 1);477}478});479}480481disableAutoSave(resourceOrEditor: EditorInput | URI): IDisposable {482const resource = this.toResource(resourceOrEditor);483if (!resource) {484return Disposable.None;485}486487const counter = this.autoSaveDisabledOverrides.get(resource) ?? 0;488this.autoSaveDisabledOverrides.set(resource, counter + 1);489490if (counter === 0) {491this._onDidChangeAutoSaveDisabled.fire(resource);492}493494return toDisposable(() => {495const counter = this.autoSaveDisabledOverrides.get(resource) ?? 0;496if (counter <= 1) {497this.autoSaveDisabledOverrides.delete(resource);498this._onDidChangeAutoSaveDisabled.fire(resource);499} else {500this.autoSaveDisabledOverrides.set(resource, counter - 1);501}502});503}504505get isHotExitEnabled(): boolean {506if (this.contextService.getWorkspace().transient) {507// Transient workspace: hot exit is disabled because508// transient workspaces are not restored upon restart509return false;510}511512return this.currentHotExitConfiguration !== HotExitConfiguration.OFF;513}514515get hotExitConfiguration(): string {516return this.currentHotExitConfiguration;517}518519preventSaveConflicts(resource: URI, language?: string): boolean {520return this.configurationService.getValue('files.saveConflictResolution', { resource, overrideIdentifier: language }) !== 'overwriteFileOnDisk';521}522}523524registerSingleton(IFilesConfigurationService, FilesConfigurationService, InstantiationType.Eager);525526527