From 81a11cded1a5b9d90ba71eae91643750184a30d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:02:08 -0700 Subject: [PATCH] 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). --- src/main/ipc/filesystem.ts | 3 +- src/main/runtime/orca-runtime-files.ts | 3 +- .../file-explorer-name-filter-projection.ts | 3 +- .../source-control-status-sort.ts | 5 +-- .../right-sidebar/source-control-tree.ts | 5 ++- src/shared/file-name-sort.test.ts | 38 +++++++++++++++++++ src/shared/file-name-sort.ts | 8 ++++ 7 files changed, 58 insertions(+), 7 deletions(-) create mode 100644 src/shared/file-name-sort.test.ts create mode 100644 src/shared/file-name-sort.ts diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 466166cfcad..06a34e6eb5a 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -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( diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 1ea6769ff4c..2ab0e0b9cc8 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -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) }) } diff --git a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts index ba057328924..9070472ed0c 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-name-filter-projection.ts @@ -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) diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts index f5ee065bc70..f22f340a855 100644 --- a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts @@ -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 ( diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.ts b/src/renderer/src/components/right-sidebar/source-control-tree.ts index 6bf94893b07..2166b1489d2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-tree.ts +++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts @@ -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 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) diff --git a/src/shared/file-name-sort.test.ts b/src/shared/file-name-sort.test.ts new file mode 100644 index 00000000000..85046075635 --- /dev/null +++ b/src/shared/file-name-sort.test.ts @@ -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' + ]) + }) +}) diff --git a/src/shared/file-name-sort.ts b/src/shared/file-name-sort.ts new file mode 100644 index 00000000000..3c1553440d7 --- /dev/null +++ b/src/shared/file-name-sort.ts @@ -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) +}