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) +}