From 2ff10468b3dd6965058550b0df44bbeef08ba8d7 Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Wed, 9 Mar 2022 08:34:26 -0700 Subject: [PATCH 1/8] Make untitled resources available in Core --- .../browser/common-frontend-contribution.ts | 41 +++++- .../core/src/browser/decorations-service.ts | 4 +- .../browser/frontend-application-module.ts | 2 + .../core/src/browser/save-resource-service.ts | 31 +++- packages/core/src/browser/saveable.ts | 5 - .../src/browser/shell/application-shell.ts | 16 +- .../untitled-file-location-provider.ts | 55 +++++++ packages/core/src/common/resource.ts | 79 ++++++++++ .../src/browser/editor-preview-widget.ts | 10 +- packages/editor/src/browser/editor-widget.ts | 6 +- .../file-dialog/file-dialog-service.ts | 12 +- .../src/browser/filesystem-frontend-module.ts | 9 +- .../filesystem-save-resource-service.ts | 130 ++++++++++++++++ .../monaco/src/browser/monaco-editor-model.ts | 5 +- .../monaco/src/browser/monaco-languages.ts | 4 + .../src/browser/navigator-contribution.ts | 6 +- .../plugin-vscode-commands-contribution.ts | 6 - .../plugin-ext/src/common/rpc-protocol.ts | 2 +- .../src/main/browser/documents-main.ts | 6 +- .../main/browser/editor/untitled-resource.ts | 100 +------------ .../src/main/browser/main-context.ts | 4 +- .../src/browser/workspace-commands.ts | 12 +- .../workspace-file-dialog-root-provider.ts | 30 ++++ .../workspace-frontend-contribution.ts | 139 ++---------------- .../src/browser/workspace-frontend-module.ts | 13 +- .../workspace-save-resource-service.ts | 50 ------- ...rkspace-untitled-file-location-provider.ts | 49 ++++++ 27 files changed, 490 insertions(+), 336 deletions(-) create mode 100644 packages/core/src/browser/untitled-file-location-provider.ts create mode 100644 packages/filesystem/src/browser/filesystem-save-resource-service.ts create mode 100644 packages/workspace/src/browser/workspace-file-dialog-root-provider.ts delete mode 100644 packages/workspace/src/browser/workspace-save-resource-service.ts create mode 100644 packages/workspace/src/browser/workspace-untitled-file-location-provider.ts diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index cdaa905e49ccf..d9e7e33b49c5b 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -61,6 +61,7 @@ import { FrontendApplicationConfigProvider } from './frontend-application-config import { DecorationStyle } from './decoration-style'; import { isPinned, Title, togglePinned, Widget } from './widgets'; import { SaveResourceService } from './save-resource-service'; +import { UntitledFileLocationProvider } from './untitled-file-location-provider'; export namespace CommonMenus { @@ -268,12 +269,21 @@ export namespace CommonCommands { category: VIEW_CATEGORY, label: 'Show Menu Bar' }); - + export const NEW_FILE = Command.toDefaultLocalizedCommand({ + id: 'workbench.action.files.newUntitledFile', + category: FILE_CATEGORY, + label: 'New File' + }); export const SAVE = Command.toDefaultLocalizedCommand({ id: 'core.save', category: FILE_CATEGORY, label: 'Save', }); + export const SAVE_AS = Command.toDefaultLocalizedCommand({ + id: 'file.saveAs', + category: FILE_CATEGORY, + label: 'Save As...', + }); export const SAVE_WITHOUT_FORMATTING = Command.toDefaultLocalizedCommand({ id: 'core.saveWithoutFormatting', category: FILE_CATEGORY, @@ -385,6 +395,9 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(WindowService) protected readonly windowService: WindowService; + @inject(UntitledFileLocationProvider) + protected readonly untitledFileLocationProvider: UntitledFileLocationProvider; + protected pinnedKey: ContextKey<boolean>; async configure(app: FrontendApplication): Promise<void> { @@ -697,6 +710,11 @@ export class CommonFrontendContribution implements FrontendApplicationContributi }); registry.registerSubmenu(CommonMenus.VIEW_APPEARANCE_SUBMENU, nls.localizeByDefault('Appearance')); + + registry.registerMenuAction(CommonMenus.FILE_NEW, { + commandId: CommonCommands.NEW_FILE.id, + order: 'a' + }); } registerCommands(commandRegistry: CommandRegistry): void { @@ -889,10 +907,22 @@ export class CommonFrontendContribution implements FrontendApplicationContributi } } }); - commandRegistry.registerCommand(CommonCommands.SAVE, { execute: () => this.save({ formatType: FormatType.ON }) }); + commandRegistry.registerCommand(CommonCommands.SAVE_AS, { + isEnabled: () => this.saveResourceService.canSaveAs(this.shell.currentWidget), + execute: () => { + const { currentWidget } = this.shell; + // No clue what could have happened between `isEnabled` and `execute` + // when fetching currentWidget, so better to double-check: + if (this.saveResourceService.canSaveAs(currentWidget)) { + this.saveResourceService.saveAs(currentWidget); + } else { + this.messageService.error(nls.localize('theia/workspace/failSaveAs', 'Cannot run "{0}" for the current widget.', CommonCommands.SAVE_AS.label!)); + } + }, + }); commandRegistry.registerCommand(CommonCommands.SAVE_WITHOUT_FORMATTING, { execute: () => this.save({ formatType: FormatType.OFF }) }); @@ -924,6 +954,9 @@ export class CommonFrontendContribution implements FrontendApplicationContributi commandRegistry.registerCommand(CommonCommands.CONFIGURE_DISPLAY_LANGUAGE, { execute: () => this.configureDisplayLanguage() }); + commandRegistry.registerCommand(CommonCommands.NEW_FILE, { + execute: async () => open(this.openerService, await this.untitledFileLocationProvider.getUntitledFileLocation()) + }); } protected isElectron(): boolean { @@ -1056,6 +1089,10 @@ export class CommonFrontendContribution implements FrontendApplicationContributi command: CommonCommands.UNPIN_TAB.id, keybinding: 'ctrlcmd+k shift+enter', when: 'activeEditorIsPinned' + }, + { + command: CommonCommands.NEW_FILE.id, + keybinding: this.isElectron() ? 'ctrlcmd+n' : 'alt+n', } ); } diff --git a/packages/core/src/browser/decorations-service.ts b/packages/core/src/browser/decorations-service.ts index d73abeda00260..f0c4b6c7228c7 100644 --- a/packages/core/src/browser/decorations-service.ts +++ b/packages/core/src/browser/decorations-service.ts @@ -48,7 +48,7 @@ export interface DecorationsService { registerDecorationsProvider(provider: DecorationsProvider): Disposable; - getDecoration(uri: URI, includeChildren: boolean): Decoration []; + getDecoration(uri: URI, includeChildren: boolean): Decoration[]; } class DecorationDataRequest { @@ -198,7 +198,7 @@ export class DecorationsServiceImpl implements DecorationsService { }); } - getDecoration(uri: URI, includeChildren: boolean): Decoration [] { + getDecoration(uri: URI, includeChildren: boolean): Decoration[] { const data: Decoration[] = []; let containsChildren: boolean = false; for (const wrapper of this.data) { diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 0953a71339ace..bcc4694871344 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -121,6 +121,7 @@ import { RendererHost } from './widgets'; import { TooltipService, TooltipServiceImpl } from './tooltip-service'; import { bindFrontendStopwatch, bindBackendStopwatch } from './performance'; import { SaveResourceService } from './save-resource-service'; +import { UntitledFileLocationProvider } from './untitled-file-location-provider'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -398,4 +399,5 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bindBackendStopwatch(bind); bind(SaveResourceService).toSelf().inSingletonScope(); + bind(UntitledFileLocationProvider).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts index 31d267aa8e960..3ec83f2387baa 100644 --- a/packages/core/src/browser/save-resource-service.ts +++ b/packages/core/src/browser/save-resource-service.ts @@ -14,19 +14,26 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { injectable } from 'inversify'; -import { Saveable, SaveOptions } from './saveable'; +import { inject, injectable } from 'inversify'; +import { MessageService, UNTITLED_SCHEME } from '../common'; +import { Navigatable, NavigatableWidget } from './navigatable-types'; +import { Saveable, SaveableSource, SaveOptions } from './saveable'; import { Widget } from './widgets'; @injectable() export class SaveResourceService { + @inject(MessageService) protected readonly messageService: MessageService; /** * Indicate if the document can be saved ('Save' command should be disable if not). */ - canSave(saveable: Saveable): boolean { + canSave(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { + return this.canSaveNotSaveAs(widget) || this.canSaveAs(widget); + } + + protected canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { // By default, we never allow a document to be saved if it is untitled. - return Saveable.isDirty(saveable) && !Saveable.isUntitled(saveable); + return Boolean(widget && Saveable.isDirty(widget) && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME); } /** @@ -36,10 +43,20 @@ export class SaveResourceService { * and is thus saveable. */ async save(widget: Widget | undefined, options?: SaveOptions): Promise<void> { - const saveable = Saveable.get(widget); - if (saveable && this.canSave(saveable)) { - await saveable.save(options); + if (this.canSaveNotSaveAs(widget)) { + await Saveable.save(widget, options); + } else if (this.canSaveAs(widget)) { + await this.saveAs(widget, options); + } else { + this.messageService.error(`Cannot save the current widget "${widget?.title.label}" .`); } } + canSaveAs(saveable?: Widget): saveable is Widget & SaveableSource & Navigatable { + return false; + } + + saveAs(sourceWidget: Widget & SaveableSource & Navigatable, options?: SaveOptions): Promise<void> { + return Promise.reject('Unsupported: The base SaveResourceService does not support saveAs action.'); + } } diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index e5f59d86d264e..c0291f1f5321b 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -21,7 +21,6 @@ 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; @@ -66,10 +65,6 @@ 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/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 92df24bf73bf7..527172b41f1e4 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -38,6 +38,7 @@ import { waitForRevealed, waitForClosed } from '../widgets'; import { CorePreferences } from '../core-preferences'; import { BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; import { Deferred } from '../../common/promise-util'; +import { SaveResourceService } from '../save-resource-service'; /** The class name added to ApplicationShell instances. */ const APPLICATION_SHELL_CLASS = 'theia-ApplicationShell'; @@ -216,7 +217,8 @@ export class ApplicationShell extends Widget { @inject(SplitPositionHandler) protected splitPositionHandler: SplitPositionHandler, @inject(FrontendApplicationStateService) protected readonly applicationStateService: FrontendApplicationStateService, @inject(ApplicationShellOptions) @optional() options: RecursivePartial<ApplicationShell.Options> = {}, - @inject(CorePreferences) protected readonly corePreferences: CorePreferences + @inject(CorePreferences) protected readonly corePreferences: CorePreferences, + @inject(SaveResourceService) protected readonly saveResourceService: SaveResourceService, ) { super(options as Widget.IOptions); } @@ -1831,32 +1833,28 @@ export class ApplicationShell extends Widget { * Test whether the current widget is dirty. */ canSave(): boolean { - return Saveable.isDirty(this.currentWidget); + return this.saveResourceService.canSave(this.currentWidget); } /** * Save the current widget if it is dirty. */ async save(options?: SaveOptions): Promise<void> { - await Saveable.save(this.currentWidget, options); + await this.saveResourceService.save(this.currentWidget, options); } /** * Test whether there is a dirty widget. */ canSaveAll(): boolean { - return this.tracker.widgets.some(Saveable.isDirty); + return this.tracker.widgets.some(widget => this.saveResourceService.canSave(widget)); } /** * Save all dirty widgets. */ async saveAll(options?: SaveOptions): Promise<void> { - await Promise.all(this.tracker.widgets.map(widget => { - if (Saveable.isDirty(widget)) { - Saveable.save(widget, options); - } - })); + await Promise.all(this.tracker.widgets.map(widget => this.saveResourceService.save(widget))); } /** diff --git a/packages/core/src/browser/untitled-file-location-provider.ts b/packages/core/src/browser/untitled-file-location-provider.ts new file mode 100644 index 0000000000000..874a19b13b26e --- /dev/null +++ b/packages/core/src/browser/untitled-file-location-provider.ts @@ -0,0 +1,55 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { inject, injectable } from 'inversify'; +import URI from '../common/uri'; +import { createUntitledURI, MaybePromise, SelectionService, UriSelection } from '../common'; +import { EnvVariablesServer } from '../common/env-variables'; +import { NavigatableWidget } from './navigatable-types'; +import { ApplicationShell } from './shell'; + +@injectable() +export class UntitledFileLocationProvider { + @inject(ApplicationShell) protected readonly shell: ApplicationShell; + @inject(SelectionService) protected readonly selectionService: SelectionService; + @inject(EnvVariablesServer) protected readonly envVariables: EnvVariablesServer; + + async getUntitledFileLocation(extension?: string): Promise<URI> { + return createUntitledURI(extension, await this.getParent()); + } + + protected async getParent(): Promise<URI> { + return await this.getFromCurrentWidget() + ?? await this.getFromSelection() + ?? this.getFromUserHome(); + } + + protected getFromCurrentWidget(): MaybePromise<URI | undefined> { + return this.ensureIsDirectory(NavigatableWidget.getUri(this.shell.currentWidget)); + } + + protected getFromSelection(): MaybePromise<URI | undefined> { + return this.ensureIsDirectory(UriSelection.getUri(this.selectionService.selection)); + } + + protected getFromUserHome(): MaybePromise<URI> { + return this.envVariables.getHomeDirUri().then(home => new URI(home)); + } + + protected ensureIsDirectory(uri?: URI): MaybePromise<URI | undefined> { + return uri?.parent; + } +} diff --git a/packages/core/src/common/resource.ts b/packages/core/src/common/resource.ts index f2e0be51bd707..5e86388dbf50e 100644 --- a/packages/core/src/common/resource.ts +++ b/packages/core/src/common/resource.ts @@ -317,3 +317,82 @@ export class InMemoryTextResourceResolver implements ResourceResolver { return new InMemoryTextResource(uri); } } + +export const UNTITLED_SCHEME = 'untitled'; + +let untitledResourceSequenceIndex = 0; + +@injectable() +export class UntitledResourceResolver implements ResourceResolver { + + protected readonly resources = new Map<string, UntitledResource>(); + + async resolve(uri: URI): Promise<UntitledResource> { + if (uri.scheme !== UNTITLED_SCHEME) { + throw new Error('The given uri is not untitled file uri: ' + uri); + } else { + const untitledResource = this.resources.get(uri.toString()); + if (!untitledResource) { + return this.createUntitledResource('', '', uri); + } else { + return untitledResource; + } + } + } + + async createUntitledResource(content?: string, extension?: string, uri?: URI): Promise<UntitledResource> { + return new UntitledResource(this.resources, uri ? uri : new URI().withScheme(UNTITLED_SCHEME).withPath(`/Untitled-${untitledResourceSequenceIndex++}${extension ?? ''}`), + content); + } +} + +export class UntitledResource implements Resource { + + protected readonly onDidChangeContentsEmitter = new Emitter<void>(); + get onDidChangeContents(): Event<void> { + return this.onDidChangeContentsEmitter.event; + } + + constructor(private resources: Map<string, UntitledResource>, public uri: URI, private content?: string) { + this.resources.set(this.uri.toString(), this); + } + + dispose(): void { + this.resources.delete(this.uri.toString()); + this.onDidChangeContentsEmitter.dispose(); + } + + async readContents(options?: { encoding?: string | undefined; } | undefined): Promise<string> { + if (this.content) { + return this.content; + } else { + return ''; + } + } + + async saveContents(content: string, options?: { encoding?: string, overwriteEncoding?: boolean }): Promise<void> { + // 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('Untitled resources cannot be saved.'); + } + + protected fireDidChangeContents(): void { + this.onDidChangeContentsEmitter.fire(undefined); + } + + get version(): ResourceVersion | undefined { + return undefined; + } + + get encoding(): string | undefined { + return undefined; + } +} + +export function createUntitledURI(extension?: string, parent?: URI): URI { + const name = `Untitled-${untitledResourceSequenceIndex++}${extension ?? ''}`; + if (parent) { + return parent.resolve(name).withScheme(UNTITLED_SCHEME); + } + return new URI().resolve(name).withScheme(UNTITLED_SCHEME); +} diff --git a/packages/editor-preview/src/browser/editor-preview-widget.ts b/packages/editor-preview/src/browser/editor-preview-widget.ts index b35f7643a4d9e..19e9127f34ec0 100644 --- a/packages/editor-preview/src/browser/editor-preview-widget.ts +++ b/packages/editor-preview/src/browser/editor-preview-widget.ts @@ -17,7 +17,7 @@ import { Message } from '@theia/core/shared/@phosphor/messaging'; import { DockPanel, TabBar, Widget, PINNED_CLASS } from '@theia/core/lib/browser'; import { EditorWidget, TextEditor } from '@theia/editor/lib/browser'; -import { Disposable, DisposableCollection, Emitter, SelectionService } from '@theia/core/lib/common'; +import { Disposable, DisposableCollection, Emitter, SelectionService, UNTITLED_SCHEME } from '@theia/core/lib/common'; import { find } from '@theia/core/shared/@phosphor/algorithm'; const PREVIEW_TITLE_CLASS = 'theia-editor-preview-title-unpinned'; @@ -97,9 +97,11 @@ export class EditorPreviewWidget extends EditorWidget { } } - override storeState(): { isPreview: boolean, editorState: object } { - const { _isPreview: isPreview } = this; - return { isPreview, editorState: this.editor.storeViewState() }; + override storeState(): { isPreview: boolean, editorState: object } | undefined { + if (this.getResourceUri()?.scheme !== UNTITLED_SCHEME) { + const { _isPreview: isPreview } = this; + return { isPreview, editorState: this.editor.storeViewState() }; + } } override restoreState(oldState: { isPreview: boolean, editorState: object }): void { diff --git a/packages/editor/src/browser/editor-widget.ts b/packages/editor/src/browser/editor-widget.ts index 3ea998f95c600..324e2728f59d0 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { Disposable, SelectionService, Event } from '@theia/core/lib/common'; +import { Disposable, SelectionService, Event, UNTITLED_SCHEME } from '@theia/core/lib/common'; import { Widget, BaseWidget, Message, Saveable, SaveableSource, Navigatable, StatefulWidget } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { TextEditor } from './editor'; @@ -80,8 +80,8 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata } } - storeState(): object { - return this.editor.storeViewState(); + storeState(): object | undefined { + return this.getResourceUri()?.scheme === UNTITLED_SCHEME ? undefined : this.editor.storeViewState(); } restoreState(oldState: object): void { diff --git a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts index 3148f54f303ad..ea9ac1249b727 100644 --- a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts +++ b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts @@ -35,6 +35,15 @@ export interface FileDialogService { } +@injectable() +export class FileDialogDefaultRootProvider { + @inject(EnvVariablesServer) protected readonly environments: EnvVariablesServer; + + async getDefaultDialogRoot(): Promise<URI> { + return new URI(await this.environments.getHomeDirUri()); + } +} + @injectable() export class DefaultFileDialogService implements FileDialogService { @@ -47,6 +56,7 @@ export class DefaultFileDialogService implements FileDialogService { @inject(OpenFileDialogFactory) protected readonly openFileDialogFactory: OpenFileDialogFactory; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(SaveFileDialogFactory) protected readonly saveFileDialogFactory: SaveFileDialogFactory; + @inject(FileDialogDefaultRootProvider) protected readonly rootProvider: FileDialogDefaultRootProvider; async showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true }, folder?: FileStat): Promise<MaybeArray<URI> | undefined>; async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<URI | undefined>; @@ -81,7 +91,7 @@ export class DefaultFileDialogService implements FileDialogService { protected async getRootNode(folderToOpen?: FileStat): Promise<DirNode | undefined> { const folderExists = folderToOpen && await this.fileService.exists(folderToOpen.resource); const folder = folderToOpen && folderExists ? folderToOpen : { - resource: new URI(await this.environments.getHomeDirUri()), + resource: await this.rootProvider.getDefaultDialogRoot(), isDirectory: true }; const folderUri = folder.resource; diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index 4744a824b0b0c..219aff925884a 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -38,8 +38,11 @@ import { FileSystemWatcherErrorHandler } from './filesystem-watcher-error-handle import { UTF8 } from '@theia/core/lib/common/encodings'; import { FilepathBreadcrumbsContribution } from './breadcrumbs/filepath-breadcrumbs-contribution'; import { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container'; +import { FilesystemSaveResourceService } from './filesystem-save-resource-service'; +import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; +import { FileDialogDefaultRootProvider } from './file-dialog'; -export default new ContainerModule(bind => { +export default new ContainerModule((bind, unbind, isBound, rebind) => { bindFileSystemPreferences(bind); bindContributionProvider(bind, FileServiceContribution); @@ -224,6 +227,10 @@ export default new ContainerModule(bind => { ); bind(FilepathBreadcrumbsContribution).toSelf().inSingletonScope(); bind(BreadcrumbsContribution).toService(FilepathBreadcrumbsContribution); + + bind(FilesystemSaveResourceService).toSelf().inSingletonScope(); + rebind(SaveResourceService).toService(FilesystemSaveResourceService); + bind(FileDialogDefaultRootProvider).toSelf().inSingletonScope(); }); export function bindFileResource(bind: interfaces.Bind): void { diff --git a/packages/filesystem/src/browser/filesystem-save-resource-service.ts b/packages/filesystem/src/browser/filesystem-save-resource-service.ts new file mode 100644 index 0000000000000..24b74d100db56 --- /dev/null +++ b/packages/filesystem/src/browser/filesystem-save-resource-service.ts @@ -0,0 +1,130 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { environment, nls, UNTITLED_SCHEME } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { Navigatable, Saveable, SaveableSource, SaveOptions, Widget, open, OpenerService, ConfirmDialog, FormatType, CommonCommands } from '@theia/core/lib/browser'; +import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; +import URI from '@theia/core/lib/common/uri'; +import { FileService } from './file-service'; +import { FileDialogService } from './file-dialog'; + +@injectable() +export class FilesystemSaveResourceService extends SaveResourceService { + + @inject(FileService) protected readonly fileService: FileService; + @inject(FileDialogService) protected readonly fileDialogService: FileDialogService; + @inject(OpenerService) protected readonly openerService: OpenerService; + + /** + * This method ensures a few things about `widget`: + * - `widget.getResourceUri()` actually returns a URI. + * - `widget.saveable.createSnapshot` is defined. + * - `widget.saveable.revert` is defined. + */ + override canSaveAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable { + return widget !== undefined + && Saveable.isSource(widget) + && typeof widget.saveable.createSnapshot === 'function' + && typeof widget.saveable.revert === 'function' + && Navigatable.is(widget) + && widget.getResourceUri() !== undefined; + } + + /** + * Save `sourceWidget` to a new file picked by the user. + */ + override async saveAs(sourceWidget: Widget & SaveableSource & Navigatable, options?: SaveOptions): Promise<void> { + let exist: boolean = false; + let overwrite: boolean = false; + let selected: URI | undefined; + const canSave = this.canSaveNotSaveAs(sourceWidget); + const uri: URI = sourceWidget.getResourceUri()!; + let stat; + if (uri.scheme === 'file') { + stat = await this.fileService.resolve(uri).catch(() => undefined); + } else if (uri.scheme === UNTITLED_SCHEME) { + stat = await this.fileService.resolve(uri.withScheme('file').parent).catch(() => undefined); + } + do { + selected = await this.fileDialogService.showSaveDialog( + { + title: CommonCommands.SAVE_AS.label!, + filters: {}, + inputValue: uri.path.base + }, stat); + if (selected) { + exist = await this.fileService.exists(selected); + if (exist) { + overwrite = await this.confirmOverwrite(selected); + } + } + } while ((selected && exist && !overwrite) || (selected?.isEqual(uri) && !canSave)); + if (selected && selected.isEqual(uri)) { + await this.save(sourceWidget, options); + } else if (selected) { + try { + await this.copyAndSave(sourceWidget, selected, overwrite); + } catch (e) { + console.warn(e); + } + } + } + + /** + * @param sourceWidget widget to save as `target`. + * @param target The new URI for the widget. + * @param overwrite + */ + private async copyAndSave(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise<void> { + const snapshot = sourceWidget.saveable.createSnapshot!(); + if (!await this.fileService.exists(target)) { + const sourceUri = sourceWidget.getResourceUri()!; + if (this.fileService.canHandleResource(sourceUri)) { + await this.fileService.copy(sourceUri, target, { overwrite }); + } else { + await this.fileService.createFile(target); + } + } + const targetWidget = await open(this.openerService, target); + const targetSaveable = Saveable.get(targetWidget); + if (targetWidget && targetSaveable && targetSaveable.applySnapshot) { + targetSaveable.applySnapshot(snapshot); + await sourceWidget.saveable.revert!(); + sourceWidget.close(); + Saveable.save(targetWidget, { formatType: FormatType.ON }); + } else { + this.messageService.error(nls.localize('theia/workspace/failApply', 'Could not apply changes to new file')); + } + } + + async confirmOverwrite(uri: URI): Promise<boolean> { + // Electron already handles the confirmation so do not prompt again. + if (this.isElectron()) { + return true; + } + // Prompt users for confirmation before overwriting. + const confirmed = await new ConfirmDialog({ + title: nls.localizeByDefault('Overwrite'), + msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', uri.toString()) + }).open(); + return !!confirmed; + } + + private isElectron(): boolean { + return environment.electron.is(); + } +} diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index c4b70cf2a1c58..045c4c1b04a03 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -19,7 +19,7 @@ import { TextEditorDocument, EncodingMode, FindMatchesOptions, FindMatch, Editor import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common/cancellation'; -import { Resource, ResourceError, ResourceVersion } from '@theia/core/lib/common/resource'; +import { Resource, ResourceError, ResourceVersion, UNTITLED_SCHEME } from '@theia/core/lib/common/resource'; import { Saveable, SaveOptions } from '@theia/core/lib/browser/saveable'; import { MonacoToProtocolConverter } from './monaco-to-protocol-converter'; import { ProtocolToMonacoConverter } from './protocol-to-monaco-converter'; @@ -163,6 +163,9 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { const languageSelection = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(uri, firstLine); this.model = monaco.services.StaticServices.modelService.get().createModel(value, languageSelection, uri); this.resourceVersion = this.resource.version; + if (this.resource.uri.scheme === UNTITLED_SCHEME && this.model.getValueLength()) { + this.setDirty(true); + } this.updateSavedVersionId(); this.toDispose.push(this.model); this.toDispose.push(this.model.onDidChangeContent(event => this.fireDidChangeContent(event))); diff --git a/packages/monaco/src/browser/monaco-languages.ts b/packages/monaco/src/browser/monaco-languages.ts index 2a62dce7add83..9a0596c309577 100644 --- a/packages/monaco/src/browser/monaco-languages.ts +++ b/packages/monaco/src/browser/monaco-languages.ts @@ -89,6 +89,10 @@ export class MonacoLanguages implements LanguageService { return this.mergeLanguages(monaco.languages.getLanguages().filter(language => language.id === languageId)).get(languageId); } + getExtension(languageId: string): string | undefined { + return this.getLanguage(languageId)?.extensions.values().next().value; + } + protected mergeLanguages(registered: monaco.languages.ILanguageExtensionPoint[]): Map<string, Mutable<Language>> { const languages = new Map<string, Mutable<Language>>(); for (const { id, aliases, extensions, filenames } of registered) { diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index 8a8597a3aab87..ba9917f9c93ef 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -573,9 +573,9 @@ export class FileNavigatorContribution extends AbstractViewContribution<FileNavi priority: 1, }); this.registerMoreToolbarItem({ - id: WorkspaceCommands.NEW_FILE.id, - command: WorkspaceCommands.NEW_FILE.id, - tooltip: WorkspaceCommands.NEW_FILE.label, + id: CommonCommands.NEW_FILE.id, + command: CommonCommands.NEW_FILE.id, + tooltip: CommonCommands.NEW_FILE.label, group: NavigatorMoreToolbarGroups.NEW_OPEN, }); this.registerMoreToolbarItem({ diff --git a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts index b10f11d9853ef..44d44d01e5270 100755 --- a/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts +++ b/packages/plugin-ext-vscode/src/browser/plugin-vscode-commands-contribution.ts @@ -19,7 +19,6 @@ import { ApplicationShell, CommonCommands, NavigatableWidget, - open, OpenerService, OpenHandler, QuickInputService, Saveable, @@ -47,7 +46,6 @@ import { DocumentHighlight } from '@theia/plugin-ext/lib/common/plugin-api-rpc-model'; import { DocumentsMainImpl } from '@theia/plugin-ext/lib/main/browser/documents-main'; -import { createUntitledURI } from '@theia/plugin-ext/lib/main/browser/editor/untitled-resource'; import { isUriComponents, toDocumentSymbol, toPosition } from '@theia/plugin-ext/lib/plugin/type-converters'; import { ViewColumn } from '@theia/plugin-ext/lib/plugin/types-impl'; import { WorkspaceCommands } from '@theia/workspace/lib/browser'; @@ -273,10 +271,6 @@ export class PluginVscodeCommandsContribution implements CommandContribution { * because of it we filter out editors from views based on `NavigatableWidget.is` * and apply actions only to them */ - commands.registerCommand({ id: 'workbench.action.files.newUntitledFile' }, { - execute: () => open(this.openerService, createUntitledURI()) - }); - if (!environment.electron.is() || isOSX) { commands.registerCommand({ id: 'workbench.action.files.openFileFolder' }, { execute: () => commands.executeCommand(WorkspaceCommands.OPEN.id) diff --git a/packages/plugin-ext/src/common/rpc-protocol.ts b/packages/plugin-ext/src/common/rpc-protocol.ts index b87adad494b92..e46c930a3c325 100644 --- a/packages/plugin-ext/src/common/rpc-protocol.ts +++ b/packages/plugin-ext/src/common/rpc-protocol.ts @@ -94,7 +94,7 @@ export class RPCProtocolImpl implements RPCProtocol { constructor(connection: MessageConnection, transformations?: { replacer?: (key: string | undefined, value: any) => any, - reviver?: (key: string | undefined, value: any) => any + reviver?: (key: string | undefined, value: any) => any }) { this.toDispose.push( this.multiplexer = new RPCMultiplexer(connection) diff --git a/packages/plugin-ext/src/main/browser/documents-main.ts b/packages/plugin-ext/src/main/browser/documents-main.ts index 8c83eac9da2a1..f469434f122e0 100644 --- a/packages/plugin-ext/src/main/browser/documents-main.ts +++ b/packages/plugin-ext/src/main/browser/documents-main.ts @@ -30,6 +30,7 @@ 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 { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages'; /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. @@ -94,6 +95,7 @@ export class DocumentsMainImpl implements DocumentsMain, Disposable { private openerService: OpenerService, private shell: ApplicationShell, private untitledResourceResolver: UntitledResourceResolver, + private languageService: MonacoLanguages, ) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.DOCUMENTS_EXT); @@ -177,8 +179,8 @@ export class DocumentsMainImpl implements DocumentsMain, Disposable { } async $tryCreateDocument(options?: { language?: string; content?: string; }): Promise<UriComponents> { - const language = options && options.language; - const content = options && options.content; + const language = options?.language && this.languageService.getExtension(options.language); + const content = options?.content; 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 69ffdbb294b0a..760c0af0ffc9f 100644 --- a/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts +++ b/packages/plugin-ext/src/main/browser/editor/untitled-resource.ts @@ -14,101 +14,5 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 // ***************************************************************************** -import { Emitter, Event } from '@theia/core/lib/common/event'; -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'; - -let index = 0; - -@injectable() -export class UntitledResourceResolver implements ResourceResolver { - - protected readonly resources = new Map<string, UntitledResource>(); - - async resolve(uri: URI): Promise<UntitledResource> { - if (uri.scheme !== Schemes.untitled) { - throw new Error('The given uri is not untitled file uri: ' + uri); - } else { - const untitledResource = this.resources.get(uri.toString()); - if (!untitledResource) { - return this.createUntitledResource('', '', uri); - } else { - return untitledResource; - } - } - } - - async createUntitledResource(content?: string, language?: string, uri?: URI): Promise<UntitledResource> { - let extension; - if (language) { - for (const lang of monaco.languages.getLanguages()) { - if (lang.id === language) { - if (lang.extensions) { - extension = lang.extensions[0]; - break; - } - } - } - } - return new UntitledResource(this.resources, uri ? uri : new URI().withScheme(Schemes.untitled).withPath(`/Untitled-${index++}${extension ? extension : ''}`), - content); - } -} - -export class UntitledResource implements Resource { - - protected readonly onDidChangeContentsEmitter = new Emitter<void>(); - readonly onDidChangeContents: Event<void> = this.onDidChangeContentsEmitter.event; - - constructor(private resources: Map<string, UntitledResource>, public uri: URI, private content?: string) { - this.resources.set(this.uri.toString(), this); - } - - dispose(): void { - this.resources.delete(this.uri.toString()); - this.onDidChangeContentsEmitter.dispose(); - } - - async readContents(options?: { encoding?: string | undefined; } | undefined): Promise<string> { - if (this.content) { - return this.content; - } else { - return ''; - } - } - - async saveContents(content: string, options?: { encoding?: string, overwriteEncoding?: boolean }): Promise<void> { - // 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 { - this.onDidChangeContentsEmitter.fire(undefined); - } - - get version(): ResourceVersion | undefined { - return undefined; - } - - get encoding(): string | undefined { - return undefined; - } -} - -export function createUntitledURI(language?: string): URI { - let extension; - if (language) { - for (const lang of monaco.languages.getLanguages()) { - if (lang.id === language) { - if (lang.extensions) { - extension = lang.extensions[0]; - break; - } - } - } - } - return new URI().withScheme(Schemes.untitled).withPath(`/Untitled-${index++}${extension ? extension : ''}`); -} +/** @deprecated @since 1.24. Import from `core/lib/common/resource` instead. */ +export { UntitledResourceResolver, UntitledResource, createUntitledURI } from '@theia/core/lib/common/resource'; diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index cd23c95084d15..c5699790b864e 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -56,6 +56,7 @@ import { CommentsMainImp } from './comments/comments-main'; import { CustomEditorsMainImpl } from './custom-editors/custom-editors-main'; import { SecretsMainImpl } from './secrets-main'; import { WebviewViewsMainImpl } from './webview-views/webview-views-main'; +import { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const authenticationMain = new AuthenticationMainImpl(rpc, container); @@ -86,7 +87,8 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const openerService = container.get<OpenerService>(OpenerService); const shell = container.get(ApplicationShell); const untitledResourceResolver = container.get(UntitledResourceResolver); - const documentsMain = new DocumentsMainImpl(editorsAndDocuments, modelService, rpc, editorManager, openerService, shell, untitledResourceResolver); + const languageService = container.get(MonacoLanguages); + const documentsMain = new DocumentsMainImpl(editorsAndDocuments, modelService, rpc, editorManager, openerService, shell, untitledResourceResolver, languageService); rpc.set(PLUGIN_RPC_CONTEXT.DOCUMENTS_MAIN, documentsMain); const bulkEditService = container.get(MonacoBulkEditService); diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 5936391450316..d9d3953ad45df 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -89,6 +89,7 @@ export namespace WorkspaceCommands { category: WORKSPACE_CATEGORY, label: 'Close Workspace' }); + /** @deprecated @since 1.24 Use CommonCommands.NEW_FILE instead. */ export const NEW_FILE = Command.toDefaultLocalizedCommand({ id: 'file.newFile', category: FILE_CATEGORY, @@ -142,11 +143,8 @@ export namespace WorkspaceCommands { category: WORKSPACE_CATEGORY, label: 'Open Workspace Configuration File' }); - export const SAVE_AS = Command.toDefaultLocalizedCommand({ - id: 'file.saveAs', - category: CommonCommands.FILE_CATEGORY, - label: 'Save As...', - }); + /** @deprecated @since 1.24.0 Use `CommonCommands.SAVE_AS` instead */ + export const SAVE_AS = CommonCommands.SAVE_AS; export const COPY_RELATIVE_FILE_PATH = Command.toDefaultLocalizedCommand({ id: 'navigator.copyRelativeFilePath', label: 'Copy Relative Path' @@ -157,10 +155,6 @@ export namespace WorkspaceCommands { export class FileMenuContribution implements MenuContribution { registerMenus(registry: MenuModelRegistry): void { - registry.registerMenuAction(CommonMenus.FILE_NEW, { - commandId: WorkspaceCommands.NEW_FILE.id, - order: 'a' - }); registry.registerMenuAction(CommonMenus.FILE_NEW, { commandId: WorkspaceCommands.NEW_FOLDER.id, order: 'b' diff --git a/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts b/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts new file mode 100644 index 0000000000000..dfd2ecb9c0d16 --- /dev/null +++ b/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts @@ -0,0 +1,30 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { inject, injectable } from '@theia/core/shared/inversify'; +import URI from '@theia/core/lib/common/uri'; +import { FileDialogDefaultRootProvider } from '@theia/filesystem/lib/browser'; +import { WorkspaceService } from './workspace-service'; + +@injectable() +export class WorkspaceFileDialogDefaultRootProvider extends FileDialogDefaultRootProvider { + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + + override async getDefaultDialogRoot(): Promise<URI> { + const root = this.workspaceService.tryGetRoots()[0]; + return root?.resource ?? super.getDefaultDialogRoot(); + } +} diff --git a/packages/workspace/src/browser/workspace-frontend-contribution.ts b/packages/workspace/src/browser/workspace-frontend-contribution.ts index 875fc46cabc56..2f0338386ee6b 100644 --- a/packages/workspace/src/browser/workspace-frontend-contribution.ts +++ b/packages/workspace/src/browser/workspace-frontend-contribution.ts @@ -15,17 +15,17 @@ // ***************************************************************************** import { injectable, inject } from '@theia/core/shared/inversify'; -import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, SelectionService, MessageService, isWindows, MaybeArray } from '@theia/core/lib/common'; +import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry, MessageService, isWindows, MaybeArray } from '@theia/core/lib/common'; 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, OnWillStopAction, FormatType + open, OpenerService, CommonMenus, ConfirmDialog, KeybindingRegistry, KeybindingContribution, + FrontendApplicationContribution, SHELL_TABBAR_CONTEXT_COPY, OnWillStopAction, Navigatable, SaveableSource, Widget } from '@theia/core/lib/browser'; import { FileDialogService, OpenFileDialogProps, FileDialogTreeFilters } from '@theia/filesystem/lib/browser'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { WorkspaceService } from './workspace-service'; import { THEIA_EXT, VSCODE_EXT } from '../common'; -import { WorkspaceCommands, WorkspaceCommandContribution } from './workspace-commands'; +import { WorkspaceCommands } from './workspace-commands'; import { QuickOpenWorkspace } from './quick-open-workspace'; import { WorkspacePreferences } from './workspace-preferences'; import URI from '@theia/core/lib/common/uri'; @@ -38,6 +38,7 @@ import { nls } from '@theia/core/lib/common/nls'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { UntitledWorkspaceExitDialog } from './untitled-workspace-exit-dialog'; +import { FilesystemSaveResourceService } from '@theia/filesystem/lib/browser/filesystem-save-resource-service'; export enum WorkspaceStates { /** @@ -59,28 +60,17 @@ export type WorkbenchState = keyof typeof WorkspaceStates; @injectable() export class WorkspaceFrontendContribution implements CommandContribution, KeybindingContribution, MenuContribution, FrontendApplicationContribution { - @inject(ApplicationShell) protected readonly applicationShell: ApplicationShell; @inject(MessageService) protected readonly messageService: MessageService; @inject(FileService) protected readonly fileService: FileService; @inject(OpenerService) protected readonly openerService: OpenerService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; - @inject(StorageService) protected readonly workspaceStorage: StorageService; - @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(QuickOpenWorkspace) protected readonly quickOpenWorkspace: QuickOpenWorkspace; @inject(FileDialogService) protected readonly fileDialogService: FileDialogService; @inject(WorkspacePreferences) protected preferences: WorkspacePreferences; - @inject(SelectionService) protected readonly selectionService: SelectionService; - @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; - @inject(WorkspaceCommandContribution) protected readonly workspaceCommands: WorkspaceCommandContribution; - - @inject(ContextKeyService) - protected readonly contextKeyService: ContextKeyService; - - @inject(EncodingRegistry) - protected readonly encodingRegistry: EncodingRegistry; - - @inject(PreferenceConfigurations) - protected readonly preferenceConfigurations: PreferenceConfigurations; + @inject(ContextKeyService) protected readonly contextKeyService: ContextKeyService; + @inject(EncodingRegistry) protected readonly encodingRegistry: EncodingRegistry; + @inject(PreferenceConfigurations) protected readonly preferenceConfigurations: PreferenceConfigurations; + @inject(FilesystemSaveResourceService) protected readonly saveService: FilesystemSaveResourceService; configure(): void { this.encodingRegistry.registerOverride({ encoding: UTF8, extension: THEIA_EXT }); @@ -161,19 +151,6 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi isEnabled: () => this.workspaceService.isMultiRootWorkspaceEnabled, execute: () => this.saveWorkspaceAs() }); - commands.registerCommand(WorkspaceCommands.SAVE_AS, { - isEnabled: () => this.canBeSavedAs(this.applicationShell.currentWidget), - execute: () => { - const { currentWidget } = this.applicationShell; - // No clue what could have happened between `isEnabled` and `execute` - // when fetching currentWidget, so better to double-check: - if (this.canBeSavedAs(currentWidget)) { - this.saveAs(currentWidget); - } else { - this.messageService.error(nls.localize('theia/workspace/failSaveAs', 'Cannot run "{0}" for the current widget.', WorkspaceCommands.SAVE_AS.label!)); - } - }, - }); commands.registerCommand(WorkspaceCommands.OPEN_WORKSPACE_FILE, { isEnabled: () => this.workspaceService.saved, execute: () => { @@ -232,10 +209,6 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi } registerKeybindings(keybindings: KeybindingRegistry): void { - keybindings.registerKeybinding({ - command: WorkspaceCommands.NEW_FILE.id, - keybinding: this.isElectron() ? 'ctrlcmd+n' : 'alt+n', - }); keybindings.registerKeybinding({ command: isOSX || !this.isElectron() ? WorkspaceCommands.OPEN.id : WorkspaceCommands.OPEN_FILE.id, keybinding: this.isElectron() ? 'ctrlcmd+o' : 'ctrlcmd+alt+o', @@ -457,7 +430,7 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi } exist = await this.fileService.exists(selected); if (exist) { - overwrite = await this.confirmOverwrite(selected); + overwrite = await this.saveService.confirmOverwrite(selected); } } } while (selected && exist && !overwrite); @@ -473,85 +446,12 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi return false; } - /** - * This method ensures a few things about `widget`: - * - `widget.getResourceUri()` actually returns a URI. - * - `widget.saveable.createSnapshot` is defined. - * - `widget.saveable.revert` is defined. - */ canBeSavedAs(widget: Widget | undefined): widget is Widget & SaveableSource & Navigatable { - return widget !== undefined - && Saveable.isSource(widget) - && typeof widget.saveable.createSnapshot === 'function' - && typeof widget.saveable.revert === 'function' - && Navigatable.is(widget) - && widget.getResourceUri() !== undefined; - } - - /** - * Save `sourceWidget` to a new file picked by the user. - */ - async saveAs(sourceWidget: Widget & SaveableSource & Navigatable): Promise<void> { - let exist: boolean = false; - let overwrite: boolean = false; - let selected: URI | undefined; - const uri: URI = sourceWidget.getResourceUri()!; - let stat; - if (uri.scheme === 'file') { - stat = await this.fileService.resolve(uri); - } else { - stat = this.workspaceService.workspace; - } - do { - selected = await this.fileDialogService.showSaveDialog( - { - title: WorkspaceCommands.SAVE_AS.label!, - filters: {}, - inputValue: uri.path.base - }, stat); - if (selected) { - exist = await this.fileService.exists(selected); - if (exist) { - overwrite = await this.confirmOverwrite(selected); - } - } - } while (selected && exist && !overwrite); - if (selected && selected.isEqual(uri)) { - await this.commandRegistry.executeCommand(CommonCommands.SAVE.id); - } else if (selected) { - try { - await this.copyAndSave(sourceWidget, selected, overwrite); - } catch (e) { - console.warn(e); - } - } + return this.saveService.canSaveAs(widget); } - /** - * @param sourceWidget widget to save as `target`. - * @param target The new URI for the widget. - * @param overwrite - */ - private async copyAndSave(sourceWidget: Widget & SaveableSource & Navigatable, target: URI, overwrite: boolean): Promise<void> { - const snapshot = sourceWidget.saveable.createSnapshot!(); - if (!await this.fileService.exists(target)) { - const sourceUri = sourceWidget.getResourceUri()!; - if (this.fileService.canHandleResource(sourceUri)) { - await this.fileService.copy(sourceUri, target, { overwrite }); - } else { - await this.fileService.createFile(target); - } - } - const targetWidget = await open(this.openerService, target); - const targetSaveable = Saveable.get(targetWidget); - if (targetWidget && targetSaveable && targetSaveable.applySnapshot) { - targetSaveable.applySnapshot(snapshot); - await sourceWidget.saveable.revert!(); - sourceWidget.close(); - Saveable.save(targetWidget, { formatType: FormatType.ON }); - } else { - this.messageService.error(nls.localize('theia/workspace/failApply', 'Could not apply changes to new file')); - } + async saveAs(widget: Widget & SaveableSource & Navigatable): Promise<void> { + return this.saveService.saveAs(widget); } protected updateWorkspaceStateKey(): WorkspaceState { @@ -569,19 +469,6 @@ export class WorkspaceFrontendContribution implements CommandContribution, Keybi return 'empty'; } - private async confirmOverwrite(uri: URI): Promise<boolean> { - // Electron already handles the confirmation so do not prompt again. - if (this.isElectron()) { - return true; - } - // Prompt users for confirmation before overwriting. - const confirmed = await new ConfirmDialog({ - title: nls.localizeByDefault('Overwrite'), - msg: nls.localizeByDefault('{0} already exists. Are you sure you want to overwrite it?', uri.toString()) - }).open(); - return !!confirmed; - } - private isElectron(): boolean { return environment.electron.is(); } diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 62e2f996ef550..1d191540e14f8 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -25,7 +25,8 @@ import { createOpenFileDialogContainer, createSaveFileDialogContainer, OpenFileDialog, - SaveFileDialog + SaveFileDialog, + FileDialogDefaultRootProvider } from '@theia/filesystem/lib/browser'; import { StorageService } from '@theia/core/lib/browser/storage-service'; import { LabelProviderContribution } from '@theia/core/lib/browser/label-provider'; @@ -50,8 +51,9 @@ import { WorkspaceBreadcrumbsContribution } from './workspace-breadcrumbs-contri import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; import { WorkspaceTrustService } from './workspace-trust-service'; import { bindWorkspaceTrustPreferences } from './workspace-trust-preferences'; -import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; -import { WorkspaceSaveResourceService } from './workspace-save-resource-service'; +import { WorkspaceFileDialogDefaultRootProvider } from './workspace-file-dialog-root-provider'; +import { UntitledFileLocationProvider } from '@theia/core/lib/browser/untitled-file-location-provider'; +import { WorkspaceUntitledFileLocationProvider } from './workspace-untitled-file-location-provider'; export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { bindWorkspacePreferences(bind); @@ -107,6 +109,7 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope(); bind(WorkspaceTrustService).toSelf().inSingletonScope(); - - rebind(SaveResourceService).to(WorkspaceSaveResourceService).inSingletonScope(); + bind(WorkspaceFileDialogDefaultRootProvider).toSelf().inSingletonScope(); + rebind(FileDialogDefaultRootProvider).toSelf().inSingletonScope(); + rebind(UntitledFileLocationProvider).to(WorkspaceUntitledFileLocationProvider).inSingletonScope(); }); diff --git a/packages/workspace/src/browser/workspace-save-resource-service.ts b/packages/workspace/src/browser/workspace-save-resource-service.ts deleted file mode 100644 index 218cfa6b8db1c..0000000000000 --- a/packages/workspace/src/browser/workspace-save-resource-service.ts +++ /dev/null @@ -1,50 +0,0 @@ -/******************************************************************************** - * 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 { inject, injectable } from '@theia/core/shared/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; - - override 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); - } - - override async save(widget: Widget | undefined, options?: SaveOptions): Promise<void> { - 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}" .`); - } - - } - -} diff --git a/packages/workspace/src/browser/workspace-untitled-file-location-provider.ts b/packages/workspace/src/browser/workspace-untitled-file-location-provider.ts new file mode 100644 index 0000000000000..ad1e3c53f7847 --- /dev/null +++ b/packages/workspace/src/browser/workspace-untitled-file-location-provider.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// Copyright (C) 2022 Ericsson 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { UntitledFileLocationProvider } from '@theia/core/lib/browser/untitled-file-location-provider'; +import URI from '@theia/core/lib/common/uri'; +import { WorkspaceService } from './workspace-service'; +import { MaybePromise } from '@theia/core'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; + +@injectable() +export class WorkspaceUntitledFileLocationProvider extends UntitledFileLocationProvider { + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(FileService) protected readonly fileService: FileService; + + protected override async getParent(): Promise<URI> { + return await this.getFromCurrentWidget() + ?? await this.getFromSelection() + ?? await this.getFromWorkspace() + ?? this.getFromUserHome(); + } + + protected getFromWorkspace(): MaybePromise<URI | undefined> { + return this.workspaceService.tryGetRoots()[0]?.resource; + } + + protected override async ensureIsDirectory(uri?: URI): Promise<URI | undefined> { + if (uri) { + const asFile = uri.withScheme('file'); + const stat = await this.fileService.resolve(asFile) + .catch(() => this.fileService.resolve(asFile.parent)) + .catch(() => undefined); + return stat?.isDirectory ? stat.resource : stat?.resource.parent; + } + } +} From cb881a9207db15df4e1a0903eb531642241c9e2b Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Thu, 10 Mar 2022 08:22:32 -0700 Subject: [PATCH 2/8] Save on non-saveable is no-op, not error --- packages/core/src/browser/save-resource-service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts index 3ec83f2387baa..afc8ae9cf2602 100644 --- a/packages/core/src/browser/save-resource-service.ts +++ b/packages/core/src/browser/save-resource-service.ts @@ -37,18 +37,15 @@ export class SaveResourceService { } /** - * Saves the document. + * Saves the document * - * This function is called only if `canSave` returns true, which means the document is not untitled - * and is thus saveable. + * No op if the widget is not saveable. */ async save(widget: Widget | undefined, options?: SaveOptions): Promise<void> { if (this.canSaveNotSaveAs(widget)) { await Saveable.save(widget, options); } else if (this.canSaveAs(widget)) { await this.saveAs(widget, options); - } else { - this.messageService.error(`Cannot save the current widget "${widget?.title.label}" .`); } } From 6dbef00ef0df9c1ba653d32dd290e94cc2cdf615 Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Mon, 14 Mar 2022 08:36:55 -0600 Subject: [PATCH 3/8] open new widget next to old widget --- .../filesystem/src/browser/filesystem-save-resource-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/filesystem/src/browser/filesystem-save-resource-service.ts b/packages/filesystem/src/browser/filesystem-save-resource-service.ts index 24b74d100db56..816601f25cf38 100644 --- a/packages/filesystem/src/browser/filesystem-save-resource-service.ts +++ b/packages/filesystem/src/browser/filesystem-save-resource-service.ts @@ -99,7 +99,7 @@ export class FilesystemSaveResourceService extends SaveResourceService { await this.fileService.createFile(target); } } - const targetWidget = await open(this.openerService, target); + const targetWidget = await open(this.openerService, target, { widgetOptions: { ref: sourceWidget } }); const targetSaveable = Saveable.get(targetWidget); if (targetWidget && targetSaveable && targetSaveable.applySnapshot) { targetSaveable.applySnapshot(snapshot); From 9689bc4a9ed6c37346afe51335f66a623379987d Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Mon, 14 Mar 2022 09:07:59 -0600 Subject: [PATCH 4/8] Handle saves in sequence; use slice --- packages/core/src/browser/save-resource-service.ts | 4 ++-- packages/core/src/browser/shell/application-shell.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts index afc8ae9cf2602..0c2f92d5c05a1 100644 --- a/packages/core/src/browser/save-resource-service.ts +++ b/packages/core/src/browser/save-resource-service.ts @@ -28,12 +28,12 @@ export class SaveResourceService { * Indicate if the document can be saved ('Save' command should be disable if not). */ canSave(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { - return this.canSaveNotSaveAs(widget) || this.canSaveAs(widget); + return Saveable.isDirty(widget) && (this.canSaveNotSaveAs(widget) || this.canSaveAs(widget)); } protected canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { // By default, we never allow a document to be saved if it is untitled. - return Boolean(widget && Saveable.isDirty(widget) && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME); + return Boolean(widget && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME); } /** diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 527172b41f1e4..282cbecc26a25 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1854,7 +1854,9 @@ export class ApplicationShell extends Widget { * Save all dirty widgets. */ async saveAll(options?: SaveOptions): Promise<void> { - await Promise.all(this.tracker.widgets.map(widget => this.saveResourceService.save(widget))); + for (const widget of this.widgets) { + await this.saveResourceService.save(widget, options); + } } /** From 15159df11b92fc3130494c9276665dc3cba2cb1e Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Tue, 15 Mar 2022 12:33:46 -0600 Subject: [PATCH 5/8] Use UserWorkingDirectoryProvider --- .../browser/common-frontend-contribution.ts | 9 +++--- .../browser/frontend-application-module.ts | 4 +-- ....ts => user-working-directory-provider.ts} | 25 ++++++---------- .../file-dialog/file-dialog-service.ts | 14 ++------- .../src/browser/filesystem-frontend-module.ts | 2 -- .../monaco/src/browser/monaco-editor-model.ts | 4 +-- .../workspace-file-dialog-root-provider.ts | 30 ------------------- .../src/browser/workspace-frontend-module.ts | 10 ++----- ...kspace-user-working-directory-provider.ts} | 9 +++--- 9 files changed, 27 insertions(+), 80 deletions(-) rename packages/core/src/browser/{untitled-file-location-provider.ts => user-working-directory-provider.ts} (67%) delete mode 100644 packages/workspace/src/browser/workspace-file-dialog-root-provider.ts rename packages/workspace/src/browser/{workspace-untitled-file-location-provider.ts => workspace-user-working-directory-provider.ts} (84%) diff --git a/packages/core/src/browser/common-frontend-contribution.ts b/packages/core/src/browser/common-frontend-contribution.ts index d9e7e33b49c5b..c2791b10ea611 100644 --- a/packages/core/src/browser/common-frontend-contribution.ts +++ b/packages/core/src/browser/common-frontend-contribution.ts @@ -61,7 +61,8 @@ import { FrontendApplicationConfigProvider } from './frontend-application-config import { DecorationStyle } from './decoration-style'; import { isPinned, Title, togglePinned, Widget } from './widgets'; import { SaveResourceService } from './save-resource-service'; -import { UntitledFileLocationProvider } from './untitled-file-location-provider'; +import { UserWorkingDirectoryProvider } from './user-working-directory-provider'; +import { createUntitledURI } from '../common'; export namespace CommonMenus { @@ -395,8 +396,8 @@ export class CommonFrontendContribution implements FrontendApplicationContributi @inject(WindowService) protected readonly windowService: WindowService; - @inject(UntitledFileLocationProvider) - protected readonly untitledFileLocationProvider: UntitledFileLocationProvider; + @inject(UserWorkingDirectoryProvider) + protected readonly workingDirProvider: UserWorkingDirectoryProvider; protected pinnedKey: ContextKey<boolean>; @@ -955,7 +956,7 @@ export class CommonFrontendContribution implements FrontendApplicationContributi execute: () => this.configureDisplayLanguage() }); commandRegistry.registerCommand(CommonCommands.NEW_FILE, { - execute: async () => open(this.openerService, await this.untitledFileLocationProvider.getUntitledFileLocation()) + execute: async () => open(this.openerService, createUntitledURI('', await this.workingDirProvider.getUserWorkingDir())) }); } diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index bcc4694871344..c00b41e8df977 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -121,7 +121,7 @@ import { RendererHost } from './widgets'; import { TooltipService, TooltipServiceImpl } from './tooltip-service'; import { bindFrontendStopwatch, bindBackendStopwatch } from './performance'; import { SaveResourceService } from './save-resource-service'; -import { UntitledFileLocationProvider } from './untitled-file-location-provider'; +import { UserWorkingDirectoryProvider } from './user-working-directory-provider'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -399,5 +399,5 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bindBackendStopwatch(bind); bind(SaveResourceService).toSelf().inSingletonScope(); - bind(UntitledFileLocationProvider).toSelf().inSingletonScope(); + bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/browser/untitled-file-location-provider.ts b/packages/core/src/browser/user-working-directory-provider.ts similarity index 67% rename from packages/core/src/browser/untitled-file-location-provider.ts rename to packages/core/src/browser/user-working-directory-provider.ts index 874a19b13b26e..8f20b76c1d842 100644 --- a/packages/core/src/browser/untitled-file-location-provider.ts +++ b/packages/core/src/browser/user-working-directory-provider.ts @@ -16,31 +16,24 @@ import { inject, injectable } from 'inversify'; import URI from '../common/uri'; -import { createUntitledURI, MaybePromise, SelectionService, UriSelection } from '../common'; +import { MaybePromise, SelectionService, UriSelection } from '../common'; import { EnvVariablesServer } from '../common/env-variables'; -import { NavigatableWidget } from './navigatable-types'; -import { ApplicationShell } from './shell'; @injectable() -export class UntitledFileLocationProvider { - @inject(ApplicationShell) protected readonly shell: ApplicationShell; +export class UserWorkingDirectoryProvider { @inject(SelectionService) protected readonly selectionService: SelectionService; @inject(EnvVariablesServer) protected readonly envVariables: EnvVariablesServer; - async getUntitledFileLocation(extension?: string): Promise<URI> { - return createUntitledURI(extension, await this.getParent()); - } - - protected async getParent(): Promise<URI> { - return await this.getFromCurrentWidget() - ?? await this.getFromSelection() + /** + * @returns A {@link URI} that represents a good guess about the directory in which the user is currently operating. + * + * Factors considered may include the current widget, current selection, user home directory, or other application state. + */ + async getUserWorkingDir(): Promise<URI> { + return await this.getFromSelection() ?? this.getFromUserHome(); } - protected getFromCurrentWidget(): MaybePromise<URI | undefined> { - return this.ensureIsDirectory(NavigatableWidget.getUri(this.shell.currentWidget)); - } - protected getFromSelection(): MaybePromise<URI | undefined> { return this.ensureIsDirectory(UriSelection.getUri(this.selectionService.selection)); } diff --git a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts index ea9ac1249b727..686360348fbae 100644 --- a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts +++ b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts @@ -23,6 +23,7 @@ import { DirNode } from '../file-tree'; import { OpenFileDialogFactory, OpenFileDialogProps, SaveFileDialogFactory, SaveFileDialogProps } from './file-dialog'; import { FileService } from '../file-service'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider'; export const FileDialogService = Symbol('FileDialogService'); export interface FileDialogService { @@ -35,15 +36,6 @@ export interface FileDialogService { } -@injectable() -export class FileDialogDefaultRootProvider { - @inject(EnvVariablesServer) protected readonly environments: EnvVariablesServer; - - async getDefaultDialogRoot(): Promise<URI> { - return new URI(await this.environments.getHomeDirUri()); - } -} - @injectable() export class DefaultFileDialogService implements FileDialogService { @@ -56,7 +48,7 @@ export class DefaultFileDialogService implements FileDialogService { @inject(OpenFileDialogFactory) protected readonly openFileDialogFactory: OpenFileDialogFactory; @inject(LabelProvider) protected readonly labelProvider: LabelProvider; @inject(SaveFileDialogFactory) protected readonly saveFileDialogFactory: SaveFileDialogFactory; - @inject(FileDialogDefaultRootProvider) protected readonly rootProvider: FileDialogDefaultRootProvider; + @inject(UserWorkingDirectoryProvider) protected readonly rootProvider: UserWorkingDirectoryProvider; async showOpenDialog(props: OpenFileDialogProps & { canSelectMany: true }, folder?: FileStat): Promise<MaybeArray<URI> | undefined>; async showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise<URI | undefined>; @@ -91,7 +83,7 @@ export class DefaultFileDialogService implements FileDialogService { protected async getRootNode(folderToOpen?: FileStat): Promise<DirNode | undefined> { const folderExists = folderToOpen && await this.fileService.exists(folderToOpen.resource); const folder = folderToOpen && folderExists ? folderToOpen : { - resource: await this.rootProvider.getDefaultDialogRoot(), + resource: await this.rootProvider.getUserWorkingDir(), isDirectory: true }; const folderUri = folder.resource; diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index 219aff925884a..0d70e4c096fda 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -40,7 +40,6 @@ import { FilepathBreadcrumbsContribution } from './breadcrumbs/filepath-breadcru import { BreadcrumbsFileTreeWidget, createFileTreeBreadcrumbsWidget } from './breadcrumbs/filepath-breadcrumbs-container'; import { FilesystemSaveResourceService } from './filesystem-save-resource-service'; import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; -import { FileDialogDefaultRootProvider } from './file-dialog'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bindFileSystemPreferences(bind); @@ -230,7 +229,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(FilesystemSaveResourceService).toSelf().inSingletonScope(); rebind(SaveResourceService).toService(FilesystemSaveResourceService); - bind(FileDialogDefaultRootProvider).toSelf().inSingletonScope(); }); export function bindFileResource(bind: interfaces.Bind): void { diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index 045c4c1b04a03..8298ac6f1e24e 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -163,9 +163,7 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { const languageSelection = monaco.services.StaticServices.modeService.get().createByFilepathOrFirstLine(uri, firstLine); this.model = monaco.services.StaticServices.modelService.get().createModel(value, languageSelection, uri); this.resourceVersion = this.resource.version; - if (this.resource.uri.scheme === UNTITLED_SCHEME && this.model.getValueLength()) { - this.setDirty(true); - } + this.setDirty(this._dirty || (this.resource.uri.scheme === UNTITLED_SCHEME && this.model.getValueLength() > 0)); this.updateSavedVersionId(); this.toDispose.push(this.model); this.toDispose.push(this.model.onDidChangeContent(event => this.fireDidChangeContent(event))); diff --git a/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts b/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts deleted file mode 100644 index dfd2ecb9c0d16..0000000000000 --- a/packages/workspace/src/browser/workspace-file-dialog-root-provider.ts +++ /dev/null @@ -1,30 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2022 Ericsson 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 { inject, injectable } from '@theia/core/shared/inversify'; -import URI from '@theia/core/lib/common/uri'; -import { FileDialogDefaultRootProvider } from '@theia/filesystem/lib/browser'; -import { WorkspaceService } from './workspace-service'; - -@injectable() -export class WorkspaceFileDialogDefaultRootProvider extends FileDialogDefaultRootProvider { - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; - - override async getDefaultDialogRoot(): Promise<URI> { - const root = this.workspaceService.tryGetRoots()[0]; - return root?.resource ?? super.getDefaultDialogRoot(); - } -} diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 1d191540e14f8..a9f38d99aadd3 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -26,7 +26,6 @@ import { createSaveFileDialogContainer, OpenFileDialog, SaveFileDialog, - FileDialogDefaultRootProvider } from '@theia/filesystem/lib/browser'; import { StorageService } from '@theia/core/lib/browser/storage-service'; import { LabelProviderContribution } from '@theia/core/lib/browser/label-provider'; @@ -51,9 +50,8 @@ import { WorkspaceBreadcrumbsContribution } from './workspace-breadcrumbs-contri import { FilepathBreadcrumbsContribution } from '@theia/filesystem/lib/browser/breadcrumbs/filepath-breadcrumbs-contribution'; import { WorkspaceTrustService } from './workspace-trust-service'; import { bindWorkspaceTrustPreferences } from './workspace-trust-preferences'; -import { WorkspaceFileDialogDefaultRootProvider } from './workspace-file-dialog-root-provider'; -import { UntitledFileLocationProvider } from '@theia/core/lib/browser/untitled-file-location-provider'; -import { WorkspaceUntitledFileLocationProvider } from './workspace-untitled-file-location-provider'; +import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider'; +import { WorkspaceUserWorkingDirectoryProvider } from './workspace-user-working-directory-provider'; export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Unbind, isBound: interfaces.IsBound, rebind: interfaces.Rebind) => { bindWorkspacePreferences(bind); @@ -109,7 +107,5 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un rebind(FilepathBreadcrumbsContribution).to(WorkspaceBreadcrumbsContribution).inSingletonScope(); bind(WorkspaceTrustService).toSelf().inSingletonScope(); - bind(WorkspaceFileDialogDefaultRootProvider).toSelf().inSingletonScope(); - rebind(FileDialogDefaultRootProvider).toSelf().inSingletonScope(); - rebind(UntitledFileLocationProvider).to(WorkspaceUntitledFileLocationProvider).inSingletonScope(); + rebind(UserWorkingDirectoryProvider).to(WorkspaceUserWorkingDirectoryProvider).inSingletonScope(); }); diff --git a/packages/workspace/src/browser/workspace-untitled-file-location-provider.ts b/packages/workspace/src/browser/workspace-user-working-directory-provider.ts similarity index 84% rename from packages/workspace/src/browser/workspace-untitled-file-location-provider.ts rename to packages/workspace/src/browser/workspace-user-working-directory-provider.ts index ad1e3c53f7847..645e8812490d8 100644 --- a/packages/workspace/src/browser/workspace-untitled-file-location-provider.ts +++ b/packages/workspace/src/browser/workspace-user-working-directory-provider.ts @@ -15,20 +15,19 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { UntitledFileLocationProvider } from '@theia/core/lib/browser/untitled-file-location-provider'; +import { UserWorkingDirectoryProvider } from '@theia/core/lib/browser/user-working-directory-provider'; import URI from '@theia/core/lib/common/uri'; import { WorkspaceService } from './workspace-service'; import { MaybePromise } from '@theia/core'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; @injectable() -export class WorkspaceUntitledFileLocationProvider extends UntitledFileLocationProvider { +export class WorkspaceUserWorkingDirectoryProvider extends UserWorkingDirectoryProvider { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(FileService) protected readonly fileService: FileService; - protected override async getParent(): Promise<URI> { - return await this.getFromCurrentWidget() - ?? await this.getFromSelection() + override async getUserWorkingDir(): Promise<URI> { + return await this.getFromSelection() ?? await this.getFromWorkspace() ?? this.getFromUserHome(); } From 68ad38f79cd88110a0998952630b360b081c0451 Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Wed, 23 Mar 2022 10:34:32 -0600 Subject: [PATCH 6/8] breaking --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a90d200b1e902..d5295c5091a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [plugin] added support for `DocumentSymbolProviderMetadata` [#10811](https://github.com/eclipse-theia/theia/pull/10811) - Contributed on behalf of STMicroelectronics - [playwright] fixed playwright tests for Windows and MacOS [#10826](https://github.com/eclipse-theia/theia/pull/10826) - Contributed on behalf of STMicroelectronics +- [core] Move code for untitled resources into `core` from `plugin-ext` and allow users to open untitled editors with `New File` command. [#10868](https://github.com/eclipse-theia/theia/pull/10868) <a name="breaking_changes_1.24.0">[Breaking Changes:](#breaking_changes_1.24.0)</a> @@ -17,6 +18,7 @@ - [debug] the getter `model` was renamed to `getModel` and accepts an optional `URI` parameter [#10875](https://github.com/eclipse-theia/theia/pull/10875) - [filesystem] The `generateUniqueResourceURI` method from the `FileSystemUtils` class has an updated signature. Additionally, the method now returns a generated Uri that uses spaces as separators. The naming scheme was also changed to match VSCode. [10767](https://github.com/eclipse-theia/theia/pull/10767) - [markers] `ProblemDecorator` reimplemented to reduce redundancy and align more closely with VSCode. `collectMarkers` now returns `Map<string, TreeDecoration.Data>`, `getOverlayIconColor` renamed to `getColor`, `getOverlayIcon` removed, `appendContainerMarkers` returns `void` [#10820](https://github.com/eclipse-theia/theia/pull/10820) +- [workspace] removed unused injections in `WorkspaceService`: `ApplicationShell`, `StorageService`, `LabelProvider`, `SelectionService`, `CommandRegistry`, `WorkspaceCommandContribution`. [#10868](https://github.com/eclipse-theia/theia/pull/10868) ## v1.23.0 - 2/24/2022 From 28cbb19d8ce39da7ca682993379d7ef6449bad6d Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Mon, 28 Mar 2022 11:20:20 -0600 Subject: [PATCH 7/8] Handle closing better --- packages/core/src/browser/saveable.ts | 15 +++++++++++---- .../core/src/browser/shell/application-shell.ts | 6 +++++- .../custom-editors/custom-editor-widget.ts | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index 5077ce374e6db..be2d4cbbe495c 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -103,7 +103,10 @@ export namespace Saveable { return waitForClosed(this); } - function createCloseWithSaving(getOtherSaveables?: () => Array<Widget | SaveableWidget>): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise<void> { + function createCloseWithSaving( + getOtherSaveables?: () => Array<Widget | SaveableWidget>, + doSave?: (widget: Widget, options?: SaveOptions) => Promise<void> + ): (this: SaveableWidget, options?: SaveableWidget.CloseOptions) => Promise<void> { let closing = false; return async function (this: SaveableWidget, options: SaveableWidget.CloseOptions): Promise<void> { if (closing) { return; } @@ -123,7 +126,7 @@ export namespace Saveable { }); if (typeof result === 'boolean') { if (result) { - await Saveable.save(this); + await (doSave?.(this) ?? Saveable.save(this)); } await this.closeWithoutSaving(); } @@ -163,7 +166,11 @@ export namespace Saveable { return !!saveable && !others.some(otherWidget => otherWidget !== widget && get(otherWidget) === saveable); } - export function apply(widget: Widget, getOtherSaveables?: () => Array<Widget | SaveableWidget>): SaveableWidget | undefined { + export function apply( + widget: Widget, + getOtherSaveables?: () => Array<Widget | SaveableWidget>, + doSave?: (widget: Widget, options?: SaveOptions) => Promise<void>, + ): SaveableWidget | undefined { if (SaveableWidget.is(widget)) { return widget; } @@ -174,7 +181,7 @@ export namespace Saveable { const saveableWidget = widget as SaveableWidget; setDirty(saveableWidget, saveable.dirty); saveable.onDirtyChanged(() => setDirty(saveableWidget, saveable.dirty)); - const closeWithSaving = createCloseWithSaving(getOtherSaveables); + const closeWithSaving = createCloseWithSaving(getOtherSaveables, doSave); return Object.assign(saveableWidget, { closeWithoutSaving, closeWithSaving, diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 282cbecc26a25..0f1407e2912a5 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1072,7 +1072,11 @@ export class ApplicationShell extends Widget { } this.tracker.add(widget); this.checkActivation(widget); - Saveable.apply(widget, () => this.widgets.filter((maybeSaveable): maybeSaveable is Widget & SaveableSource => !!Saveable.get(maybeSaveable))); + Saveable.apply( + widget, + () => this.widgets.filter((maybeSaveable): maybeSaveable is Widget & SaveableSource => !!Saveable.get(maybeSaveable)), + (toSave, options) => this.saveResourceService.save(toSave, options), + ); if (ApplicationShell.TrackableWidgetProvider.is(widget)) { for (const toTrack of widget.getTrackableWidgets()) { this.track(toTrack); diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts index 80e48e1ffb93a..6b72ea49295ed 100644 --- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-widget.ts @@ -17,7 +17,8 @@ import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { FileOperation } from '@theia/filesystem/lib/common/files'; -import { NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser'; +import { ApplicationShell, NavigatableWidget, Saveable, SaveableSource, SaveOptions } from '@theia/core/lib/browser'; +import { SaveResourceService } from '@theia/core/lib/browser/save-resource-service'; import { Reference } from '@theia/core/lib/common/reference'; import { WebviewWidget } from '../webview/webview'; import { UndoRedoService } from './undo-redo-service'; @@ -37,7 +38,11 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource, set modelRef(modelRef: Reference<CustomEditorModel>) { this._modelRef = modelRef; this.doUpdateContent(); - Saveable.apply(this); + Saveable.apply( + this, + () => this.shell.widgets.filter(widget => !!Saveable.get(widget)), + (widget, options) => this.saveService.save(widget, options), + ); } get saveable(): Saveable { return this._modelRef.object; @@ -46,6 +51,12 @@ export class CustomEditorWidget extends WebviewWidget implements SaveableSource, @inject(UndoRedoService) protected readonly undoRedoService: UndoRedoService; + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + + @inject(SaveResourceService) + protected readonly saveService: SaveResourceService; + @postConstruct() protected override init(): void { super.init(); From 5b9f52aa4a1fd241d2e44312f9368708f80f9671 Mon Sep 17 00:00:00 2001 From: Colin Grant <colin.grant@ericsson.com> Date: Mon, 28 Mar 2022 14:05:19 -0600 Subject: [PATCH 8/8] handle autosave --- packages/core/src/browser/save-resource-service.ts | 2 +- packages/core/src/browser/shell/application-shell.ts | 4 +++- packages/monaco/src/browser/monaco-editor-model.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/browser/save-resource-service.ts b/packages/core/src/browser/save-resource-service.ts index 0c2f92d5c05a1..cbf6871208bfd 100644 --- a/packages/core/src/browser/save-resource-service.ts +++ b/packages/core/src/browser/save-resource-service.ts @@ -31,7 +31,7 @@ export class SaveResourceService { return Saveable.isDirty(widget) && (this.canSaveNotSaveAs(widget) || this.canSaveAs(widget)); } - protected canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { + canSaveNotSaveAs(widget?: Widget): widget is Widget & (Saveable | SaveableSource) { // By default, we never allow a document to be saved if it is untitled. return Boolean(widget && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME); } diff --git a/packages/core/src/browser/shell/application-shell.ts b/packages/core/src/browser/shell/application-shell.ts index 0f1407e2912a5..af80b4316bb4d 100644 --- a/packages/core/src/browser/shell/application-shell.ts +++ b/packages/core/src/browser/shell/application-shell.ts @@ -1859,7 +1859,9 @@ export class ApplicationShell extends Widget { */ async saveAll(options?: SaveOptions): Promise<void> { for (const widget of this.widgets) { - await this.saveResourceService.save(widget, options); + if (this.saveResourceService.canSaveNotSaveAs(widget)) { + await this.saveResourceService.save(widget, options); + } } } diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index d5f319ddfeb55..ce3ddc87f8d87 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -445,7 +445,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo } protected doAutoSave(): void { - if (this.autoSave !== 'off') { + if (this.autoSave !== 'off' && this.resource.uri.scheme !== UNTITLED_SCHEME) { const token = this.cancelSave(); this.toDisposeOnAutoSave.dispose(); const handle = window.setTimeout(() => {