Skip to content

Commit

Permalink
Workspace: Rename files/folder is case sensitive
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
OmarSdt-EC committed Aug 4, 2021
1 parent bc3b5e1 commit 33f5fb1
Show file tree
Hide file tree
Showing 3 changed files with 182 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/filesystem/src/browser/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
152 changes: 152 additions & 0 deletions packages/workspace/src/browser/workspace-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/********************************************************************************
* 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, OS } 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';
import { ApplicationServer } from '@theia/core/lib/common/application-protocol';

disableJSDOM();

describe.only('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(<FileDialogService>{});
container.bind(FileService).toConstantValue(<FileService>{
async exists(resource: URI): Promise<boolean> {
return resource.path.base.includes('bar'); // 'bar' exists for test purposes.
}
});
container.bind(FrontendApplication).toConstantValue(<FrontendApplication>{});
container.bind(LabelProvider).toConstantValue(<LabelProvider>{});
container.bind(MessageService).toConstantValue(<MessageService>{});
container.bind(OpenerService).toConstantValue(<OpenerService>{});
container.bind(SelectionService).toConstantValue(<SelectionService>{});
container.bind(WorkspaceCommandContribution).toSelf().inSingletonScope();
container.bind(WorkspaceCompareHandler).toConstantValue(<WorkspaceCompareHandler>{});
container.bind(WorkspaceDeleteHandler).toConstantValue(<WorkspaceDeleteHandler>{});
container.bind(WorkspaceDuplicateHandler).toConstantValue(<WorkspaceDuplicateHandler>{});
container.bind(WorkspacePreferences).toConstantValue(<WorkspacePreferences>{});
container.bind(WorkspaceService).toConstantValue(<WorkspaceService>{});
container.bind(ApplicationServer).toConstantValue(<ApplicationServer>{
getBackendOS(): Promise<OS.Type> {
return Promise.resolve(OS.type());
}
});

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 message = await commands['validateFileRename'](newValue, parent, initialValue);
expect(message).to.equal('');
});

it('should accept if the resource does not exist case-insensitively', async () => {
const initialValue: string = 'bar';
const newValue = 'foo';
const message = await commands['validateFileRename'](newValue, parent, initialValue);
expect(message).to.equal('');
});

});

});
39 changes: 29 additions & 10 deletions packages/workspace/src/browser/workspace-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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<DidCreateNewResourceEvent>();
private readonly onDidCreateNewFolderEmitter = new Emitter<DidCreateNewResourceEvent>();

protected backendOS: Promise<OS.Type>;

@postConstruct()
async init(): Promise<void> {
this.backendOS = this.applicationServer.getBackendOS();
};

get onDidCreateNewFile(): Event<DidCreateNewResourceEvent> {
return this.onDidCreateNewFileEmitter.event;
}
Expand Down Expand Up @@ -271,22 +280,22 @@ export class WorkspaceCommandContribution implements CommandContribution {
uris.forEach(async uri => {
const parent = await this.getParent(uri);
if (parent) {
const initialValue = uri.path.base;
const oldName = uri.path.base;
const stat = await this.fileService.resolve(uri);
const fileType = stat.isDirectory ? 'Directory' : 'File';
const titleStr = `Rename ${fileType}`;
const dialog = new SingleTextInputDialog({
title: titleStr,
initialValue,
initialValue: oldName,
initialSelectionRange: {
start: 0,
end: uri.path.name.length
},
validate: (name, mode) => {
if (initialValue === name && mode === 'preview') {
validate: async (newName, mode) => {
if (oldName === newName && mode === 'preview') {
return false;
}
return this.validateFileName(name, parent, false);
return this.validateFileRename(newName, parent, oldName);
}
});
const fileName = await dialog.open();
Expand Down Expand Up @@ -368,19 +377,29 @@ export class WorkspaceCommandContribution implements CommandContribution {
return new WorkspaceRootUriAwareCommandHandler(this.workspaceService, this.selectionService, handler);
}

protected async validateFileRename(newName: string, parent: FileStat, oldName: string): Promise<string> {
if (
await this.backendOS === OS.Type.Windows
&& parent.resource.resolve(newName).isEqual(parent.resource.resolve(oldName), false)
) {
return '';
}
return this.validateFileName(newName, parent, false);
}

/**
* Returns an error message if the file name is invalid. Otherwise, an empty string.
*
* @param name the simple file name of the file to validate.
* @param parent the parent directory's file stat.
* @param recursive allow file or folder creation using recursive path
* @param allowNested allow file or folder creation using recursive path
*/
protected async validateFileName(name: string, parent: FileStat, recursive: boolean = false): Promise<string> {
protected async validateFileName(name: string, parent: FileStat, allowNested: boolean = false): Promise<string> {
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('/')) {
Expand Down

0 comments on commit 33f5fb1

Please sign in to comment.