From 60308eaadb9a7e26f225f18d800bbe39b6dfdbf7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sat, 30 May 2026 16:18:53 -0400 Subject: [PATCH] fix: compare file explorer paths case-insensitively on Windows (#3911) --- .../editor/editor-self-write-registry.test.ts | 39 +++++++++++++++++++ .../editor/editor-self-write-registry.ts | 8 ++-- .../right-sidebar/file-explorer-paths.test.ts | 38 ++++++++++++++++++ .../right-sidebar/file-explorer-paths.ts | 31 +++++++-------- 4 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/editor/editor-self-write-registry.test.ts create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts diff --git a/src/renderer/src/components/editor/editor-self-write-registry.test.ts b/src/renderer/src/components/editor/editor-self-write-registry.test.ts new file mode 100644 index 00000000000..0b30bda1b14 --- /dev/null +++ b/src/renderer/src/components/editor/editor-self-write-registry.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/editor/editor-self-write-registry.ts b/src/renderer/src/components/editor/editor-self-write-registry.ts index 4d87dd1365e..e1a2bb1f199 100644 --- a/src/renderer/src/components/editor/editor-self-write-registry.ts +++ b/src/renderer/src/components/editor/editor-self-write-registry.ts @@ -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() 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 diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts new file mode 100644 index 00000000000..ae4bc4e7baa --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts index dd0fb93022c..1c2fa044117 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-paths.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-paths.ts @@ -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