fix(file-explorer): sort numbered file names naturally

The File Explorer compared names with bare localeCompare, so numbered
files listed 100, 200 before 99. Hoist the numeric collator Source
Control file rows already use (#10850) into src/shared and apply it to
the local and runtime directory listings, the name-filtered view, and
Source Control directory nodes, which were inconsistent with the file
rows one line below (#11426).
This commit is contained in:
Brennan Benson
2026-07-30 07:02:08 -07:00
parent ab665a3ce7
commit 81a11cded1
7 changed files with 58 additions and 7 deletions
+2 -1
View File
@@ -30,6 +30,7 @@ import type {
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { compareFileNames } from '../../shared/file-name-sort'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import {
buildRgArgs,
@@ -531,7 +532,7 @@ export function registerFilesystemHandlers(
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
return compareFileNames(a.name, b.name)
})
} catch (error: unknown) {
recordCrashBreadcrumb(
+2 -1
View File
@@ -38,6 +38,7 @@ import {
resolveRuntimePath
} from '../../shared/cross-platform-path'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { compareFileNames } from '../../shared/file-name-sort'
import type {
RuntimeFileListResult,
RuntimeFileOpenResult,
@@ -1277,7 +1278,7 @@ export class RuntimeFileCommands {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
return compareFileNames(a.name, b.name)
})
}
@@ -1,5 +1,6 @@
import { joinPath, normalizeRelativePath } from '@/lib/path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { compareFileNames } from '../../../../shared/file-name-sort'
import type { FileExplorerOperationOwner, TreeNode } from './file-explorer-types'
import {
createFileExplorerRowProjectionFromParts,
@@ -232,7 +233,7 @@ function appendNameFilteredEntries(
if (a.node.isDirectory !== b.node.isDirectory) {
return a.node.isDirectory ? -1 : 1
}
return a.node.name.localeCompare(b.node.name)
return compareFileNames(a.node.name, b.node.name)
})
for (const entry of sortedEntries) {
visibleFlatRows.push(entry.node)
@@ -1,8 +1,7 @@
import type { GitStatusEntry } from '../../../../shared/types'
import { fileNameCollator } from '../../../../shared/file-name-sort'
// Why hoisted: localeCompare with an options object resolves a fresh ICU collator
// on every comparison, so a changed-file sort paid for one per O(n log n) step.
export const sourceControlPathCollator = new Intl.Collator(undefined, { numeric: true })
export const sourceControlPathCollator = fileNameCollator
export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number {
return (
@@ -1,5 +1,6 @@
import { normalizeRelativePath } from '@/lib/path'
import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types'
import { compareFileNames } from '../../../../shared/file-name-sort'
import { splitPathSegments } from './path-tree'
import { compareGitStatusEntries, sourceControlPathCollator } from './source-control-status-sort'
@@ -90,7 +91,9 @@ function finalizeDirectoryNode<Entry extends SourceControlTreeEntry, Area extend
}
}
directories.sort((a, b) => a.name.localeCompare(b.name))
// Why: keep directory ordering consistent with the numeric path collator used
// for the file rows one line below.
directories.sort((a, b) => compareFileNames(a.name, b.name))
files.sort((a, b) => compareEntries(a.entry, b.entry))
const fileCount =
files.length + directories.reduce((count, directory) => count + directory.fileCount, 0)
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { compareFileNames } from './file-name-sort'
describe('compareFileNames', () => {
it('sorts numeric name segments naturally instead of lexicographically', () => {
const names = [
'1 - item.txt',
'100 - item.txt',
'2 - item.txt',
'200 - item.txt',
'409 - item.txt',
'41 - item.txt',
'410 - item.txt',
'9 - item.txt',
'99 - item.txt'
]
expect([...names].sort(compareFileNames)).toEqual([
'1 - item.txt',
'2 - item.txt',
'9 - item.txt',
'41 - item.txt',
'99 - item.txt',
'100 - item.txt',
'200 - item.txt',
'409 - item.txt',
'410 - item.txt'
])
})
it('sorts embedded numbers naturally within alphabetical order', () => {
expect(['b1.md', 'a10.md', 'a2.md', 'a1.md'].sort(compareFileNames)).toEqual([
'a1.md',
'a2.md',
'a10.md',
'b1.md'
])
})
})
+8
View File
@@ -0,0 +1,8 @@
// Why hoisted: localeCompare with an options object resolves a fresh ICU collator
// on every comparison, so a directory sort paid for one per O(n log n) step.
// numeric: true orders "99 - a" before "100 - b", matching Finder/Explorer.
export const fileNameCollator = new Intl.Collator(undefined, { numeric: true })
export function compareFileNames(a: string, b: string): number {
return fileNameCollator.compare(a, b)
}