Various kinds of sorting in explorer (#29509)

* Make listing directories first an option #29329

* Added refresh on `dirsFirst` config value changed

* Switch sorting configuration to enum

* Added `filesFirst` sorting

* Added sorting by type

* Got rid of the `path` dependency

* Added sorting by last modified date

* Merge conflict fix

* `modified` sorting order changed to descending

* New compareFileExtensions comparer added for TypeSorter

* Switched to a single self-contained explorer sorter

* Improved description of 'explorer.sortOrder' config option
This commit is contained in:
Dmitry Zabello
2017-07-06 08:35:48 +03:00
committed by Benjamin Pasero
parent ac8f47b193
commit 52004b4cfe
6 changed files with 202 additions and 26 deletions

View File

@@ -34,7 +34,7 @@ export function compareFileNames(one: string, other: string): number {
return noIntlCompareFileNames(one, other);
}
const FileNameMatch = /^([^.]*)(\.(.*))?$/;
const FileNameMatch = /^(.*?)(\.([^.]*))?$/;
export function noIntlCompareFileNames(one: string, other: string): number {
let oneMatch = FileNameMatch.exec(one.toLowerCase());
@@ -57,6 +57,61 @@ export function noIntlCompareFileNames(one: string, other: string): number {
return oneExtension < otherExtension ? -1 : 1;
}
export function compareFileExtensions(one: string, other: string): number {
if (intlFileNameCollator) {
const oneMatch = one ? FileNameMatch.exec(one) : [];
const otherMatch = other ? FileNameMatch.exec(other) : [];
const oneName = oneMatch[1] || '';
const oneExtension = oneMatch[3] || '';
const otherName = otherMatch[1] || '';
const otherExtension = otherMatch[3] || '';
let result = intlFileNameCollator.compare(oneExtension, otherExtension);
if (result === 0) {
// Using the numeric option in the collator will
// make compare(`foo1`, `foo01`) === 0. We must disambiguate.
if (intlFileNameCollatorIsNumeric && oneExtension !== otherExtension) {
return oneExtension < otherExtension ? -1 : 1;
}
// Extensions are equal, compare filenames
result = intlFileNameCollator.compare(oneName, otherName);
if (intlFileNameCollatorIsNumeric && result === 0 && oneName !== otherName) {
return oneName < otherName ? -1 : 1;
}
}
return result;
}
return noIntlCompareFileExtensions(one, other);
}
function noIntlCompareFileExtensions(one: string, other: string): number {
const oneMatch = one ? FileNameMatch.exec(one.toLowerCase()) : [];
const otherMatch = other ? FileNameMatch.exec(other.toLowerCase()) : [];
const oneName = oneMatch[1] || '';
const oneExtension = oneMatch[3] || '';
const otherName = otherMatch[1] || '';
const otherExtension = otherMatch[3] || '';
if (oneExtension !== otherExtension) {
return oneExtension < otherExtension ? -1 : 1;
}
if (oneName === otherName) {
return 0;
}
return oneName < otherName ? -1 : 1;
}
export function comparePaths(one: string, other: string): number {
const oneParts = one.split(paths.nativeSep);
const otherParts = other.split(paths.nativeSep);

View File

@@ -5,7 +5,7 @@
'use strict';
import { compareFileNames, setFileNameComparer } from 'vs/base/common/comparers';
import { compareFileNames, compareFileExtensions, setFileNameComparer } from 'vs/base/common/comparers';
import * as assert from 'assert';
suite('Comparers', () => {
@@ -27,4 +27,32 @@ suite('Comparers', () => {
assert(compareFileNames('abc1.txt', 'abc2.txt') < 0, 'filenames with numbers should be in numerical order, not alphabetical order');
assert(compareFileNames('abc2.txt', 'abc10.txt') < 0, 'filenames with numbers should be in numerical order even when they are multiple digits long');
});
test('compareFileExtensions', () => {
// Setup Intl
setFileNameComparer(new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }));
assert(compareFileExtensions(null, null) === 0, 'null should be equal');
assert(compareFileExtensions(null, '.abc') < 0, 'null should come before real files');
assert(compareFileExtensions(null, 'abc') < 0, 'null should come before real files without extension');
assert(compareFileExtensions('', '') === 0, 'empty should be equal');
assert(compareFileExtensions('abc', 'abc') === 0, 'equal names should be equal');
assert(compareFileExtensions('.abc', '.abc') === 0, 'equal full names should be equal');
assert(compareFileExtensions('file.ext', 'file.ext') === 0, 'equal full names should be equal');
assert(compareFileExtensions('a.ext', 'b.ext') < 0, 'if equal extensions, filenames should be compared');
assert(compareFileExtensions('.ext', 'a.ext') < 0, 'if equal extensions, filenames should be compared, empty filename should come before others');
assert(compareFileExtensions('file.aaa', 'file.bbb') < 0, 'files should be compared by extensions');
assert(compareFileExtensions('bbb.aaa', 'aaa.bbb') < 0, 'files should be compared by extensions even if filenames compare differently');
assert(compareFileExtensions('1', '1') === 0, 'numerically equal full names should be equal');
assert(compareFileExtensions('abc1.txt', 'abc1.txt') === 0, 'equal filenames with numbers should be equal');
assert(compareFileExtensions('abc1.txt', 'abc2.txt') < 0, 'filenames with numbers should be in numerical order, not alphabetical order');
assert(compareFileExtensions('abc2.txt', 'abc10.txt') < 0, 'filenames with numbers should be in numerical order even when they are multiple digits long');
assert(compareFileExtensions('txt.abc1', 'txt.abc1') === 0, 'equal extensions with numbers should be equal');
assert(compareFileExtensions('txt.abc1', 'txt.abc2') < 0, 'extensions with numbers should be in numerical order, not alphabetical order');
assert(compareFileExtensions('txt.abc2', 'txt.abc10') < 0, 'extensions with numbers should be in numerical order even when they are multiple digits long');
assert(compareFileExtensions('a.ext1', 'b.ext1') < 0, 'if equal extensions with numbers, filenames should be compared');
assert(compareFileExtensions('file2.ext2', 'file1.ext10') < 0, 'extensions with numbers should be in numerical order, not alphabetical order');
assert(compareFileExtensions('file.ext01', 'file.ext1') < 0, 'extensions with equal numbers should be in alphabetical order');
});
});

View File

@@ -16,7 +16,7 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } fr
import { IEditorRegistry, Extensions as EditorExtensions, IEditorInputFactory, EditorInput, IFileEditorInput } from 'vs/workbench/common/editor';
import { AutoSaveConfiguration, HotExitConfiguration, SUPPORTED_ENCODINGS } from 'vs/platform/files/common/files';
import { EditorDescriptor } from 'vs/workbench/browser/parts/editor/baseEditor';
import { FILE_EDITOR_INPUT_ID, VIEWLET_ID } from 'vs/workbench/parts/files/common/files';
import { FILE_EDITOR_INPUT_ID, VIEWLET_ID, SortOrderConfiguration } from 'vs/workbench/parts/files/common/files';
import { FileEditorTracker } from 'vs/workbench/parts/files/common/editors/fileEditorTracker';
import { SaveErrorHandler } from 'vs/workbench/parts/files/browser/saveErrorHandler';
import { FileEditorInput } from 'vs/workbench/parts/files/common/editors/fileEditorInput';
@@ -321,6 +321,19 @@ configurationRegistry.registerConfiguration({
'type': 'boolean',
'description': nls.localize('enableDragAndDrop', "Controls if the explorer should allow to move files and folders via drag and drop."),
'default': true
},
'explorer.sortOrder': {
'type': 'string',
'enum': [SortOrderConfiguration.DEFAULT, SortOrderConfiguration.MIXED, SortOrderConfiguration.FILES_FIRST, SortOrderConfiguration.TYPE, SortOrderConfiguration.MODIFIED],
'default': SortOrderConfiguration.DEFAULT,
'enumDescriptions': [
nls.localize('sortOrder.default', 'Files and directories are sorted by their names, in alphabetical order. Directories are displayed before files.'),
nls.localize('sortOrder.mixed', 'Files and directories are sorted by their names, in alphabetical order. Files are interwoven with directories.'),
nls.localize('sortOrder.filesFirst', 'Files and directories are sorted by their names, in alphabetical order. Files are displayed before directories.'),
nls.localize('sortOrder.type', 'Files and directories are sorted by their extensions, in alphabetical order. Directories are displayed before files.'),
nls.localize('sortOrder.modified', 'Files and directories are sorted by last modified date, in descending order. Directories are displayed before files.')
],
'description': nls.localize('sortOrder', "Controls the way of sorting files and directories in the explorer.")
}
}
});

