Files
orca/src/shared/quick-open-directory-reader.ts
T
Brennan Benson 6df8997c3c fix(file-explorer): sort numbered file names naturally across every listing surface (#11576)
* 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).

* fix(file-explorer): natural sort on SSH funnels, relay, and pickers

Adversarial-review round 1 rework:
- Both readDir funnels short-circuited to the SSH filesystem provider
  before the patched sort, so SSH workspaces kept lexicographic order;
  re-sort locally after the provider returns (the remote relay may be an
  older build), and fix the relay's own comparator for relay-native
  consumers.
- sortDirEntries (shared, unit-tested) owns the directories-first +
  natural-order listing contract used by every funnel.
- compareFileNames breaks numeric-collation ties ('2' vs '02') by code
  units so sibling order stays total instead of readdir order, and pins
  the collator locale to 'en' so every host produces one order.
- The SSH folder browser and runtime server dir picker now match the
  Explorer they browse into.
- Ordering pinned by tests at the relay, source-control tree, and shared
  helper.

* fix(mobile): natural sort in the mobile file explorer

Mobile re-sorted host readDir results with bare localeCompare, undoing
the host funnel's natural order (round-2 review). Reuse the shared
comparator and pin the order in the mobile suite.

* fix(file-explorer): natural sort at the renderer choke point and remaining ties

Round-3 review: the remote-runtime RPC and paired-web routes return the
host's order verbatim, so re-sort in readFileExplorerDirectory where
every desktop route converges; pin the SSH funnel with a handler-level
test; and route Source Control path compares through compareFileNames so
numeric-collation ties share one total order with the Explorer.

* docs(file-name-sort): state the real perf baseline in the hoist comment

* refactor(source-control): drop the dead collator export; pin the test oracle locale

* fix(file-listings): cover remaining natural-sort surfaces
2026-08-05 17:11:28 -07:00

64 lines
2.2 KiB
TypeScript

import { lstat, opendir } from 'node:fs/promises'
import { compareFileNames } from './file-name-sort'
import { isFileListingCancellation, throwIfFileListingCancelled } from './file-listing-cancellation'
import { isQuickOpenReadableDirectory } from './quick-open-directory-validation'
import {
assertQuickOpenReaddirDeadline,
consumeQuickOpenReaddirEntryBudget,
consumeQuickOpenReaddirPathBudget,
isQuickOpenReaddirBudgetError,
type QuickOpenReaddirBudget
} from './quick-open-readdir-budget'
export type QuickOpenDirectoryEntry = {
name: string
kind: 'directory' | 'file' | 'symlink' | 'other'
}
export async function readQuickOpenDirectoryEntries(opts: {
absPath: string
allowSymlinkedRoot: boolean
budget: QuickOpenReaddirBudget
signal?: AbortSignal
}): Promise<QuickOpenDirectoryEntry[]> {
try {
const stat = await lstat(opts.absPath)
if (!isQuickOpenReadableDirectory(stat, opts.allowSymlinkedRoot)) {
return []
}
const entries: QuickOpenDirectoryEntry[] = []
const directory = await opendir(opts.absPath)
throwIfFileListingCancelled(opts.signal)
assertQuickOpenReaddirDeadline(opts.budget)
for await (const entry of directory) {
throwIfFileListingCancelled(opts.signal)
assertQuickOpenReaddirDeadline(opts.budget)
consumeQuickOpenReaddirEntryBudget(opts.budget)
consumeQuickOpenReaddirPathBudget(opts.budget, entry.name)
entries.push({
name: entry.name,
kind: entry.isDirectory()
? 'directory'
: entry.isFile()
? 'file'
: entry.isSymbolicLink()
? 'symlink'
: 'other'
})
}
entries.sort((left, right) => compareFileNames(left.name, right.name))
// Why: discard buffered names if the path became a symlink while its
// directory handle was open; descendants must never escape the root.
const statAfterRead = await lstat(opts.absPath)
return isQuickOpenReadableDirectory(statAfterRead, opts.allowSymlinkedRoot) ? entries : []
} catch (error) {
if (isQuickOpenReaddirBudgetError(error) || isFileListingCancellation(error)) {
throw error
}
// Permission denied or a vanished subtree must not hide readable siblings.
return []
}
}