diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index 258493a2c8a10..78c8c68c74379 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -50,7 +50,7 @@ import { EncodingRegistry } from './encoding-registry'; import { UTF8 } from '../common/encodings'; import { EnvVariablesServer } from '../common/env-variables'; import { AuthenticationService } from './authentication-service'; -import { FormatType, Saveable } from './saveable'; +import { FormatType, Saveable, SaveOptions } from './saveable'; import { QuickInputService, QuickPick, QuickPickItem } from './quick-input'; import { AsyncLocalizationProvider } from '../common/i18n/localization'; import { nls } from '../common/nls'; @@ -59,6 +59,7 @@ import { ConfirmDialog, confirmExit, Dialog } from './dialogs'; import { WindowService } from './window/window-service'; import { FrontendApplicationConfigProvider } from './frontend-application-config-provider'; import { DecorationStyle } from './decoration-style'; +import { SaveResourceService } from './save-resource-service'; export namespace CommonMenus { @@ -327,7 +328,8 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(MessageService) protected readonly messageService: MessageService, @inject(OpenerService) protected readonly openerService: OpenerService, @inject(AboutDialog) protected readonly aboutDialog: AboutDialog, - @inject(AsyncLocalizationProvider) protected readonly localizationProvider: AsyncLocalizationProvider + @inject(AsyncLocalizationProvider) protected readonly localizationProvider: AsyncLocalizationProvider, + @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService, ) { } @inject(ContextKeyService) @@ -855,10 +857,10 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }); commandRegistry.registerCommand(CommonCommands.SAVE, { - execute: () => this.shell.save({ formatType: FormatType.ON }) + execute: () => this.save({ formatType: FormatType.ON }) }); commandRegistry.registerCommand(CommonCommands.SAVE_WITHOUT_FORMATTING, { - execute: () => this.shell.save({ formatType: FormatType.OFF }) + execute: () => this.save({ formatType: FormatType.OFF }) }); commandRegistry.registerCommand(CommonCommands.SAVE_ALL, { execute: () => this.shell.saveAll({ formatType: FormatType.DIRTY }) @@ -1000,6 +1002,11 @@ export class CommonFrontendContribution implements FrontendApplicationContributi ); } + protected async save(options?: SaveOptions): Promise { + const widget = this.shell.currentWidget; + this.saveResourceService.save(widget, options); + } + protected async openAbout(): Promise { this.aboutDialog.open(); } diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index fb2a485fb0d56..aa34dfb3ab024 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -119,6 +119,7 @@ import { } from './breadcrumbs'; import { RendererHost } from './widgets'; import { TooltipService, TooltipServiceImpl } from './tooltip-service'; +import { SaveResourceService } from './save-resource-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -390,4 +391,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo child.bind(Coordinate).toConstantValue(position); return child.get(BreadcrumbPopupContainer); }); + + bind(SaveResourceService).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts new file mode 100644 index 0000000000000..a4cb6e7fc8d9f --- /dev/null +++ b/packages/core/src/browser/save-resource-service.ts @@ -0,0 +1,44 @@ +/******************************************************************************** + * Copyright (C) 2022 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import { Saveable, SaveOptions, Widget } from '.'; + +@injectable() +export class SaveResourceService { + + /** + * Indicate if the document can be saved ('Save' command should be disable if not). + */ + canSave(saveable: Saveable): boolean { + // By default, we never allow a document to be saved if it is untitled. + return Saveable.isDirty(saveable) && !Saveable.isUntitled(saveable); + } + + /** + * Saves the document. + * + * This function is called only if `canSave` returns true, which means the document is not untitled + * and is thus saveable. + */ + async save(widget: Widget | undefined, options?: SaveOptions): Promise { + const saveable = Saveable.get(widget); + if (saveable && this.canSave(saveable)) { + await saveable.save(options); + } + } + +} diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index cea603bfaa8f2..f3932ee4738ba 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -21,6 +21,7 @@ import { MaybePromise } from '../common/types'; import { Key } from './keyboard/keys'; import { AbstractDialog } from './dialogs'; import { waitForClosed } from './widgets'; +import { URI } from 'vscode-uri'; export interface Saveable { readonly dirty: boolean; @@ -65,6 +66,10 @@ export namespace Saveable { return !!arg && ('dirty' in arg) && ('onDirtyChanged' in arg); } // eslint-disable-next-line @typescript-eslint/no-explicit-any + export function isUntitled(arg: any): boolean { + return !!arg && ('uri' in arg) && URI.parse((arg as { uri: string; }).uri).scheme === 'untitled'; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function get(arg: any): Saveable | undefined { if (is(arg)) { return arg; diff --git a/packages/plugin-ext/src/main/browser/documents-main.ts b/packages/plugin-ext/src/main/browser/documents-main.ts index 807210665a636..6c601aaa3aefc 100644 --- a/packages/plugin-ext/src/main/browser/documents-main.ts +++ b/packages/plugin-ext/src/main/browser/documents-main.ts @@ -30,7 +30,6 @@ import { Range } from '@theia/core/shared/vscode-languageserver-protocol'; import { OpenerService } from '@theia/core/lib/browser/opener-service'; import { Reference } from '@theia/core/lib/common/reference'; import { dispose } from '../../common/disposable-util'; -import { FileResourceResolver } from '@theia/filesystem/lib/browser'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -95,7 +94,6 @@ export class DocumentsMainImpl implements DocumentsMain, Disposable { private openerService: OpenerService, private shell: ApplicationShell, private untitledResourceResolver: UntitledResourceResolver, - private fileResourceResolver: FileResourceResolver ) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.DOCUMENTS_EXT); @@ -181,7 +179,7 @@ export class DocumentsMainImpl implements DocumentsMain, Disposable { async $tryCreateDocument(options?: { language?: string; content?: string; }): Promise { const language = options && options.language; const content = options && options.content; - const resource = await this.untitledResourceResolver.createUntitledResource(this.fileResourceResolver, content, language); + const resource = await this.untitledResourceResolver.createUntitledResource(content, language); return monaco.Uri.parse(resource.uri.toString()); } diff --git a/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts b/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts index 8c2dcb7852fd7..2319252ad4892 100644 --- a/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts +++ b/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts @@ -15,21 +15,16 @@ ********************************************************************************/ import { Emitter, Event } from '@theia/core/lib/common/event'; -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Resource, ResourceResolver, ResourceVersion, ResourceSaveOptions } from '@theia/core/lib/common/resource'; +import { injectable } from '@theia/core/shared/inversify'; +import { Resource, ResourceResolver, ResourceVersion } from '@theia/core/lib/common/resource'; import URI from '@theia/core/lib/common/uri'; import { Schemes } from '../../../common/uri-components'; -import { FileResource, FileResourceResolver } from '@theia/filesystem/lib/browser'; -import { TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol'; let index = 0; @injectable() export class UntitledResourceResolver implements ResourceResolver { - @inject(FileResourceResolver) - protected readonly fileResourceResolver: FileResourceResolver; - protected readonly resources = new Map(); async resolve(uri: URI): Promise { @@ -38,14 +33,14 @@ export class UntitledResourceResolver implements ResourceResolver { } else { const untitledResource = this.resources.get(uri.toString()); if (!untitledResource) { - return this.createUntitledResource(this.fileResourceResolver, '', '', uri); + return this.createUntitledResource('', '', uri); } else { return untitledResource; } } } - async createUntitledResource(fileResourceResolver: FileResourceResolver, content?: string, language?: string, uri?: URI): Promise { + async createUntitledResource(content?: string, language?: string, uri?: URI): Promise { let extension; if (language) { for (const lang of monaco.languages.getLanguages()) { @@ -58,33 +53,26 @@ export class UntitledResourceResolver implements ResourceResolver { } } return new UntitledResource(this.resources, uri ? uri : new URI().withScheme(Schemes.untitled).withPath(`/Untitled-${index++}${extension ? extension : ''}`), - fileResourceResolver, content); + content); } } export class UntitledResource implements Resource { - private fileResource?: FileResource; - protected readonly onDidChangeContentsEmitter = new Emitter(); readonly onDidChangeContents: Event = this.onDidChangeContentsEmitter.event; - constructor(private resources: Map, public uri: URI, private fileResourceResolver: FileResourceResolver, private content?: string) { + constructor(private resources: Map, public uri: URI, private content?: string) { this.resources.set(this.uri.toString(), this); } dispose(): void { this.resources.delete(this.uri.toString()); this.onDidChangeContentsEmitter.dispose(); - if (this.fileResource) { - this.fileResource.dispose(); - } } async readContents(options?: { encoding?: string | undefined; } | undefined): Promise { - if (this.fileResource) { - return this.fileResource.readContents(options); - } else if (this.content) { + if (this.content) { return this.content; } else { return ''; @@ -92,26 +80,9 @@ export class UntitledResource implements Resource { } async saveContents(content: string, options?: { encoding?: string, overwriteEncoding?: boolean }): Promise { - if (!this.fileResource) { - this.fileResource = await this.fileResourceResolver.resolve(new URI(this.uri.path.toString())); - if (this.fileResource.onDidChangeContents) { - this.fileResource.onDidChangeContents(() => this.fireDidChangeContents()); - } - } - await this.fileResource.saveContents(content, options); - } - - async saveContentChanges(changes: TextDocumentContentChangeEvent[], options?: ResourceSaveOptions): Promise { - if (!this.fileResource || !this.fileResource.saveContentChanges) { - throw new Error('FileResource is not available for: ' + this.uri.path.toString()); - } - await this.fileResource.saveContentChanges(changes, options); - } - - async guessEncoding(): Promise { - if (this.fileResource) { - return this.fileResource.guessEncoding(); - } + // This function must exist to ensure readOnly is false for the Monaco editor. + // However it should not be called because saving 'untitled' is always processed as 'Save As'. + throw Error('never'); } protected fireDidChangeContents(): void { @@ -119,16 +90,10 @@ export class UntitledResource implements Resource { } get version(): ResourceVersion | undefined { - if (this.fileResource) { - return this.fileResource.version; - } return undefined; } get encoding(): string | undefined { - if (this.fileResource) { - return this.fileResource.encoding; - } return undefined; } } diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index fcdfb5e033acc..c8131239aed91 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -47,7 +47,6 @@ import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shel import { MonacoBulkEditService } from '@theia/monaco/lib/browser/monaco-bulk-edit-service'; import { MonacoEditorService } from '@theia/monaco/lib/browser/monaco-editor-service'; import { UntitledResourceResolver } from './editor/untitled-resource'; -import { FileResourceResolver } from '@theia/filesystem/lib/browser'; import { MainFileSystemEventService } from './main-file-system-event-service'; import { LabelServiceMainImpl } from './label-service-main'; import { TimelineMainImpl } from './timeline-main'; @@ -86,8 +85,7 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const openerService = container.get(OpenerService); const shell = container.get(ApplicationShell); const untitledResourceResolver = container.get(UntitledResourceResolver); - const fileResourceResolver = container.get(FileResourceResolver); - const documentsMain = new DocumentsMainImpl(editorsAndDocuments, modelService, rpc, editorManager, openerService, shell, untitledResourceResolver, fileResourceResolver); + const documentsMain = new DocumentsMainImpl(editorsAndDocuments, modelService, rpc, editorManager, openerService, shell, untitledResourceResolver); rpc.set(PLUGIN_RPC_CONTEXT.DOCUMENTS_MAIN, documentsMain); const bulkEditService = container.get(MonacoBulkEditService); diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index 4819fcece6ffc..74ad9893070ad 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -19,7 +19,7 @@ import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegist import { isOSX, environment, OS } from '@theia/core'; import { open, OpenerService, CommonMenus, StorageService, LabelProvider, ConfirmDialog, KeybindingRegistry, KeybindingContribution, - CommonCommands, FrontendApplicationContribution, ApplicationShell, Saveable, SaveableSource, Widget, Navigatable, SHELL_TABBAR_CONTEXT_COPY + CommonCommands, FrontendApplicationContribution, ApplicationShell, Saveable, SaveableSource, Widget, Navigatable, SHELL_TABBAR_CONTEXT_COPY, FormatType } from '@theia/core/lib/browser'; import { FileDialogService, OpenFileDialogProps, FileDialogTreeFilters } from '@theia/filesystem/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; @@ -434,7 +434,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi * - `widget.saveable.createSnapshot` is defined. * - `widget.saveable.revert` is defined. */ - protected canBeSavedAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable { + public canBeSavedAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable { return widget !== undefined && Saveable.isSource(widget) && typeof widget.saveable.createSnapshot === 'function' @@ -446,7 +446,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi /** * Save `sourceWidget` to a new file picked by the user. */ - protected async saveAs(sourceWidget: Widget & SaveableSource & Navigatable): Promise { + public async saveAs(sourceWidget: Widget & SaveableSource & Navigatable): Promise { let exist: boolean = false; let overwrite: boolean = false; let selected: URI | undefined; @@ -503,8 +503,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi targetSaveable.applySnapshot(snapshot); await sourceWidget.saveable.revert!(); sourceWidget.close(); - // At this point `targetWidget` should be `applicationShell.currentWidget` for the save command to pick up: - await this.commandRegistry.executeCommand(CommonCommands.SAVE.id); + Saveable.save(targetWidget, { formatType: FormatType.ON }); } else { this.messageService.error(nls.localize('theia/workspace/failApply', 'Could not apply changes to new file')); } diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 9cb94ab93768a..8106334456884 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -48,6 +48,8 @@ import { JsonSchemaContribution } from '@theia/core/lib/browser/json-schema-stor import { WorkspaceSchemaUpdater } from './workspace-schema-updater'; import { WorkspaceBreadcrumbsContribution } from './workspace-breadcrumbs-contribution'; import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; +import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; +import { WorkspaceSaveResourceService } from './workspace-save-resource-service'; export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { bindWorkspacePreferences(bind); @@ -99,4 +101,6 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un bind(WorkspaceSchemaUpdater).toSelf().inSingletonScope(); bind(JsonSchemaContribution).toService(WorkspaceSchemaUpdater); rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope(); + + rebind(SaveResourceService).to(WorkspaceSaveResourceService).inSingletonScope(); }); diff --git a/packages/workspace/src/browser/workspace-save-resource-service.ts b/packages/workspace/src/browser/workspace-save-resource-service.ts new file mode 100644 index 0000000000000..a2b4b491e5d98 --- /dev/null +++ b/packages/workspace/src/browser/workspace-save-resource-service.ts @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (C) 2022 Arm and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, inject } from 'inversify'; +import { WorkspaceFrontendContribution } from './workspace-frontend-contribution'; +import { Saveable, SaveOptions, Widget } from '@theia/core/lib/browser'; +import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; +import { MessageService } from '@theia/core/lib/common'; + +@injectable() +export class WorkspaceSaveResourceService extends SaveResourceService { + + @inject(WorkspaceFrontendContribution) protected readonly workspaceFrontendContribution: WorkspaceFrontendContribution; + + @inject(MessageService) protected readonly messageService: MessageService; + + canSave(saveable: Saveable): boolean { + // In addition to dirty documents, untitled documents can be saved because for these we treat 'Save' as 'Save As'. + return Saveable.isDirty(saveable) || Saveable.isUntitled(saveable); + } + + public async save(widget: Widget | undefined, options?: SaveOptions): Promise { + const saveable = Saveable.get(widget); + if (widget instanceof Widget && this.workspaceFrontendContribution.canBeSavedAs(widget) && saveable) { + if (Saveable.isUntitled(saveable)) { + this.workspaceFrontendContribution.saveAs(widget); + } else { + await saveable.save(options); + } + } else { + // This should not happen because the caller should check this. + this.messageService.error(`Cannot save the current widget "${widget?.title}" .`); + } + + } + +}