View File

@@ -18,7 +18,7 @@ import { prepareActions } from 'vs/workbench/browser/actions';
import { memoize } from 'vs/base/common/decorators';
import { ITree } from 'vs/base/parts/tree/browser/tree';
import { Tree } from 'vs/base/parts/tree/browser/treeImpl';
import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocussedContext, ExplorerFocussedContext } from 'vs/workbench/parts/files/common/files';
import { IFilesConfiguration, ExplorerFolderContext, FilesExplorerFocussedContext, ExplorerFocussedContext, SortOrderConfiguration, SortOrder } from 'vs/workbench/parts/files/common/files';
import { FileOperation, FileOperationEvent, IResolveFileOptions, FileChangeType, FileChangesEvent, IFileService } from 'vs/platform/files/common/files';
import { RefreshViewExplorerAction, NewFolderAction, NewFileAction } from 'vs/workbench/parts/files/browser/fileActions';
import { FileDragAndDrop, FileFilter, FileSorter, FileController, FileRenderer, FileDataSource, FileViewletState, FileAccessibilityProvider } from 'vs/workbench/parts/files/browser/views/explorerViewer';
@@ -82,6 +82,8 @@ export class ExplorerView extends CollapsibleView {
private autoReveal: boolean;
private sortOrder: SortOrder;
private settings: any;
constructor(
@@ -261,6 +263,12 @@ export class ExplorerView extends CollapsibleView {
needsRefresh = this.filter.updateConfiguration();
}
const configSortOrder = configuration && configuration.explorer && configuration.explorer.sortOrder || 'default';
if (this.sortOrder !== configSortOrder) {
this.sortOrder = configSortOrder;
needsRefresh = true;
}
// Refresh viewer as needed
if (refresh && needsRefresh) {
this.doRefresh().done(null, errors.onUnexpectedError);
@@ -375,7 +383,7 @@ export class ExplorerView extends CollapsibleView {
const dataSource = this.instantiationService.createInstance(FileDataSource);
const renderer = this.instantiationService.createInstance(FileRenderer, this.viewletState);
const controller = this.instantiationService.createInstance(FileController, this.viewletState);
const sorter = new FileSorter();
const sorter = this.instantiationService.createInstance(FileSorter);
this.filter = this.instantiationService.createInstance(FileFilter);
const dnd = this.instantiationService.createInstance(FileDragAndDrop);
const accessibilityProvider = this.instantiationService.createInstance(FileAccessibilityProvider);
@@ -580,14 +588,12 @@ export class ExplorerView extends CollapsibleView {
// Filter to the ones we care
e = this.filterFileEvents(e);
// We only ever refresh from files/folders that got added or deleted
if (e.gotAdded() || e.gotDeleted()) {
const added = e.getAdded();
const deleted = e.getDeleted();
if (!this.isCreated) {
return false;
}
if (!this.isCreated) {
return false;
}
if (e.gotAdded()) {
const added = e.getAdded();
// Check added: Refresh if added file/folder is not part of resolved root and parent is part of it
const ignoredPaths: { [fsPath: string]: boolean } = <{ [fsPath: string]: boolean }>{};
@@ -616,6 +622,10 @@ export class ExplorerView extends CollapsibleView {
ignoredPaths[parent] = true;
}
}
}
if (e.gotDeleted()) {
const deleted = e.getDeleted();
// Check deleted: Refresh if deleted file/folder part of resolved root
for (let j = 0; j < deleted.length; j++) {
@@ -630,15 +640,27 @@ export class ExplorerView extends CollapsibleView {
}
}
if (this.sortOrder === SortOrderConfiguration.MODIFIED && e.gotUpdated()) {
const updated = e.getUpdated();
// Check updated: Refresh if updated file/folder part of resolved root
for (let j = 0; j < updated.length; j++) {
const upd = updated[j];
if (!this.contextService.isInsideWorkspace(upd.resource)) {
continue; // out of workspace file
}
if (this.model.findClosest(upd.resource)) {
return true;
}
}
}
return false;
}
private filterFileEvents(e: FileChangesEvent): FileChangesEvent {
return new FileChangesEvent(e.changes.filter(change => {
if (change.type === FileChangeType.UPDATED) {
return false; // we only want added / removed
}
if (!this.contextService.isInsideWorkspace(change.resource)) {
return false; // exclude changes for resources outside of workspace
}

View File

@@ -23,7 +23,7 @@ import glob = require('vs/base/common/glob');
import { FileLabel, IFileLabelOptions } from 'vs/workbench/browser/labels';
import { IDisposable } from 'vs/base/common/lifecycle';
import { ContributableActionProvider } from 'vs/workbench/browser/actions';
import { IFilesConfiguration } from 'vs/workbench/parts/files/common/files';
import { IFilesConfiguration, SortOrder } from 'vs/workbench/parts/files/common/files';
import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles';
import { FileOperationError, FileOperationResult, IFileService } from 'vs/platform/files/common/files';
import { ResourceMap } from 'vs/base/common/map';
@@ -526,20 +526,53 @@ export class FileController extends DefaultController {
// Explorer Sorter
export class FileSorter implements ISorter {
private toDispose: IDisposable[];
private sortOrder: SortOrder;
constructor(
@IConfigurationService private configurationService: IConfigurationService
) {
this.toDispose = [];
this.onConfigurationUpdated(configurationService.getConfiguration<IFilesConfiguration>());
this.registerListeners();
}
private registerListeners(): void {
this.toDispose.push(this.configurationService.onDidUpdateConfiguration(e => this.onConfigurationUpdated(this.configurationService.getConfiguration<IFilesConfiguration>())));
}
private onConfigurationUpdated(configuration: IFilesConfiguration): void {
this.sortOrder = configuration && configuration.explorer && configuration.explorer.sortOrder || 'default';
}
public compare(tree: ITree, statA: FileStat, statB: FileStat): number {
if (statA.isDirectory && !statB.isDirectory) {
return -1;
}
switch (this.sortOrder) {
case 'default':
case 'type':
case 'modified':
if (statA.isDirectory && !statB.isDirectory) {
return -1;
}
if (statB.isDirectory && !statA.isDirectory) {
return 1;
}
break;
if (statB.isDirectory && !statA.isDirectory) {
return 1;
case 'filesFirst':
if (statA.isDirectory && !statB.isDirectory) {
return 1;
}
if (statB.isDirectory && !statA.isDirectory) {
return -1;
}
break;
}
if (statA instanceof NewStatPlaceholder) {
return -1;
}
if (statB instanceof NewStatPlaceholder) {
return 1;
}
@@ -548,12 +581,26 @@ export class FileSorter implements ISorter {
if (statA.isRoot) {
return -1;
}
if (statB.isRoot) {
return 1;
}
return comparers.compareFileNames(statA.name, statB.name);
switch (this.sortOrder) {
case 'default':
case 'mixed':
case 'filesFirst':
return comparers.compareFileNames(statA.name, statB.name);
case 'type':
return comparers.compareFileExtensions(statA.name, statB.name);
case 'modified':
if (statA.mtime !== statB.mtime) {
return statA.mtime < statB.mtime ? 1 : -1;
} else {
return comparers.compareFileNames(statA.name, statB.name);
}
}
}
}

View File

@@ -60,6 +60,7 @@ export interface IFilesConfiguration extends IFilesConfiguration, IWorkbenchEdit
};
autoReveal: boolean;
enableDragAndDrop: boolean;
sortOrder: SortOrder;
};
editor: IEditorOptions;
}
@@ -93,4 +94,14 @@ export function explorerItemToFileResource(obj: FileStat | OpenEditor): IFileRes
}
return null;
}
}
export const SortOrderConfiguration = {
DEFAULT: 'default',
MIXED: 'mixed',
FILES_FIRST: 'filesFirst',
TYPE: 'type',
MODIFIED: 'modified'
};
export type SortOrder = 'default' | 'mixed' | 'filesFirst' | 'type' | 'modified';