import { open } from '@theia/core/lib/browser/opener-service'; import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell'; import { CommandRegistry, CommandService, } from '@theia/core/lib/common/command'; import { nls } from '@theia/core/lib/common/nls'; import { Path } from '@theia/core/lib/common/path'; import { waitForEvent } from '@theia/core/lib/common/promise-util'; import { SelectionService } from '@theia/core/lib/common/selection-service'; import { MaybeArray } from '@theia/core/lib/common/types'; import URI from '@theia/core/lib/common/uri'; import { UriAwareCommandHandler, UriCommandHandler, } from '@theia/core/lib/common/uri-command-handler'; import { inject, injectable } from '@theia/core/shared/inversify'; import { EditorWidget } from '@theia/editor/lib/browser/editor-widget'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { WorkspaceCommandContribution as TheiaWorkspaceCommandContribution, WorkspaceCommands, } from '@theia/workspace/lib/browser/workspace-commands'; import { Sketch } from '../../../common/protocol'; import { ConfigServiceClient } from '../../config/config-service-client'; import { CreateFeatures } from '../../create/create-features'; import { CurrentSketch, SketchesServiceClientImpl, } from '../../sketches-service-client-impl'; import { WorkspaceInputDialog } from './workspace-input-dialog'; interface ValidationContext { sketch: Sketch; isCloud: boolean | undefined; } @injectable() export class WorkspaceCommandContribution extends TheiaWorkspaceCommandContribution { @inject(CommandService) private readonly commandService: CommandService; @inject(SketchesServiceClientImpl) private readonly sketchesServiceClient: SketchesServiceClientImpl; @inject(CreateFeatures) private readonly createFeatures: CreateFeatures; @inject(ApplicationShell) private readonly shell: ApplicationShell; @inject(ConfigServiceClient) private readonly configServiceClient: ConfigServiceClient; private _validationContext: ValidationContext | undefined; override registerCommands(registry: CommandRegistry): void { super.registerCommands(registry); registry.unregisterCommand(WorkspaceCommands.NEW_FILE); registry.registerCommand( WorkspaceCommands.NEW_FILE, this.newWorkspaceRootUriAwareCommandHandler({ execute: (uri) => this.newFile(uri), }) ); registry.unregisterCommand(WorkspaceCommands.FILE_RENAME); registry.registerCommand( WorkspaceCommands.FILE_RENAME, this.newUriAwareCommandHandler({ execute: (uri) => this.renameFile(uri), }) ); registry.unregisterCommand(WorkspaceCommands.FILE_DELETE); registry.registerCommand( WorkspaceCommands.FILE_DELETE, this.newMultiUriAwareCommandHandler(this.deleteHandler) ); } private async newFile(uri: URI | undefined): Promise { if (!uri) { return; } const parent = await this.getDirectory(uri); if (!parent) { return; } const parentUri = parent.resource; const dialog = new WorkspaceInputDialog( { title: nls.localize('theia/workspace/fileNewName', 'Name for new file'), parentUri, validate: (name) => this.validateFileName(name, parent, true), }, this.labelProvider ); const name = await this.openDialog(dialog, parentUri); if (!name) { return; } const nameWithExt = this.maybeAppendInoExt(name); const fileUri = parentUri.resolve(nameWithExt); await this.fileService.createFile(fileUri); this.fireCreateNewFile({ parent: parentUri, uri: fileUri }); open(this.openerService, fileUri); } protected override async validateFileName( userInput: string, parent: FileStat, recursive = false ): Promise { // If name does not have extension or ends with trailing dot (from IDE 1.x), treat it as an .ino file. // If has extension, // - if unsupported extension -> error // - if has a code file extension -> apply folder name validation without the extension and use the Theia-based validation // - if has any additional file extension -> use the default Theia-based validation const fileInput = parseFileInput(userInput); const { name, extension } = fileInput; if (!Sketch.Extensions.ALL.includes(extension)) { return invalidExtension(extension); } let errorMessage: string | undefined = undefined; if (Sketch.Extensions.CODE_FILES.includes(extension)) { errorMessage = this._validationContext?.isCloud ? Sketch.validateCloudSketchFolderName(name) : Sketch.validateSketchFolderName(name); } if (errorMessage) { return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput); } errorMessage = await super.validateFileName(userInput, parent, recursive); // run the default Theia validation with the raw input. if (errorMessage) { return this.maybeRemapAlreadyExistsMessage(errorMessage, userInput); } // It's a legacy behavior from IDE 1.x. Validate the file as if it were an `.ino` file. // If user did not write the `.ino` extension or ended the user input with dot, run the default Theia validation with the inferred name. if (extension === '.ino' && !userInput.endsWith('.ino')) { userInput = `${name}${extension}`; errorMessage = await super.validateFileName(userInput, parent, recursive); } return this.maybeRemapAlreadyExistsMessage(errorMessage ?? '', userInput); } // Remaps the Theia-based `A file or folder **$fileName** already exists at this location. Please choose a different name.` to a custom one. private maybeRemapAlreadyExistsMessage( errorMessage: string, userInput: string ): string { if ( errorMessage === nls.localizeByDefault( 'A file or folder **{0}** already exists at this location. Please choose a different name.', this['trimFileName'](userInput) ) ) { return fileAlreadyExists(userInput); } return errorMessage; } private maybeAppendInoExt(name: string): string { if (!name) { return ''; } if (name.trim().length) { if (name.indexOf('.') === -1) { return `${name}.ino`; } if (name.lastIndexOf('.') === name.length - 1) { return `${name.slice(0, -1)}.ino`; } } return name; } protected async renameFile(uri: URI | undefined): Promise { if (!uri) { return; } const sketch = await this.sketchesServiceClient.currentSketch(); if (!CurrentSketch.isValid(sketch)) { return; } // file belongs to another sketch, do not allow rename if (!Sketch.isInSketch(uri, sketch)) { return; } if (uri.toString() === sketch.mainFileUri) { const options = { execOnlyIfTemp: false, openAfterMove: true, wipeOriginal: true, }; return await this.commandService.executeCommand( 'arduino-save-as-sketch', options ); } const parent = await this.getParent(uri); if (!parent) { return; } const initialValue = uri.path.base; const parentUri = parent.resource; const dialog = new WorkspaceInputDialog( { title: nls.localize('theia/workspace/newFileName', 'New name for file'), initialValue, parentUri, initialSelectionRange: { start: 0, end: uri.path.name.length, }, validate: (name, mode) => { if (initialValue === name && mode === 'preview') { return false; } return this.validateFileName(name, parent, false); }, }, this.labelProvider ); const name = await this.openDialog(dialog, uri); if (!name) { return; } const nameWithExt = this.maybeAppendInoExt(name); const oldUri = uri; const newUri = uri.parent.resolve(nameWithExt); return this.fileService.move(oldUri, newUri); } protected override newUriAwareCommandHandler( handler: UriCommandHandler ): UriAwareCommandHandler { return this.createUriAwareCommandHandler(handler); } protected override newMultiUriAwareCommandHandler( handler: UriCommandHandler ): UriAwareCommandHandler { return this.createUriAwareCommandHandler(handler, true); } private createUriAwareCommandHandler>( delegate: UriCommandHandler, multi = false ): UriAwareCommandHandler { return new UriAwareCommandHandlerWithCurrentEditorFallback( delegate, this.selectionService, this.shell, this.sketchesServiceClient, this.configServiceClient, this.createFeatures, { multi } ); } private async openDialog( dialog: WorkspaceInputDialog, uri: URI ): Promise { try { let dataDirUri = this.configServiceClient.tryGetDataDirUri(); if (!dataDirUri) { dataDirUri = await waitForEvent( this.configServiceClient.onDidChangeDataDirUri, 2_000 ); } this.acquireValidationContext(uri, dataDirUri); const name = await dialog.open(true); return name; } finally { this._validationContext = undefined; } } private acquireValidationContext( uri: URI, dataDirUri: URI | undefined ): void { const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); if ( CurrentSketch.isValid(sketch) && new URI(sketch.uri).isEqualOrParent(uri) ) { const isCloud = this.createFeatures.isCloud(sketch, dataDirUri); this._validationContext = { sketch, isCloud }; } } } // (non-API) exported for tests export function fileAlreadyExists(userInput: string): string { return nls.localize( 'arduino/workspace/alreadyExists', "'{0}' already exists.", userInput ); } // (non-API) exported for tests export function invalidExtension(extension: string): string { return nls.localize( 'theia/workspace/invalidExtension', '.{0} is not a valid extension', extension.charAt(0) === '.' ? extension.slice(1) : extension ); } interface FileInput { /** * The raw text the user enters in the ``. */ readonly raw: string; /** * This is the name without the extension. If raw is `'lib.cpp'`, then `name` will be `'lib'`. If raw is `'foo'` or `'foo.'` this value is `'foo'`. */ readonly name: string; /** * With the leading dot. For example `'.ino'` or `'.cpp'`. */ readonly extension: string; } export function parseFileInput(userInput: string): FileInput { if (!userInput) { return { raw: '', name: '', extension: Sketch.Extensions.DEFAULT, }; } const path = new Path(userInput); let extension = path.ext; if (extension.trim() === '' || extension.trim() === '.') { extension = Sketch.Extensions.DEFAULT; } return { raw: userInput, name: path.name, extension, }; } /** * By default, the Theia-based URI-aware command handler tries to retrieve the URI from the selection service. * Delete/Rename from the tab-bar toolbar (`...`) is not active if the selection was never inside an editor. * This implementation falls back to the current current title of the main panel if no URI can be retrieved from the parent classes. * - https://github.com/arduino/arduino-ide/issues/1847 * - https://github.com/eclipse-theia/theia/issues/12139 */ class UriAwareCommandHandlerWithCurrentEditorFallback< T extends MaybeArray > extends UriAwareCommandHandler { constructor( delegate: UriCommandHandler, selectionService: SelectionService, private readonly shell: ApplicationShell, private readonly sketchesServiceClient: SketchesServiceClientImpl, private readonly configServiceClient: ConfigServiceClient, private readonly createFeatures: CreateFeatures, options?: UriAwareCommandHandler.Options ) { super(selectionService, delegate, options); } // eslint-disable-next-line @typescript-eslint/no-explicit-any protected override getUri(...args: any[]): T | undefined { const uri = super.getUri(...args); if (!uri || (Array.isArray(uri) && !uri.length)) { const fallbackUri = this.currentTitleOwnerUriFromMainPanel; if (fallbackUri) { return (this.isMulti() ? [fallbackUri] : fallbackUri) as T; } } return uri; } // eslint-disable-next-line @typescript-eslint/no-explicit-any override isEnabled(...args: any[]): boolean { const [uri, ...others] = this.getArgsWithUri(...args); if (uri) { if (!this.isInSketch(uri)) { return false; } if (this.affectsCloudSketchFolderWhenSignedOut(uri)) { return false; } if (this.handler.isEnabled) { return this.handler.isEnabled(uri, ...others); } return true; } return false; } // The `currentEditor` is broken after a rename. (https://github.com/eclipse-theia/theia/issues/12139) // `ApplicationShell#currentWidget` might provide a wrong result just as the `getFocusedCodeEditor` and `getFocusedCodeEditor` of the `MonacoEditorService` // Try to extract the URI from the current title of the main panel if it's an editor widget. private get currentTitleOwnerUriFromMainPanel(): URI | undefined { const owner = this.shell.mainPanel.currentTitle?.owner; return owner instanceof EditorWidget ? owner.editor.getResourceUri() : undefined; } private isInSketch(uri: T | undefined): boolean { if (!uri) { return false; } const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); if (!CurrentSketch.isValid(sketch)) { return false; } if (this.isMulti() && Array.isArray(uri)) { return uri.every((u) => Sketch.isInSketch(u, sketch)); } if (!this.isMulti() && uri instanceof URI) { return Sketch.isInSketch(uri, sketch); } return false; } /** * If the user is not logged in, deleting/renaming the main sketch file or the sketch folder of a cloud sketch is disabled. */ private affectsCloudSketchFolderWhenSignedOut(uri: T | undefined): boolean { return ( !Boolean(this.createFeatures.session) && Boolean(this.isCurrentSketchCloud()) && this.affectsSketchFolder(uri) ); } private affectsSketchFolder(uri: T | undefined): boolean { if (!uri) { return false; } const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); if (!CurrentSketch.isValid(sketch)) { return false; } if (this.isMulti() && Array.isArray(uri)) { return uri.map((u) => u.toString()).includes(sketch.mainFileUri); } if (!this.isMulti()) { return sketch.mainFileUri === uri.toString(); } return false; } private isCurrentSketchCloud(): boolean | undefined { const sketch = this.sketchesServiceClient.tryGetCurrentSketch(); if (!CurrentSketch.isValid(sketch)) { return false; } const dataDirUri = this.configServiceClient.tryGetDataDirUri(); return this.createFeatures.isCloud(sketch, dataDirUri); } }