From fb824ecf355cad1742a863fa58d545a0890b939c Mon Sep 17 00:00:00 2001 From: OmarSdt-EC Date: Wed, 7 Jul 2021 05:29:03 -0400 Subject: [PATCH] Workspace: Rename files/folder is case sensitive This commit fixes the rename command for windows users by making possible the case sensitive rename of files and folders. Also, it adds the test file workspace-commands.spec.ts which verifies that the implementation works as expected. --- .../filesystem/src/browser/file-service.ts | 2 +- .../src/browser/workspace-commands.spec.ts | 148 ++++++++++++++++++ .../src/browser/workspace-commands.ts | 31 +++- 3 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/workspace/src/browser/workspace-commands.spec.ts diff --git a/packages/filesystem/src/browser/file-service.ts b/packages/filesystem/src/browser/file-service.ts index fdd76dea7daa4..c202be5aa494d 100644 --- a/packages/filesystem/src/browser/file-service.ts +++ b/packages/filesystem/src/browser/file-service.ts @@ -1120,7 +1120,7 @@ export class FileService { // if target exists get valid target if (exists && !overwrite) { const parent = await this.resolve(target.parent); - const name = target.path.name + '_copy'; + const name = isSameResourceWithDifferentPathCase ? target.path.name : target.path.name + '_copy'; target = FileSystemUtils.generateUniqueResourceURI(target.parent, parent, name, target.path.ext); } diff --git a/packages/workspace/src/browser/workspace-commands.spec.ts b/packages/workspace/src/browser/workspace-commands.spec.ts new file mode 100644 index 0000000000000..19a50a96f6ece --- /dev/null +++ b/packages/workspace/src/browser/workspace-commands.spec.ts @@ -0,0 +1,148 @@ +/******************************************************************************** + * Copyright (C) 2021 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 { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; +let disableJSDOM = enableJSDOM(); + +import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; +import { ApplicationProps } from '@theia/application-package/lib/application-props'; +FrontendApplicationConfigProvider.set({ + ...ApplicationProps.DEFAULT.frontend.config +}); + +import { expect } from 'chai'; +import URI from '@theia/core/lib/common/uri'; +import { Container } from '@theia/core/shared/inversify'; +import { FileDialogService } from '@theia/filesystem/lib/browser'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { FileStat } from '@theia/filesystem/lib/common/files'; +import { LabelProvider, OpenerService, FrontendApplication } from '@theia/core/lib/browser'; +import { MessageService } from '@theia/core/lib/common'; +import { SelectionService } from '@theia/core/lib/common/selection-service'; +import { WorkspaceCommandContribution } from './workspace-commands'; +import { WorkspaceCompareHandler } from './workspace-compare-handler'; +import { WorkspaceDeleteHandler } from './workspace-delete-handler'; +import { WorkspaceDuplicateHandler } from './workspace-duplicate-handler'; +import { WorkspacePreferences } from './workspace-preferences'; +import { WorkspaceService } from './workspace-service'; + +disableJSDOM(); + +describe('workspace-commands', () => { + + let commands: WorkspaceCommandContribution; + + const childStat: FileStat = { + isFile: true, + isDirectory: false, + isSymbolicLink: false, + resource: new URI('foo/bar'), + name: 'bar', + }; + + const parent: FileStat = { + isFile: false, + isDirectory: true, + isSymbolicLink: false, + resource: new URI('foo'), + name: 'foo', + children: [ + childStat + ] + }; + + before(() => disableJSDOM = enableJSDOM()); + after(() => disableJSDOM()); + + beforeEach(() => { + const container = new Container(); + + container.bind(FileDialogService).toConstantValue({}); + container.bind(FileService).toConstantValue({ + async exists(resource: URI): Promise { + return resource.path.base.includes('bar'); // 'bar' exists for test purposes. + } + }); + container.bind(FrontendApplication).toConstantValue({}); + container.bind(LabelProvider).toConstantValue({}); + container.bind(MessageService).toConstantValue({}); + container.bind(OpenerService).toConstantValue({}); + container.bind(SelectionService).toConstantValue({}); + container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope(); + container.bind(WorkspaceCompareHandler).toConstantValue({}); + container.bind(WorkspaceDeleteHandler).toConstantValue({}); + container.bind(WorkspaceDuplicateHandler).toConstantValue({}); + container.bind(WorkspacePreferences).toConstantValue({}); + container.bind(WorkspaceService).toConstantValue({}); + + commands = container.get(WorkspaceCommandContribution); + }); + + describe('#validateFileName', () => { + + it('should not validate an empty file name', async () => { + const message = await commands['validateFileName']('', parent); + expect(message).to.equal(''); + }); + + it('should accept the resource does not exist', async () => { + const message = await commands['validateFileName']('a.ts', parent); + expect(message).to.equal(''); + }); + + it('should not accept if the resource exists', async () => { + const message = await commands['validateFileName']('bar', parent); + expect(message).to.not.equal(''); // a non empty message indicates an error. + }); + + it('should not accept invalid filenames', async () => { + let message = await commands['validateFileName']('.', parent, true); // invalid filename. + expect(message).to.not.equal(''); + + message = await commands['validateFileName']('/a', parent, true); // invalid starts-with `\`. + expect(message).to.not.equal(''); + + message = await commands['validateFileName'](' a', parent, true); // invalid leading whitespace. + expect(message).to.not.equal(''); + + message = await commands['validateFileName']('a ', parent, true); // invalid trailing whitespace. + expect(message).to.not.equal(''); + + }); + + }); + + describe('#validateFileRename', () => { + + it('should accept if the resource exists case-insensitively', async () => { + const initialValue: string = 'bar'; + const newValue = 'Bar'; + const overwrite = parent.resource.resolve(newValue).isEqual(parent.resource.resolve(initialValue), false); + const message = await commands['validateFileRename'](newValue, parent, initialValue, overwrite); + expect(message).to.equal(''); + }); + + it('should accept if the resource does not exist case-insensitively', async () => { + const initialValue: string = 'bar'; + const newValue = 'foo'; + const overwrite = parent.resource.resolve(newValue).isEqual(parent.resource.resolve(initialValue), false); + const message = await commands['validateFileRename'](newValue, parent, initialValue, overwrite); + expect(message).to.equal(''); + }); + + }); + +}); diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts index 334534a75b408..eeaaf0292b83d 100644 --- a/packages/workspace/src/browser/workspace-commands.ts +++ b/packages/workspace/src/browser/workspace-commands.ts @@ -14,9 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from '@theia/core/shared/inversify'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { SelectionService } from '@theia/core/lib/common/selection-service'; +import { ApplicationServer } from '@theia/core/lib/common/application-protocol'; import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; import { MenuContribution, MenuModelRegistry } from '@theia/core/lib/common/menu'; import { CommonMenus } from '@theia/core/lib/browser/common-frontend-contribution'; @@ -34,7 +35,7 @@ import { WorkspaceCompareHandler } from './workspace-compare-handler'; import { FileDownloadCommands } from '@theia/filesystem/lib/browser/download/file-download-command-contribution'; import { FileSystemCommands } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution'; import { WorkspaceInputDialog } from './workspace-input-dialog'; -import { Emitter, Event } from '@theia/core/lib/common'; +import { Emitter, Event, OS } from '@theia/core/lib/common'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileStat } from '@theia/filesystem/lib/common/files'; @@ -196,10 +197,18 @@ export class WorkspaceCommandContribution implements CommandContribution { @inject(WorkspaceDeleteHandler) protected readonly deleteHandler: WorkspaceDeleteHandler; @inject(WorkspaceDuplicateHandler) protected readonly duplicateHandler: WorkspaceDuplicateHandler; @inject(WorkspaceCompareHandler) protected readonly compareHandler: WorkspaceCompareHandler; + @inject(ApplicationServer) protected readonly applicationServer: ApplicationServer; private readonly onDidCreateNewFileEmitter = new Emitter(); private readonly onDidCreateNewFolderEmitter = new Emitter(); + protected _backendOS: Promise; + + @postConstruct() + async onStart(): Promise { + this._backendOS = this.applicationServer.getBackendOS(); + }; + get onDidCreateNewFile(): Event { return this.onDidCreateNewFileEmitter.event; } @@ -282,11 +291,14 @@ export class WorkspaceCommandContribution implements CommandContribution { start: 0, end: uri.path.name.length }, - validate: (name, mode) => { + validate: async (name, mode) => { if (initialValue === name && mode === 'preview') { return false; } - return this.validateFileName(name, parent, false); + const allowOverwrite = await this._backendOS === OS.Type.Windows + && parent.resource.resolve(name).isEqual(parent.resource.resolve(initialValue), false); + + return this.validateFileRename(name, parent, initialValue, allowOverwrite); } }); const fileName = await dialog.open(); @@ -368,6 +380,13 @@ export class WorkspaceCommandContribution implements CommandContribution { return new WorkspaceRootUriAwareCommandHandler(this.workspaceService, this.selectionService, handler); } + protected async validateFileRename(name: string, parent: FileStat, initialValue: string, allowOverwrite: boolean = false): Promise { + if (allowOverwrite) { + return ''; + } + return this.validateFileName(name, parent, false); + } + /** * Returns an error message if the file name is invalid. Otherwise, an empty string. * @@ -375,12 +394,12 @@ export class WorkspaceCommandContribution implements CommandContribution { * @param parent the parent directory's file stat. * @param recursive allow file or folder creation using recursive path */ - protected async validateFileName(name: string, parent: FileStat, recursive: boolean = false): Promise { + protected async validateFileName(name: string, parent: FileStat, allowNested: boolean = false): Promise { if (!name) { return ''; } // do not allow recursive rename - if (!recursive && !validFilename(name)) { + if (!allowNested && !validFilename(name)) { return 'Invalid file or folder name'; } if (name.startsWith('/')) {