From 52004b4cfedf2d2e7d9aa7fd86cd1ec5e61d27b7 Mon Sep 17 00:00:00 2001 From: Dmitry Zabello Date: Thu, 6 Jul 2017 08:35:48 +0300 Subject: [PATCH] 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 --- src/vs/base/common/comparers.ts | 57 +++++++++++++++- src/vs/base/test/browser/comparers.test.ts | 30 ++++++++- .../parts/files/browser/files.contribution.ts | 15 ++++- .../parts/files/browser/views/explorerView.ts | 48 ++++++++++---- .../files/browser/views/explorerViewer.ts | 65 ++++++++++++++++--- src/vs/workbench/parts/files/common/files.ts | 13 +++- 6 files changed, 202 insertions(+), 26 deletions(-) diff --git a/src/vs/base/common/comparers.ts b/src/vs/base/common/comparers.ts index 8e059b9f18c..5e0a11ca5ce 100644 --- a/src/vs/base/common/comparers.ts +++ b/src/vs/base/common/comparers.ts @@ -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); diff --git a/src/vs/base/test/browser/comparers.test.ts b/src/vs/base/test/browser/comparers.test.ts index e531c6658fd..ebed857ff52 100644 --- a/src/vs/base/test/browser/comparers.test.ts +++ b/src/vs/base/test/browser/comparers.test.ts @@ -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'); + }); }); \ No newline at end of file diff --git a/src/vs/workbench/parts/files/browser/files.contribution.ts b/src/vs/workbench/parts/files/browser/files.contribution.ts index f5a3be2e139..9f1388cfa35 100644 --- a/src/vs/workbench/parts/files/browser/files.contribution.ts +++ b/src/vs/workbench/parts/files/browser/files.contribution.ts @@ -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.") } } }); diff --git a/src/vs/workbench/parts/files/browser/views/explorerView.ts b/src/vs/workbench/parts/files/browser/views/explorerView.ts index c179c6865ee..a3db96fa02a 100644 --- a/src/vs/workbench/parts/files/browser/views/explorerView.ts +++ b/src/vs/workbench/parts/files/browser/views/explorerView.ts @@ -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 } diff --git a/src/vs/workbench/parts/files/browser/views/explorerViewer.ts b/src/vs/workbench/parts/files/browser/views/explorerViewer.ts index 29611199838..70dfa8ecc10 100644 --- a/src/vs/workbench/parts/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/parts/files/browser/views/explorerViewer.ts @@ -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()); + + this.registerListeners(); + } + + private registerListeners(): void { + this.toDispose.push(this.configurationService.onDidUpdateConfiguration(e => this.onConfigurationUpdated(this.configurationService.getConfiguration()))); + } + + 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); + } + } } } diff --git a/src/vs/workbench/parts/files/common/files.ts b/src/vs/workbench/parts/files/common/files.ts index fdf0c517b24..604d8bcab96 100644 --- a/src/vs/workbench/parts/files/common/files.ts +++ b/src/vs/workbench/parts/files/common/files.ts @@ -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; -} \ No newline at end of file +} + +export const SortOrderConfiguration = { + DEFAULT: 'default', + MIXED: 'mixed', + FILES_FIRST: 'filesFirst', + TYPE: 'type', + MODIFIED: 'modified' +}; + +export type SortOrder = 'default' | 'mixed' | 'filesFirst' | 'type' | 'modified'; \ No newline at end of file