fix: compare file explorer paths case-insensitively on Windows (#3911)

This commit is contained in:
Jinwoo Hong
2026-05-30 13:18:53 -07:00
committed by GitHub
parent 181741d769
commit 60308eaadb
4 changed files with 96 additions and 20 deletions
@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
__clearSelfWriteRegistryForTests,
clearSelfWrite,
hasRecentSelfWrite,
recordSelfWrite
} from './editor-self-write-registry'
describe('editor self-write registry', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
__clearSelfWriteRegistryForTests()
})
it('matches Windows drive paths case-insensitively', () => {
recordSelfWrite('C:\\Repo\\a.md')
expect(hasRecentSelfWrite('c:\\repo\\a.md')).toBe(true)
clearSelfWrite('c:\\repo\\a.md')
expect(hasRecentSelfWrite('C:\\Repo\\a.md')).toBe(false)
})
it('matches Windows UNC paths case-insensitively', () => {
recordSelfWrite('\\\\Server\\Share\\Repo\\a.md')
expect(hasRecentSelfWrite('\\\\server\\share\\repo\\a.md')).toBe(true)
})
it('keeps POSIX path casing distinct', () => {
recordSelfWrite('/Repo/a.md')
expect(hasRecentSelfWrite('/repo/a.md')).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { normalizeAbsolutePath } from '@/components/right-sidebar/file-explorer-paths'
import { normalizeAbsolutePathForComparison } from '@/components/right-sidebar/file-explorer-paths'
// Why: the editor's own save path writes to disk, which fans out as an
// fs:changed event back to useEditorExternalWatch a few ms later. Treating
@@ -24,18 +24,18 @@ type SelfWriteStamp = RecentSelfWrite & {
const stamps = new Map<string, SelfWriteStamp>()
export function recordSelfWrite(absolutePath: string, content?: string): void {
stamps.set(normalizeAbsolutePath(absolutePath), {
stamps.set(normalizeAbsolutePathForComparison(absolutePath), {
content: content ?? null,
expiresAt: Date.now() + SELF_WRITE_TTL_MS
})
}
export function clearSelfWrite(absolutePath: string): void {
stamps.delete(normalizeAbsolutePath(absolutePath))
stamps.delete(normalizeAbsolutePathForComparison(absolutePath))
}
export function getRecentSelfWrite(absolutePath: string): RecentSelfWrite | null {
const key = normalizeAbsolutePath(absolutePath)
const key = normalizeAbsolutePathForComparison(absolutePath)
const stamp = stamps.get(key)
if (!stamp) {
return null
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
getRevealAncestorDirs,
isPathEqualOrDescendant,
normalizeAbsolutePath
} from './file-explorer-paths'
describe('file explorer path helpers', () => {
it('preserves UNC roots while normalizing separators', () => {
expect(normalizeAbsolutePath('\\\\Server\\Share\\Repo\\')).toBe('//Server/Share/Repo')
})
it('matches Windows drive paths case-insensitively with segment boundaries', () => {
expect(isPathEqualOrDescendant('c:\\repo\\src\\a.ts', 'C:\\Repo')).toBe(true)
expect(isPathEqualOrDescendant('C:\\Repository\\src\\a.ts', 'C:\\Repo')).toBe(false)
})
it('matches Windows UNC paths case-insensitively with segment boundaries', () => {
expect(isPathEqualOrDescendant('\\\\server\\share\\repo\\src', '\\\\Server\\Share\\Repo')).toBe(
true
)
expect(
isPathEqualOrDescendant('\\\\server\\share\\repository\\src', '\\\\Server\\Share\\Repo')
).toBe(false)
})
it('keeps POSIX path comparisons case-sensitive', () => {
expect(isPathEqualOrDescendant('/Repo/src/a.ts', '/repo')).toBe(false)
})
it('builds reveal ancestors from the worktree casing and target relative casing', () => {
expect(getRevealAncestorDirs('C:\\Repo', 'c:\\repo\\Src\\Nested\\File.ts')).toEqual([
'C:\\Repo\\Src',
'C:\\Repo\\Src\\Nested'
])
expect(getRevealAncestorDirs('/repo', '/Repo/Src/File.ts')).toBeNull()
})
})
@@ -1,8 +1,14 @@
import { joinPath, normalizeRelativePath } from '@/lib/path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators,
relativePathInsideRoot
} from '../../../../shared/cross-platform-path'
import { splitPathSegments } from './path-tree'
export function normalizeAbsolutePath(path: string): string {
const normalizedPath = path.replace(/[\\/]+/g, '/')
const normalizedPath = normalizeRuntimePathSeparators(path)
if (normalizedPath === '/') {
return normalizedPath
@@ -15,28 +21,21 @@ export function normalizeAbsolutePath(path: string): string {
return normalizedPath.replace(/\/+$/, '')
}
export function normalizeAbsolutePathForComparison(path: string): string {
return normalizeRuntimePathForComparison(path)
}
export function isPathEqualOrDescendant(candidatePath: string, targetPath: string): boolean {
const normalizedCandidate = normalizeAbsolutePath(candidatePath)
const normalizedTarget = normalizeAbsolutePath(targetPath)
return (
normalizedCandidate === normalizedTarget ||
normalizedCandidate.startsWith(`${normalizedTarget}/`)
)
return isPathInsideOrEqual(targetPath, candidatePath)
}
export function getRevealAncestorDirs(worktreePath: string, filePath: string): string[] | null {
const normalizedWorktreePath = normalizeAbsolutePath(worktreePath)
const normalizedTargetPath = normalizeAbsolutePath(filePath)
const prefix = `${normalizedWorktreePath}/`
if (normalizedTargetPath !== normalizedWorktreePath && !normalizedTargetPath.startsWith(prefix)) {
const relativePath = relativePathInsideRoot(worktreePath, filePath)
if (relativePath === null) {
return null
}
const relativePath = normalizeRelativePath(
normalizedTargetPath === normalizedWorktreePath ? '' : normalizedTargetPath.slice(prefix.length)
)
const segments = splitPathSegments(relativePath)
const segments = splitPathSegments(normalizeRelativePath(relativePath))
const ancestorDirs: string[] = []
let currentPath = worktreePath