Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New File opens untitled editor #10868

Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions packages/core/src/browser/common-frontend-contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +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 { UserWorkingDirectoryProvider } from './user-working-directory-provider';
import { createUntitledURI } from '../common';

export namespace CommonMenus {

Expand Down Expand Up @@ -268,12 +270,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,
Expand Down Expand Up @@ -385,6 +396,9 @@ export class CommonFrontendContribution implements FrontendApplicationContributi
@inject(WindowService)
protected readonly windowService: WindowService;

@inject(UserWorkingDirectoryProvider)
protected readonly workingDirProvider: UserWorkingDirectoryProvider;

protected pinnedKey: ContextKey<boolean>;

async configure(app: FrontendApplication): Promise<void> {
Expand Down Expand Up @@ -697,6 +711,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 {
Expand Down Expand Up @@ -889,10 +908,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 })
});
Expand Down Expand Up @@ -924,6 +955,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, createUntitledURI('', await this.workingDirProvider.getUserWorkingDir()))
});
}

protected isElectron(): boolean {
Expand Down Expand Up @@ -1056,6 +1090,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',
}
);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/browser/decorations-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/browser/frontend-application-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { UserWorkingDirectoryProvider } from './user-working-directory-provider';

export { bindResourceProvider, bindMessageService, bindPreferenceService };

Expand Down Expand Up @@ -398,4 +399,5 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo
bindBackendStopwatch(bind);

bind(SaveResourceService).toSelf().inSingletonScope();
bind(UserWorkingDirectoryProvider).toSelf().inSingletonScope();
});
34 changes: 24 additions & 10 deletions packages/core/src/browser/save-resource-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,46 @@
* 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 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 Saveable.isDirty(saveable) && !Saveable.isUntitled(saveable);
return Boolean(widget && NavigatableWidget.getUri(widget)?.scheme !== UNTITLED_SCHEME);
}

/**
* 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> {
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);
}
}

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.');
}
}
5 changes: 0 additions & 5 deletions packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 9 additions & 9 deletions packages/core/src/browser/shell/application-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -1831,32 +1833,30 @@ 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);
}
}));
for (const widget of this.widgets) {
await this.saveResourceService.save(widget, options);
}
}

/**
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/browser/user-working-directory-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// *****************************************************************************
// 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 { MaybePromise, SelectionService, UriSelection } from '../common';
import { EnvVariablesServer } from '../common/env-variables';

@injectable()
export class UserWorkingDirectoryProvider {
@inject(SelectionService) protected readonly selectionService: SelectionService;
@inject(EnvVariablesServer) protected readonly envVariables: EnvVariablesServer;

/**
* @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 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;
}
}
79 changes: 79 additions & 0 deletions packages/core/src/common/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading